Design

Concepts

This library has two fundamental concepts:

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.

main and thread single pmr::unsynchronized_pool_resource per thread with PMR enabled.

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.

Example of an interruptible awaitable
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.

join and gather will forward interruptions, i.e. this will only interrupt g1 and g2 if gen2() completes first:

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() (always executor, must return by const ref)

  • allocator_type get_allocator() (always pmr::polymorphic_allocator<void>)

  • cancellation_slot_type get_cancellation_slot() (must have the same IF as asio::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.