Coroutine Primer
Async programming
Asynchronous programming generally refers to a style of programming that allows tasks to be run in the background, while the other works is performed.
Imagine if you will a get-request function that performs a full http request including connecting & ssl handshakes etc.
std::string http_get(std:string_view url);
int main(int argc, char * argv[])
{
auto res = http_get("https://boost.org");
printf("%s", res.c_str());
return 0;
}
The above code would be traditional synchronous programming. If we want to perform two requests in parallel we would need to create another thread to run another thread with synchronous code.
std::string http_get(std:string_view url);
int main(int argc, char * argv[])
{
std::string other_res;
std::thread thr{[&]{ other_res = http_get("https://cppalliance.org"); }};
auto res = http_get("https://boost.org");
thr.join();
printf("%s", res.c_str());
printf("%s", other_res.c_str());
return 0;
}
This works, but our program will spend most of the time waiting for input. Operating systems provide APIs that allow IO to be performed asynchronously, and libraries such as boost.asio provide portable ways to manage asynchronous operations. Asio itself does not dictate a way to handle the completions. This library (boost.async) provides a way to manage this all through coroutines/awaitables.
async::promise<std::string> http_async_get(std:string_view url);
async::main co_main(int argc, char * argv[])
{
auto [res, other_res] =
async::join(
http_async_get(("https://boost.org"),
http_async_get(("https://cppalliance.org")
);
printf("%s", res.c_str());
printf("%s", other_res.c_str());
return 0;
}
In the above code the asynchronous function to perform the request takes advantage of the operating system APIs so that the actual IO doesn’t block. This means that while we’re waiting for both functions to complete, the operations are interleaved and non-blocking. At the same time async provides the coroutine primitives that keep us out of callback hell.
Coroutines
Coroutines are resumable functions. Resumable means that a function can suspend, i.e. pass the control back to the caller multiple times.
A regular function yields control back to the caller with the return
function, where it also returns the value.
A coroutine on the other hand might yield control to the caller and get resumed multiple times.
A coroutine has three control keywords akin to co_return
(of which only co_return
has to be supported).
-
co_return
-
co_yield
-
co_await
co_return
This is similar to return
, but marks the function as a coroutine.
co_await
The co_await
expression suspends for an Awaitable,
i.e. stops execution until the awaitable
resumes it.
E.g.:
async::promise<void> delay(std::chrono::milliseconds);
async::task<void> example()
{
co_await delay(std::chrono::milliseconds(50));
}
A co_await
expression can yield a value, depending on what it is awaiting.
async::promise<std::string> read_some();
async::task<void> example()
{
std::string res = co_await read_some();
}
In async most coroutine primitives are also Awaitables.
|
co_yield
The co_yield
expression is similar to the co_await
,
but it yields control to the caller and carries a value.
For example:
async::generator<int> iota(int max)
{
int i = 0;
while (i < max)
co_yield i++;
co_return i;
}
A co_yield
expression can also produce a value,
which allows the user of yielding coroutine to push values into it.
async::generator<int, bool> iota()
{
int i = 0;
bool more = false;
do
{
more = co_yield i++;
}
while(more);
co_return -1;
}
Awaitables
Awaitables are types that can be used in a co_await
expression.
struct awaitable_prototype
{
bool await_ready();
template<typename T>
see_below await_suspend(std::coroutine_handle<T>);
return_type await_resume();
};
Type will be implicitly converted into an awaitable if there is an operator co_await call available.
This documentation will use awaitable to include these types,
and "actual_awaitable" to refer to type conforming to the above prototype.
|
In a co_await
expression the waiting coroutine will first invoke
await_ready
to check if the coroutine needs to suspend.
When ready, it goes directly to await_resume
to get the value,
as there is no suspension needed.
Otherwise, it will suspend itself and call await_suspend
with a
std::coroutine_handle
to its own promise.
std::coroutine_handle<void> can be used for type erasure.
|
The return_type is the result type of the co_await expression
, e.g. int
:
int i = co_await awaitable_with_int_result();
The return type of the await_suspend
can be three things:
-
void
-
bool
-
std::coroutine_handle<U>
If it is void the awaiting coroutine remains suspended. If it is bool
,
the value will be checked, and if falls, the awaiting coroutine will resume right away.
If a std::coroutine_handle
is returned, this coroutine will be resumed.
The latter allows await_suspend
to return the handle passed in,
being effectively the same as returning false
.
If the awaiting coroutine gets re-resumed right away, i.e. after calling await_resume,
it is referred to as "immediate completion" within this library.
This is not to be confused with a non-suspending awaitable, i.e. one that returns true
from await_ready
.
Event Loops
Since the coroutines in async
can co_await
events, they need to be run on an event-loop.
That is another piece of code is responsible for tracking outstanding event and resume a resuming coroutines that are awaiting them.
This pattern is very common and is used in a similar way by node.js or python’s asyncio
.
async
uses an asio::io_context
as its default event loop.
The event loop is accessed through an executor (following the asio terminology) and can be manually set using set_executor.