Design
Concepts
This library has two fundamental concepts:
-
coroutine
An awaitable is an expression that can be used with co_await
from within a coroutine, e.g.:
co_await delay(50ms);
An actual awaitable is a type that can be co_await
-ed from within any coroutine,
like a delay
operation.
A pseudo-awaitable
is one that can only be used in coroutines adding special
functionality for it. It is akin to a contextual pseudo-keyword.
All the verbs in the this_coro namespace are such pseudo-awaitables.
auto exec = co_await this_coro::executor;
This library exposes a set of enable_* base classes for promises,
to make the creation of custom coroutines easy.
|
A coroutine in the context of this documentation refers to an asynchronous coroutine, i.e. synchronous coroutines like generator are not considered.
All coroutines except main are also actual awaitables.
Executors
Since everything is asynchronous the library needs to use an event-loop.
Because everything is single-threaded, it can be assumed that there is exactly one executor
per thread, which will suffice for 97% of use-cases.
Therefore, there is a thread_local
executor that gets used as default
by the coroutine objects (although stored by copy in the coroutine promise).
Likewise, there is one executor
type used by the library,
which defaults to asio::any_io_executor
.
If you write your own coroutine, it should hold a copy of the executor,
and have a get_executor function returning it by const reference.
|
Using Strands
While strands can be used, they are not compatible with the thread_local
executor.
This is because they might switch threads, thus they can’t be thread_local
.
If you wish to use strands (e.g. through a spawn) the executor for any promise, generator or channel must be assigned manually.
In the case of a channel this is a constructor argument,
but for the other coroutine types, asio::executor_arg
needs to be used.
This is done by having asio::executor_arg_t
(somewhere) in the argument
list directly followed by the executor to be used in the argument list of the coroutine, e.g.:
async::promise<void> example_with_executor(int some_arg, asio::executor_arg_t, async::executor);
This way the coroutine-promise can pick up the executor from the third argument, instead of defaulting to the thread_local one.
The arguments can of course be defaulted, to make them less inconvenient, if they are sometimes with a thread_local executor.
async::promise<void> example_with_executor(int some_arg,
asio::executor_arg_t = asio::executor_arg,
async::executor = async::this_thread::get_executor());
If this gets omitted on a strand an exception of type asio::bad_allocator
is thrown,
or - worse - the wrong executor is used.
polymorphic memory resource
Similarly, the library uses a thread_local pmr::memory_resource
to allocate
coroutine frames & to use as allocator on asynchronous operations.
The reason is, that users may want to customize allocations,
e.g. to avoid locks, limit memory usage or monitor usage.
pmr
allows us to achieve this without introducing unnecessary template parameters,
i.e. no promise<T, Allocator>
complexity.
Using pmr
however does introduce some minimal overheads,
so a user has the option to disable by defining BOOST_ASYNC_NO_PMR
.
op uses an internal resource optimized for asio’s allocator usages
and gather, select and join use a monotonic resource to miminize allocations.
Both still work with BOOST_ASYNC_NO_PMR
defined, in which case they’ll use new/delete
as upstream allocations.
If you write your own coroutine, it should have a get_allocator function
returning a pmr::polymorphic_allocator<void> .
|
cancellation
async uses implicit cancellation based on asio::cancellation_signal
.
This is mostly used implicitly (e.g. with select),
so that there is very little explicit use in the examples.
If you write custom coroutine it must return a cancellation_slot from a
get_cancellation_slot function in order to be able to cancel other operations.
|
If you write a custom awaitable, it can use that function in await_suspend to receive cancellation signals. |
Promise
The main coroutine type is a promise
, which is eager.
The reason to default to this, is that the compiler can optimize out
promises that do not suspend, like this:
async::promise<void> noop()
{
co_return;
}
Awaiting the above operation is in theory a noop, but practically speaking, compilers aren’t there as of 2023.
Select
The most important synchronization mechanism is the select
function.
It awaits multiple awaitables in a pseudo-random order and will return the result of the first one completion, before disregarding the rest.
That is, it initiates the co_await
in a pseudo-random order and stops once one
awaitable is found to be ready or completed immediately.
async::generator<int> gen1();
async::generator<double> gen2();
async::promise<void> p()
{
auto g1 = gen1();
auto g2 = gen2();
while (!co_await async::this_coro::cancelled)
{
switch(auto v = co_await select(g1, g2); v.index())
{
case 0:
printf("Got int %d\n", get<0>(v));
break;
case 1:
printf("Got double %f\n", get<1>(v));
break;
}
}
}
The select
must however internally wait for all awaitable to complete
once it initiates to co_await
.
Therefor, once the first awaitable completes,
it tries to interrupt the rest, and if that fails cancels them.
select
is the preferred way to trigger cancellations, e.g:
async::promise<void> timeout();
async::promise<void> work();
select(timeout(), work());
interrupt_await
If it naively cancelled it would however lose data.
Thus, the concept of interrupt_await
is introduced,
which tells the awaitable (that supports it)
to immediately resume the awaiter and return or throw an ignored value.
struct awaitable
{
bool await_ready() const;
template<typename Promise>
std::coroutine_handle<void> await_suspend(std::coroutine_handle<Promise> h);
T await_resume();
void interrupt_await() &;
};
If the interrupt_await
doesn’t result in immediate resumption (of h
),
select
will send a cancel signal.
select
applies these with the correct reference qualification:
auto g = gen1();
select(g, gen2());
The above will call a interrupt_await() &
function for g1
and interrupt_await() &&
for g2
if available.
Generally speaking, the coroutines in async support lvalue interruption, ie. interrupt_await() & .
channel operations are unqualified, i.e. work in both cases.
|
Associators
async
uses the associator
concept of asio, but simplifies it.
That is, it has three associators that are member functions of an awaiting promise.
-
const executor_type & get_executor()
(alwaysexecutor
, must return by const ref) -
allocator_type get_allocator()
(alwayspmr::polymorphic_allocator<void>
) -
cancellation_slot_type get_cancellation_slot()
(must have the same IF asasio::cancellation_slot
)
async
uses concepts to check if those are present in its await_suspend
functions.
That way custom coroutines can support cancellation, executors and allocators.
In a custom awaitable you can obtain them like this:
struct my_awaitable
{
bool await_ready();
template<typename T>
void await_suspend(std::corutine_handle<P> h)
{
if constexpr (requires (Promise p) {p.get_executor();})
handle_executor(h.promise().get_executor();
if constexpr (requires (Promise p) {p.get_cancellation_slot();})
if ((cl = h.promise().get_cancellation_slot()).is_connected())
cl.emplace<my_cancellation>();
}
void await_resume();
};
Cancellation gets connected by co_await
statements (if supported by the coroutine & awaitable),
including synchronization mechanism like select.
Threading
This library is single-threaded by design, because this simplifies resumption and thus more performant handling of synchronizations like select. select would need to lock every selected awaitable to avoid data loss which would need to be blocking and get worse with every additional element.
you can’t have any coroutines be resumed on a different thread than created on, except for a task (e.g. using spawn). |
The main technical reason is that the most efficient way of switching coroutines is by returning the handle
of the new coroutine from await_suspend
like this:
struct my_awaitable
{
bool await_ready();
std::coroutine_handle<T> await_suspend(std::coroutine_handle<U>);
void await_resume();
};
In this case, the awaiting coroutine will be suspended before await_suspend is called, and the coroutine returned is resumed. This of course doesn’t work if we need to go through an executor.
This doesn’t only apply to awaited coroutines, but channels, too.
The channels in this library use an intrusive list of awaitables
and may return the handle of reading (and thus suspended) coroutine
from a write_operation’s await_suspend
.