Informed poll#

pw_async2: Cooperative async tasks for embedded

The informed poll programming model is the core design philosophy behind pw_async2. Informed poll is an alternative to callback-based asynchronous programming that simplifies state management for complex concurrent operations.

This overview helps you build a mental model of how informed poll works. It’s easier to build robust and correct asynchronous systems with pw_async2 when you’ve got a thorough understanding of the informed poll programming model.

Summary#

The central idea is that asynchronous work is encapsulated in Tasks, which are similar to green threads. Instead of registering callbacks for different events, a central Dispatcher polls tasks to see if they can make progress. Tasks drive one or more asynchronous operations to completion. The async operations communicate whether or not their values are ready via Futures, the basic async primitive in pw_async2. When none of the future values are ready, the task notifies the dispatcher that it can’t progress, and the dispatcher sleeps the task. When a future’s value becomes ready, the future uses a Waker to inform the dispatcher that its parent task can make more progress and therefore should be polled again.

sequenceDiagram participant d as Dispatcher participant t as Task participant o as Async Operation d->>t: Run task t->>o: Start async operation o->>t: Vend a future t->>t: Poll the future, not ready yet t->>d: Sleep o->>o: Future is ready o->>d: Wake the task d->>t: Run task t->>t: Consume the future's value

Core components#

This section provides more explanation of the core components of the pw_async2 framework: dispatchers, tasks, futures, and wakers.

The following diagram summarizes how asynchronous work generally progresses in a pw_async2 system.

stateDiagram-v2 direction LR Dispatcher --> Task Task --> Future Future --> Waker Waker --> Dispatcher

Dispatcher: The cooperative scheduler#

The dispatcher maintains a queue of tasks that are ready to be polled. The dispatcher runs whatever task has been ready the longest. There is no concept of task priority. The dispatcher drives a task forward by calling the task’s Pend() method, which is a non-virtual wrapper around DoPend(). The core logic of a task is implemented in its DoPend() method.

Note

Pend() is also the core interface for futures and coroutines.

When the dispatcher is informed that a task can’t make any more progress, the dispatcher removes the task from its ready queue and places the task in its sleep queue. When the dispatcher is informed that a sleeping task can make more progress, it places the task back into its ready queue.

Tasks: Logical collections of async work#

Tasks are similar to green threads, i.e. threads that are cooperatively scheduled by a runtime library, not preemptively scheduled by an underlying OS. Tasks usually represent logical collections of work. For example, in the pw_async2 codelab, where you write firmware for a vending machine, one task handles user input (coin insertions and item selection) while another task manages the item dispenser machinery (controlling the motors to dispense an item and detecting when an item has dropped).

Communicating task state to the dispatcher#

A task communicates to the dispatcher what state it’s in by returning one of these values in its Pend() implementation:

  • Ready(): The task has finished its work. The Dispatcher should not poll it again.

  • Pending(): The task is not yet finished because it is waiting for an external event. E.g. it’s waiting for a timer to finish or for data to arrive. The dispatcher should sleep the task and then run it again later.

Futures: The basic async primitive#

Tasks invoke asynchronous operations that return Futures, which are values that may not be ready yet. In the vending machine example mentioned in Tasks: Logical collections of async work, coin insertions, item selections, motor control, and item drop detection are all examples of async operations.

Like tasks, futures use Ready() and Pending() to communicate whether they’re complete or not. The main difference is that futures can return a value.

A task’s primary role is often to poll multiple futures to completion and coordinate the values that it’s receiving from the futures. Futures are always composable, which makes it easier for the task to manage complex asynchronous logic coming from many different async operations.

Futures are always owned by a parent task. The task is responsible for holding the state of the futures it owns and polling them all to completion, or canceling them.

Wakers: Progress updates for the dispatcher#

When a task signals to the dispatcher that it can’t make any more progress, the task must ensure that something will eventually trigger it to be run again. This is accomplished via Wakers. When a future’s value is ready, the future invokes a waker to inform the dispatcher that its parent task can make more progress. This mechanism prevents the Dispatcher from having to wastefully poll tasks that aren’t ready, allowing the task to sleep and save power when no work can be done.

Wakers are an important concept in the informed poll model, but in your code they are often an implementation detail that you usually don’t need to think about. Pigweed-provided futures like ValueFuture automatically store and invoke wakers on your behalf.

Comparison with Rust’s informed polling#

Informed polling was first proposed for Pigweed in SEED 0112. SEED 0112 was inspired by Rust’s informed polling model.

Informed polling in Rust is built around its Future trait. pw_async2 futures are conceptually very similar.

A key difference is that in Rust, async functions and blocks automatically generate state machines that implement the Future trait. In pw_async2, you can achieve a similar ergonomic benefit by using coroutines, which allow you to co_await futures. Without coroutines, you manually manage the state of futures within a Task.