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())orcoro.MakeFallible([] { return Status::ResourceExhausted(); })). The resulting future yieldsTdirectly.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 yieldsstd::optional<T>(orvoidifTisvoid), wherestd::nulloptindicates 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.