Capy-Coroutines
To make the event-driven flows manageable for users, Capy uses Capy-coroutines. Capy-coroutines are function definitions that:
-
Use one of the two C++ coroutine keywords:
co_return,co_await. -
Use an instance of class template
capy::taskas a return type.
C++ coroutines are a very versatile and a very configurable tool. Capy comes with coroutine
configuration that is tailored for the I/O-specific event-driven flows. You
employ this configuration by using capy::task return types for your coroutines.
The following example shows how Capy-coroutines look like in a user server using Capy’s framework and an accompanying library, Corosio, offering a concrete socket implementation.
capy::task<> echo_session(corosio::tcp_socket sock)
{
char buf[1024];
for (;;)
{
auto [ec1, n] = co_await sock.read_some(capy::mutable_buffer(buf, sizeof(buf)));
auto [ec2, _] = co_await capy::write(sock, capy::const_buffer(buf, n));
if (ec1 || ec2)
break;
}
}
To the caller, this appears as a function taking a socket object and returning a capy::task.
When invoked, the caller indeed obtains this task — a sort of handle — but nothing in the
coroutine body is executed. In some sense, this is analogous to creating a lambda object:
auto session = [](corosio::tcp_socket sock) { /*...*/ };
that is, you invoked something that contains the function body, you got a "handle"
(function object session), but the body has not been executed yet.
This is the most counter-intuitive thing about C++ coroutines: you get a function
which, when called, returns an object of type capy::task<> but there is no
return-statement in the function body. We cannot see in the sources the code that
performs this return: it is compiler-generated from the customization points
related to class template capy::task.
capy::task<> is an IoAwaitable.
This means it can be awaited on with expression co_await t inside any other
Capy-coroutine. When this happens, the body of the coroutine is executed.
The body of function echo_session is a callback. It is intended to
be called in response to an event: having established a TCP connection. When it
is invoked, the instructions from the body are executed up until the co_await-expression.
Expression
sock.read_some(capy::mutable_buffer(buf, sizeof(buf)))
does a couple of things. It creates a
buffer object. It is a non-owing object
that represents access to contiguous byte storage. It is conceptually similar
to std::span<byte>. Next, sock.read_some is another coroutine. This one is provided
by the library. As explained above, calling it does not execute that coroutine’s body.
It only gives us a handle: another IoAwaitable.
Only when we evaluate the co_await operator does the magic happen. This
instructs the framework to do the following things:
-
Observe on the socket for the incoming bytes.
-
When any portion of the bytes arrives put it into the provided buffer.
-
Then invoke the handler, additionally providing it the number of written bytes, and possibly information on contingencies encountered during the read.
After the registration has been completed, the framework moves on to processing other tasks in the queue, unrelated to the current coroutine.
At some point, the data on our socket will arrive and the framework will invoke the callback.
In the case of Capy-coroutines, this means resuming the coroutine right after the
co_await-expression. The expression returns value of type io_result<std::size>
which we immediately destructure to [ec1, n]. The function body keeps executing
until the next co_await-expression, where analogous event-callback association
is registered and the coroutine execution suspended.
If we look at the execution of a coroutine body, with its suspensions and resumptions,
as a series of callback registrations and invocations, we immediately see a unique challenge
with the lifetimes of arguments passed to coroutines. The entire "session" represented
by the body of echo_session needs access to socket sock. But different parts of the
session are executed at different unpredictable points in time. These points occur
long after the caller invoked echo_session, long after it passed the function arguments,
and long after it destroyed these arguments in its scope. Therefore having reference coroutine
parameters is risky. This is why echo_session takes the socket parameter by value
and then the compiler puts it in the special storage — the coroutine frame — that
lasts as long as the coroutine body has anything left to execute. Also, the byte sequence
that we use, represented by array buf needs to live between the callback calls.
This is why we represent it as an automatic object in the coroutine body.
This means it will be stored in the same coroutine frame — not in the function call stack — and therefore will survive until the end of the coroutine body.