Coroutines#

pw_async2: Cooperative async tasks for embedded

For projects using C++20, pw_async2 provides first-class support for coroutines via Coro. This allows writing asynchronous logic in a sequential, synchronous style, eliminating the need to write explicit state machines. The co_await keyword is used to suspend execution until an asynchronous operation is Ready.

See also Pigweed Blog #5: C++20 coroutines without heap allocation, a blog post on how Pigweed implements coroutines without heap allocation, and challenges encountered along the way.

Using coroutines#

Define a coroutine#

A pw_async2 coroutine is a function with CoroContext as its first parameter and a return type of Coro<T>. Here is an example of a coroutine that returns pw::Status:

 1#include "pw_async2/channel.h"
 2#include "pw_async2/coro.h"
 3#include "pw_log/log.h"
 4#include "pw_result/result.h"
 5#include "pw_status/status.h"
 6
 7namespace {
 8
 9using ::pw::OkStatus;
10using ::pw::Status;
11using ::pw::async2::Coro;
12using ::pw::async2::CoroContext;
13using ::pw::async2::Receiver;
14using ::pw::async2::Sender;
15
16/// Create a coroutine which asynchronously receives a value from `receiver` and
17/// forwards it to `sender`.
18///
19/// Note: the `CoroContext` argument is used by the `Coro<T>` internals to
20/// allocate the coroutine state. If this allocation fails, the coroutine
21/// aborts.
22Coro<Status> ForwardingCoro(CoroContext,
23                            Receiver<int> receiver,
24                            Sender<int> sender) {
25  std::optional<int> data = co_await receiver.Receive();
26  if (!data.has_value()) {
27    PW_LOG_ERROR("Receiving failed: channel has closed");
28    co_return Status::Unavailable();
29  }
30
31  if (!(co_await sender.Send(*data))) {
32    PW_LOG_ERROR("Sending failed: channel has closed");
33    co_return Status::Unavailable();
34  }
35
36  co_return OkStatus();
37}
38
39}  // namespace

Any future or coroutine can be passed to co_await, which evaluates to a value_type when the result is ready. To return from a coroutine, use co_return <expression> instead of the usual return <expression> syntax.

Tip

Use PW_CO_TRY and PW_CO_TRY_ASSIGN instead of PW_TRY and PW_TRY_ASSIGN when working with pw::Status or pw::Result in a coroutine. These macros use co_return instead of return.

Run a coroutine#

Run a coroutine as a pw_async2 task using Dispatcher::Post(). The following posts a coroutine as a FutureTask:

  SharedPtr<FutureTask<Coro<Status>>> task = dispatcher.Post<ForwardingCoro>(
      allocator, std::move(receiver1_), std::move(sender2_));

  // The task is automatically posted when allocated.
  dispatcher.RunToCompletion();

The coroutine can also be instantiated directly and passed to Post, though this requires listing the allocator twice:

  SharedPtr<FutureTask<Coro<Status>>> task = dispatcher.Post(
      allocator,
      ForwardingCoro(allocator, std::move(receiver1_), std::move(sender2_)));

  // The task is automatically posted when allocated.
  dispatcher.RunToCompletion();

In the examples above, Dispatcher::Post() returns nullptr if either the initial coroutine frame or the FutureTask fails to allocate. However, if a nested coroutine invoked with co_await fails to allocate while the coroutine is running (or if an unallocated Coro<T> is pended directly), Coro::Pend crashes with PW_CRASH.

To handle coroutine allocation failures gracefully, wrap the coroutine with Coro::MakeFallible:

  auto task = dispatcher.Post(
      allocator,
      ForwardingCoro(allocator, std::move(receiver1_), std::move(sender2_))
          .MakeFallible([] {
            PW_LOG_ERROR("coroutine allocation failed! Aborting...");
            return Status::ResourceExhausted();
          }));

  // The task is automatically posted when allocated.
  dispatcher.RunToCompletion();

MakeFallible returns a FallibleCoro that intercepts allocation failures (both during initial coroutine creation and when invoking nested coroutines with co_await) and resolves gracefully rather than crashing:

  • Fallback value: Pass a fallback value or an error handler function that returns a fallback value (e.g. coro.MakeFallible(Status::ResourceExhausted()) or coro.MakeFallible([] { return Status::ResourceExhausted(); })). The resulting future yields T directly.

      auto task = dispatcher.Post(
          allocator,
          ForwardingCoro(allocator, std::move(receiver1_), std::move(sender2_))
              .MakeFallible(Status::ResourceExhausted()));
    
      dispatcher.RunToCompletion();
    
  • Optional value: If the handler returns void (e.g. coro.MakeFallible([] { PW_LOG_ERROR("Alloc failed"); })) or no arguments are passed (coro.MakeFallible()), the resulting future yields std::optional<T> (or void if T is void), where std::nullopt indicates an allocation failure.

MakeFallible can also be applied to a nested coroutine inside co_await (e.g. co_await InnerCoro(cx).MakeFallible(Status::ResourceExhausted())) to handle a child coroutine’s allocation failure locally without aborting the calling coroutine.

FutureTask (or the CoroTask and FallibleCoroTask aliases in pw_async2/coro_task.h, which discard the coroutine’s return value) can also be stack or statically allocated instead of dynamically allocated with Dispatcher::Post(). This is not recommended, as it is more complex and does not eliminate all allocations. Coroutines always dynamically allocate their frames.

  // NOT RECOMMENDED: Manually declare a FutureTask to wrap a Coro. The
  // coroutine itself still requires dynamic allocation.
  pw::async2::FutureTask task(
      ForwardingCoro(allocator, std::move(receiver1_), std::move(sender2_)));

  dispatcher.Post(task);
  dispatcher.RunToCompletion();

For more details about Pigweed’s coroutine support, see Coro.

Memory#

When using C++20 coroutines, the compiler generates code to save the coroutine’s state (including arguments and local variables) across suspension points (co_await). pw_async2 hooks into this mechanism to control where this state is stored and to support gracefully handling allocation failures.

A pw_async2 coroutine must accept a CoroContext by value as its first argument. CoroContext wraps a reference to a pw::Allocator, and this allocator is used to allocate the coroutine frame. When instantiating a coroutine, simply pass an allocator as the first argument; CoroContext is implicitly constructible from an Allocator&.

If initial coroutine frame allocation fails, the resulting Coro object is invalid (!coro.is_pendable()) and the coroutine body does not execute. If a nested coroutine invoked via co_await fails to allocate while a coroutine is running, the coroutine aborts and unwinds its active coroutine frames, destroying any local variables in scope.

Pending an invalid Coro directly or encountering a nested allocation failure in Coro::Pend crashes with PW_CRASH. To handle initial and nested allocation failures without crashing, wrap the coroutine with MakeFallible.

Passing data between coroutines#

Coroutines run within pw_async2 tasks and can pass data in all the same ways. See Channels for details about passing data with channels.