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 that 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 CoroTask:

  SharedPtr<CoroTask<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<CoroTask<Status>> task = dispatcher.Post(
      allocator,
      ForwardingCoro(allocator, std::move(receiver1_), std::move(sender2_)));

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

The previous examples use CoroTask, which crashes if coroutione stack allocation fails. To handle allocation failures gracefully with FallibleCoroTask, pass an allocation error handler function after the coroutine:

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

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

CoroTask or FallibleCoroTask can 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 stacks.

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

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

For a 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 local variables) across suspension points (co_await). pw_async2 hooks into this mechanism to control where this state is stored and 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 allocation fails, the resulting Coro object is invalid. Coroutine execution halts, and what happens next depends on the task executing the coroutine. CoroTask crashes with PW_CRASH on allocation failure. FallibleCoroTask invokes an error handler function instead.

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.