Asynchronous Programming with Seastar

Nadav Har’El - nyh@ScyllaDB.com

Avi Kivity - avi@ScyllaDB.com

Back to table of contents. Previous: 3 Threads and memory. Next: 5 Continuations.

4 Introducing futures and continuations

Futures and continuations, which we will introduce now, are the building blocks of asynchronous programming in Seastar. Their strength lies in the ease of composing them together into a large, complex, asynchronous program, while keeping the code fairly readable and understandable.

A future is a result of a computation that may not be available yet. Examples include:

The type future<int> variable holds an int that will eventually be available - at this point might already be available, or might not be available yet. The method available() tests if a value is already available, and the method get() gets the value. The type future<> indicates something which will eventually complete, but not return any value.

A future is usually returned by an asynchronous function, a function which returns a future and arranges for this future to be eventually resolved. Because asynchrnous functions promise to eventually resolve the future which they returned, asynchronous functions are sometimes called “promises”; But we will avoid this term because it tends to confuse more than it explains.

One simple example of an asynchronous function is Seastar’s function sleep():

future<> sleep(std::chrono::duration<Rep, Period> dur);

This function arranges a timer so that the returned future becomes available (without an associated value) when the given time duration elapses.

A continuation is a callback (typically a lambda) to run when a future becomes available. A continuation is attached to a future with the then() method. Here is a simple example:

#include <seastar/core/app-template.hh>
#include <seastar/core/sleep.hh>
#include <iostream>

int main(int argc, char** argv) {
    seastar::app_template app;
    app.run(argc, argv, [] {
        std::cout << "Sleeping... " << std::flush;
        using namespace std::chrono_literals;
        return seastar::sleep(1s).then([] {
            std::cout << "Done.\n";
        });
    });
}

In this example we see us getting a future from seastar::sleep(1s), and attaching to it a continuation which prints a “Done.” message. The future will become available after 1 second has passed, at which point the continuation is executed. Running this program, we indeed see the message “Sleeping…” immediately, and one second later the message “Done.” appears and the program exits.

The return value of then() is itself a future which is useful for chaining multiple continuations one after another, as we will explain below. But here we just note that we return this future from app.run’s function, so that the program will exit only after both the sleep and its continuation are done.

To avoid repeating the boilerplate “app_engine” part in every code example in this tutorial, let’s create a simple main() with which we will compile the following examples. This main just calls function future<> f(), does the appropriate exception handling, and exits when the future returned by f is resolved:

#include <seastar/core/app-template.hh>
#include <seastar/util/log.hh>
#include <iostream>
#include <stdexcept>

extern seastar::future<> f();

int main(int argc, char** argv) {
    seastar::app_template app;
    try {
        app.run(argc, argv, f);
    } catch(...) {
        std::cerr << "Couldn't start application: "
                  << std::current_exception() << "\n";
        return 1;
    }
    return 0;
}

Compiling together with this main.cc, the above sleep() example code becomes:

#include <seastar/core/sleep.hh>
#include <iostream>

seastar::future<> f() {
    std::cout << "Sleeping... " << std::flush;
    using namespace std::chrono_literals;
    return seastar::sleep(1s).then([] {
        std::cout << "Done.\n";
    });
}

So far, this example was not very interesting - there is no parallelism, and the same thing could have been achieved by the normal blocking POSIX sleep(). Things become much more interesting when we start several sleep() futures in parallel, and attach a different continuation to each. Futures and continuation make parallelism very easy and natural:

#include <seastar/core/sleep.hh>
#include <iostream>

seastar::future<> f() {
    std::cout << "Sleeping... " << std::flush;
    using namespace std::chrono_literals;
    seastar::sleep(200ms).then([] { std::cout << "200ms " << std::flush; });
    seastar::sleep(100ms).then([] { std::cout << "100ms " << std::flush; });
    return seastar::sleep(1s).then([] { std::cout << "Done.\n"; });
}

Each sleep() and then() call returns immediately: sleep() just starts the requested timer, and then() sets up the function to call when the timer expires. So all three lines happen immediately and f returns. Only then, the event loop starts to wait for the three outstanding futures to become ready, and when each one becomes ready, the continuation attached to it is run. The output of the above program is of course:

$ ./a.out
Sleeping... 100ms 200ms Done.

sleep() returns future<>, meaning it will complete at a future time, but once complete, does not return any value. More interesting futures do specify a value of any type (or multiple values) that will become available later. In the following example, we have a function returning a future<int>, and a continuation to be run once this value becomes available. Note how the continuation gets the future’s value as a parameter:

#include <seastar/core/sleep.hh>
#include <iostream>

seastar::future<int> slow() {
    using namespace std::chrono_literals;
    return seastar::sleep(100ms).then([] { return 3; });
}

seastar::future<> f() {
    return slow().then([] (int val) {
        std::cout << "Got " << val << "\n";
    });
}

The function slow() deserves more explanation. As usual, this function returns a future<int> immediately, and doesn’t wait for the sleep to complete, and the code in f() can chain a continuation to this future’s completion. The future returned by slow() is itself a chain of futures: It will become ready once sleep’s future becomes ready and then the value 3 is returned. We’ll explain below in more details how then() returns a future, and how this allows chaining futures.

This example begins to show the convenience of the futures programming model, which allows the programmer to neatly encapsulate complex asynchronous operations. slow() might involve a complex asynchronous operation requiring multiple steps, but its user can use it just as easily as a simple sleep(), and Seastar’s engine takes care of running the continuations whose futures have become ready at the right time.

4.1 Ready futures

A future value might already be ready when then() is called to chain a continuation to it. This important case is optimized, and usually the continuation is run immediately instead of being registered to run later in the next iteration of the event loop.

This optimization is done usually, though sometimes it is avoided: The implementation of then() holds a counter of such immediate continuations, and after many continuations have been run immediately without returning to the event loop (currently the limit is 256), the next continuation is deferred to the event loop in any case. This is important because in some cases (such as future loops, discussed later) we could find that each ready continuation spawns a new one, and without this limit we can starve the event loop. It important not to starve the event loop, as this would starve continuations of futures that weren’t ready but have since become ready, and also starve the important polling done by the event loop (e.g., checking whether there is new activity on the network card).

make_ready_future<> can be used to return a future which is already ready. The following example is identical to the previous one, except the promise function fast() returns a future which is already ready, and not one which will be ready in a second as in the previous example. The nice thing is that the consumer of the future does not care, and uses the future in the same way in both cases.

#include <seastar/core/future.hh>
#include <iostream>

seastar::future<int> fast() {
    return seastar::make_ready_future<int>(3);
}

seastar::future<> f() {
    return fast().then([] (int val) {
        std::cout << "Got " << val << "\n";
    });
}