| name | pw_async2 |
| description | Best practices for pw_async2 development and testing, covering futures, values, tasks, dispatchers, polling, and coroutines. |
pw_async2 development instructions and skill guide
Use this skill when writing, reviewing, refactoring, or testing code that uses
Pigweed's pw_async2 cooperative asynchronous framework.
1. System topology and the 3-tier execution graph
Structure pw_async2 systems using a strict 3-tier execution graph:
- Top level: Tasks (posted to dispatcher)
- Inherits from
pw::async2::Task or uses CoroTask/FuncTask.
- Registered with
Dispatcher; holds task lifecycle state.
- Middle level: Composite futures and async helper functions
- Pure C++ value objects returned directly on the stack.
- NO
FutureCore, NO Wakers, NO providers, NO dynamic allocation.
- Composes subfutures inline, transitively passing
Context&.
- Leaf level: Primitive wakeable futures
ValueFuture<T>, TimeFuture<Clock>, Channel Receiver/Sender.
- Uses
FutureCore / Waker / Provider; interacts with ISRs/timers.
Directives
- Top-level tasks: Use
pw::async2::Task only for top-level entry
points (background worker loops, main event loops). Do NOT make mid-level
operations Tasks.
- Mid-level logic: Implement mid-level logic as composite futures
returned by factory functions. Store child futures inline as member
variables.
- Leaf futures: Use primitive futures (
ValueFuture, TimeFuture,
Channel) or FutureCore only when interfacing directly with hardware,
timers, or event providers.
2. What is a future? (Concept and invariants)
A Future is a stack-allocated state machine representing an asynchronous
operation that produces a value_type upon completion.
The Future C++ concept
Futures do NOT derive from a common polymorphic base class. Instead, they
satisfy the pw::async2::Future concept defined in pw_async2/future.h. Any
concrete type F is a Future if it exposes:
typename F::value_type: Result type (e.g. pw::Result<int>, pw::Status,
void).
Poll<value_type> Pend(Context& cx): Advances the operation. Returns
Ready(T) when complete or Pending() when blocked.
bool is_pendable() const: Returns true if active/pollable.
bool is_complete() const: Returns true if completed.
- Special member functions: Default-constructible, destructible, movable.
[!TIP]
ALWAYS verify custom future types with static_assert: Immediately after
defining any custom future class, add a static assertion to enforce compliance
with pw::async2::Future at compile time:
static_assert(pw::async2::Future<MyCustomFuture>);
Fundamental invariants
- Lazy execution: Creating a future executes no work. Work occurs only when
polled via
Pend(Context& cx).
- Explicit single ownership: The caller owns the
Future by value on the
stack or inline inside a parent struct. There is no std::shared_ptr or
hidden heap allocation.
- Zero-cost RAII cancellation: Destructing a future cancels the operation
immediately and synchronously. Leaf futures unlist from providers and drop
wakers; composite futures recursively destruct child subfutures inline.
- Single-use completion: Once
Pend() returns Ready(), the operation is
final. Do NOT poll a completed future again.
3. Futures vs. pendable functions
Avoid writing pendable functions—helper methods or standalone functions that
accept Context& cx and return Poll<T> directly:
Poll<pw::Result<int>> PendReadSensor(Context& cx,
Sensor& sensor,
int& retries);
Pendable functions operate within pw_async2's informed poll model, but they
make the asynchronous contract bespoke and inscrutable:
- Opaque state and ownership: It is impossible to tell from the signature
what state the function maintains, where that state is stored, or who
currently owns it. State easily leaks across parent Task member variables or
lambda captures, creating brittle, error-prone state management.
- No self-describing lifecycle: A pendable function cannot report whether it
is pendable or complete (
is_pendable(), is_complete()), forcing callers
to maintain external flags to track execution state.
- No RAII cancellation: A pendable function cannot be cleanly canceled by
dropping a value object. Destructing a task with active pendable
sub-operations risks leaving underlying resources or timers in indeterminate
states.
- Incompatible with combinators and coroutines: You cannot
co_await,
PW_AWAIT, Select, or Join an arbitrary Poll<T> function. All
pw_async2 combinators, macros, and coroutines require a concrete type
fulfilling the Future concept.
Exceptions (when pendable functions are acceptable)
Pendable functions should be avoided for general application logic and
public/module APIs. They are acceptable only for very low-level internal
helper functions that are strictly private to a single class or file and
consumed from a single caller by design.
In all other cases, wrap mid-level async operations in a concrete Future type
returned by value.
4. Implementing composite and leaf futures
A. Leaf futures (external event signals)
Leaf futures interact directly with event providers (hardware interrupts,
timers, etc.) and manage waker registration.
[!NOTE]
Prefer pre-built primitives: In most cases, do NOT write a custom leaf
future from scratch. Use existing primitives like ValueFuture<T> (with
ValueProvider or ValueListProvider), Notification, or Channel.
Pre-built primitives handle synchronization, thread safety, and waker
management automatically.
When a custom leaf future is required (e.g. interfacing with a low-level
hardware driver):
- Store a
pw::async2::FutureCore member to manage wakers, intrusive list
membership, and state tracking.
- Movability and intrusive relocation: Leaf futures must be
move-constructible and move-assignable (
ButtonFuture(ButtonFuture&&) = default;). FutureCore handles movability automatically: when a future is
moved by value (e.g. returned from a factory function or stored in a task
member), FutureCore automatically updates its intrusive list pointers in
the provider's FutureList and transfers the Waker to the new memory
location. Note that FutureCore itself is lock-free; synchronizing access
across threads or ISR contexts is the responsibility of the user (e.g. via
spinlocks, mutexes, or global locks).
- Delegate public
Pend(cx) to core_.DoPend(*this, cx).
- Implement a private
DoPend(Context& cx) callback friended by FutureCore.
- Store pending futures in a
pw::async2::FutureList inside the provider
class.
For full implementation patterns, see:
B. Composite futures (mid-level operations)
Composite futures encapsulate multi-step asynchronous operations without using
FutureCore or registering wakers:
- Do NOT inherit from
FutureCore or use Wakers.
- Store child subfutures inline as member variables.
- Pass
Context& cx down transitively into subfutures via PW_AWAIT or
subfuture_.Pend(cx).
- Loop state transitions immediately (
while (true) switch (state_)) to pend
newly created subfutures within the same Pend(cx) call. Returning
Pending() before pending a new subfuture prevents waker registration,
causing the task to stall indefinitely.
For full implementation patterns, see:
5. Async API design rules
1. Direct return of futures
Async functions MUST return Future objects directly by value. Do NOT
wrap futures in pw::Result<Future> or std::optional<Future>.
Why:
- Composability: Returning a future directly allows callers to seamlessly
pass the returned future to combinators (
Select, Join), macros
(PW_AWAIT), or coroutines (co_await). Wrapping the future in a
pw::Result or std::optional breaks composition, forcing callers to
perform awkward nested unwrapping before polling.
- Fallible operations: If an operation can fail (or fail to produce a
value), move the
pw::Result<T>, pw::Status, or std::optional<T>
inside the future's result type (value_type), e.g.,
ReadSensorFuture::value_type is pw::Result<int>.
- Synchronous failures: If an operation fails synchronously during factory
argument validation, return a future that immediately resolves to that error
state rather than failing out-of-band.
ReadSensorWithRetryFuture ReadSensorWithRetry(Sensor& sensor, ...);
pw::Result<ReadSensorWithRetryFuture> ReadSensorWithRetry(Sensor& sensor);
std::optional<ReadSensorWithRetryFuture> TryReadSensor(Sensor& sensor);
2. Synchronous argument validation
Validate arguments synchronously in the factory function before future
construction. Return an immediately-resolving error future if validation
fails:
inline ReadSensorWithRetryFuture ReadSensorWithRetry(
MockSensor& sensor,
SimulatedTimeProvider<SystemClock>& time_provider,
int max_retries = 3) {
if (max_retries <= 0) {
return ReadSensorWithRetryFuture(pw::Status::InvalidArgument());
}
return ReadSensorWithRetryFuture(sensor, time_provider, max_retries);
}
3. Enforce factory construction
Make composite future constructors private and friend the factory function to
prevent callers from bypassing validation.
4. Strict naming conventions
- Future-returning functions: Name for the operation (
Read(), Write()).
NEVER use Async in function names (AsyncRead() ❌) or name after
future types (GetReadFuture() ❌).
- Non-blocking immediate operations: Prefix with
Try (std::optional<T> TryRead()).
- Thread-blocking operations: Prefix with
Blocking (pw::Result<T> BlockingRead()).
6. Tasks and lifecycle management
Creating tasks
Subclass pw::async2::Task and implement DoPend(Context& cx):
class SensorReaderTask : public pw::async2::Task {
public:
SensorReaderTask(MockSensor& sensor,
SimulatedTimeProvider<SystemClock>& time_provider)
: sensor_(&sensor), time_provider_(&time_provider) {}
pw::Result<int> result() const { return result_; }
private:
Poll<> DoPend(Context& cx) override {
if (!read_retry_future_.is_pendable()) {
read_retry_future_ = ReadSensorWithRetry(*sensor_, *time_provider_, 2);
}
PW_AWAIT(auto res, read_retry_future_, cx);
result_ = res;
return pw::async2::Ready();
}
MockSensor* sensor_ = nullptr;
SimulatedTimeProvider<SystemClock>* time_provider_ = nullptr;
ReadSensorWithRetryFuture read_retry_future_;
pw::Result<int> result_ = pw::Status::Unknown();
};
[!IMPORTANT]
Lazy Future Provisioning: Do NOT construct futures in a Task's
constructor. Constructing futures eagerly in constructors can trigger side
effects (e.g. initiating hardware transactions or timer registrations) before
the task is posted to or polled by a dispatcher, defeating lazy execution
invariants. Always provision subfutures lazily inside DoPend(Context& cx)
when !subfuture_.is_pendable().
Task deregistration requirement (CRITICAL)
Every posted task MUST be explicitly deregistered before destruction:
SensorReaderTask task(sensor, time_provider);
dispatcher.Post(task);
task.Deregister();
7. C++20 coroutines (pw::async2::Coro)
[!NOTE]
Prefer manual state machines using composite futures and PW_AWAIT by default
due to coroutine frame memory overhead and code size. Use Coro<T> when
coroutines are explicitly requested or already established in the codebase.
If working with coroutines, view
references/coroutines.md for detailed patterns,
error handling with PW_CO_TRY / PW_CO_TRY_ASSIGN, and memory guidelines.
8. Channels (pw::async2::Channel)
If working with channels for message passing or sync-to-async bridging, view
references/channels.md for channel creation
(SPSC/MPSC/SPMC/MPMC), Send/Receive futures, and non-blocking
Try/Blocking APIs.
9. Testing async code
If writing unit tests for pw_async2 tasks, futures, channels, or
time-dependent operations, view references/testing.md
for deterministic unit testing patterns with DispatcherForTest and
SimulatedTimeProvider.
10. Rules and anti-pattern checklist