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