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 asynchronous 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():
<> sleep(std::chrono::duration<Rep, Period> dur); future
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) {
::app_template app;
seastar.run(argc, argv, [] {
appstd::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) {
::app_template app;
seastartry {
.run(argc, argv, f);
app} 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>
::future<> f() {
seastarstd::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>
::future<> f() {
seastarstd::cout << "Sleeping... " << std::flush;
using namespace std::chrono_literals;
::sleep(200ms).then([] { std::cout << "200ms " << std::flush; });
seastar::sleep(100ms).then([] { std::cout << "100ms " << std::flush; });
seastarreturn 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>
::future<int> slow() {
seastarusing namespace std::chrono_literals;
return seastar::sleep(100ms).then([] { return 3; });
}
::future<> f() {
seastarreturn 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.
A future value might already be ready when then()
is
called to chain a continuation to it. This important case is optimized,
and the continuation is run immediately instead of being registered to
run later in the next iteration of the event loop.
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>
::future<int> fast() {
seastarreturn seastar::make_ready_future<int>(3);
}
::future<> f() {
seastarreturn fast().then([] (int val) {
std::cout << "Got " << val << "\n";
});
}
As described above, an existing fiber of execution will yield back to the event loop when it performs a blocking operation such as IO or sleeping, as it has no more work to do until this blocking operation completes. Should a fiber have a lot of CPU bound work to do without any intervening blocking operations, however, it is important that execution is still yielded back to the event loop periodically.
This is implemented via preemption: which can only occur at specific preemption points. At these points the fiber’s remaining task quota is checked and it has been exceeded the fiber yields. The task quota is a measure of how long tasks should be allowed to run before yielding to the event loop, and is set to 500 µs by default.
It is 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). For example, iterating over a large container while doing CPU-bound work without any suspension points could starve the reactor and cause a reactor stall, which refers to a substantial period of time (e.g., more than 20 milliseconds) during which a task does not yield.
Many seastar constructs such as looping constructs have built-in
preemption points. You may also insert your own preemption points by
calling seastar::maybe_yield
, which performs a preemption
check. Coroutines will also perform a preemption check at each
co_await
. Note that there is not a preemption
check between continuations attached to a future with
then()
, so a recursive future loop without explicit
preemption checks may starve the reactor.