Codelab#
pw_async2: Cooperative async tasks for embedded
Welcome to the pw_async2
codelab!
This codelab provides a hands-on introduction to Pigweed’s cooperative
asynchronous framework. You will build a simple, simulated “Vending Machine”
application — an automated machine that asynchronously waits for user input
like coin insertion and keypad presses before dispensing an item. Along the
way, you’ll learn the core concepts of pw_async2
.
By the end of this codelab, you will know how to:
Implement a
pw::async2::Task
as a state machine.Call asynchronous functions and manage state across suspension points.
Write your own pendable functions that use a
Waker
to handle external events.Use
pw::async2::OnceSender
andpw::async2::OnceReceiver
for basic inter-task communication.Use
pw::async2::TimeProvider
andpw::async2::Select
to implement timeouts.
Let’s get started!
Setup#
The code for this codelab is part of the Pigweed repository. If you haven’t
already, follow the contributor guide to clone the Pigweed repository and set up your development environment.
Step 1: Hello, Async World!#
The first step is to create and run a basic asynchronous task. This will
introduce you to the two most fundamental components of pw_async2
: the
Task and the Dispatcher.
Tip
We encourage you to implement each step on your own, but if you ever get stuck, a solution is provided at the start of each step.
The solution for this step is here: //pw_async2/codelab/solutions/step1
What’s a Task?#
A pw::async2::Task is the basic unit of execution in this framework. It’s an object that represents a job to be done, like blinking an LED, processing sensor data, or, in our case, running a vending machine.
Tasks are implemented by inheriting from the pw::async2::Task
class and
implementing a single virtual method: DoPend()
. This method is where the
task’s logic lives.
Let’s look at the code.
You’ll see the definition for our VendingMachineTask
:
1#pragma once
2
3#include "pw_async2/task.h"
4
5namespace codelab {
6
7// The main task that drives the vending machine.
8class VendingMachineTask : public pw::async2::Task {
9 public:
10 VendingMachineTask()
11 : pw::async2::Task(PW_ASYNC_TASK_NAME("VendingMachineTask")) {}
12
13 private:
14 // This is the core of the asynchronous task. The dispatcher calls this method
15 // to give the task a chance to do work.
16 pw::async2::Poll<> DoPend(pw::async2::Context& cx) override;
17};
18
19} // namespace codelab
It’s a simple class that inherits from pw::async2::Task
. The important part
is the DoPend
method, which is where we’ll add our logic.
Here you’ll find the incomplete implementation of DoPend
:
1#include "vending_machine.h"
2
3#include "pw_async2/try.h"
4#include "pw_log/log.h"
5
6namespace codelab {
7
8pw::async2::Poll<> VendingMachineTask::DoPend(pw::async2::Context&) {
9 // Fill in your implementation here.
10 return pw::async2::Ready();
11}
12
13} // namespace codelab
The DoPend
method returns a pw::async2::Poll. A Poll
can
be in one of two states:
Ready()
: The task has finished its work.Pending()
: The task is not yet finished and should be run again later.
Our stub currently simply returns Ready()
, meaning it would exit
immediately without doing any work.
What’s a Dispatcher?#
A pw::async2::Dispatcher is the engine that runs the tasks. It’s a
simple, cooperative scheduler. You give it tasks by calling Post()
, and
then you tell it to run them by calling RunUntilStalled()
or
RunToCompletion()
.
The dispatcher maintains a queue of tasks that are ready to be polled. When a
run is triggered, it pulls a task from the queue and invokes its DoPend()
method. If the task returns Pending()
, the task is put to sleep until it
is woken by the operation that blocked it. If it returns Ready()
, the
dispatcher considers it complete and will not run it again.
Here you can see we have a dispatcher, but it’s not doing anything yet.
1#include "coin_slot.h"
2#include "hardware.h"
3#include "pw_async2/dispatcher.h"
4#include "vending_machine.h"
5
6namespace {
7
8codelab::CoinSlot coin_slot;
9
10} // namespace
11
12// Interrupt handler function invoked when the user inserts a coin into the
13// vending machine.
14void coin_inserted_isr() { coin_slot.Deposit(); }
15
16// Interrupt handler function invoked when the user presses a key on the
17// machine's keypad. Receives the value of the pressed key (0-9).
18void key_press_isr(int /*key*/) {
19 // In Step 3, implement your keypad handler here.
20}
21
22int main() {
23 pw::async2::Dispatcher dispatcher;
24 codelab::HardwareInit(&dispatcher);
25
26 // Fill in your implementation here.
27
28 return 0;
29}
Putting it all together#
Now, let’s modify the code to print a welcome message.
In //pw_async2/codelab/vending_machine.cc, change DoPend
to log the
message:
Welcome to the Pigweed Vending Machine!
Keep the Ready()
return, telling the dispatcher to complete the task after
it has logged.
Post and Run the Task
In //pw_async2/codelab/main.cc, create an instance of your vending machine task and give it to the dispatcher to run.
1#include "coin_slot.h"
2#include "hardware.h"
3#include "pw_async2/dispatcher.h"
4#include "vending_machine.h"
5
6namespace {
7
8codelab::CoinSlot coin_slot;
9
10} // namespace
11
12// Interrupt handler function invoked when the user inserts a coin into the
13// vending machine.
14void coin_inserted_isr() { coin_slot.Deposit(); }
15
16// Interrupt handler function invoked when the user presses a key on the
17// machine's keypad. Receives the value of the pressed key (0-9).
18void key_press_isr(int /*key*/) {
19 // In Step 3, implement your keypad handler here.
20}
21
22int main() {
23 pw::async2::Dispatcher dispatcher;
24
25 codelab::VendingMachineTask task;
26 dispatcher.Post(task);
27
28 dispatcher.RunToCompletion();
29
30 return 0;
31}
Here, dispatcher.Post(task)
adds our task to the dispatcher’s run queue.
dispatcher.RunToCompletion()
tells the dispatcher to run all of its tasks
until they have all returned Ready()
.
6. Build and Run
Now, build and run the codelab target from the root of the Pigweed repository:
bazelisk run //pw_async2/codelab
You should see the following output:
INF Welcome to the Pigweed Vending Machine!
Congratulations! You’ve written and run your first asynchronous task with
pw_async2
. In the next step, you’ll learn how to have your task call run
asynchronous operations.
Step 2: Calling an async function#
In the last step, our task ran from start to finish without stopping. Most real-world tasks, however, need to wait for things: a timer to expire, a network packet to arrive, or, in our case, a user to insert a coin.
In pw_async2
, operations that can wait are called pendable functions.
Tip
Solution for this step: //pw_async2/codelab/solutions/step2
What’s a Pendable function?#
A pendable function is a function that, like a Task
implementation’s
DoPend
method, takes an async Context and
returns a Poll
of some value. When a task calls a pendable function, it
checks the return value to determine how to proceed.
If it’s
Ready(value)
, the operation is complete, and the task can continue with thevalue
.If it’s
Pending()
, the operation is not yet complete. The task will generally stop and returnPending()
itself, effectively “sleeping” until it is woken up.
When a task is sleeping, it doesn’t consume any CPU cycles. The Dispatcher
simply won’t poll it again until an external event wakes it up. This is the core
of cooperative multitasking.
For this step, we’ve provided a CoinSlot
class with a pendable function to
read the number of coins inserted:
pw::async2::Poll<unsigned> Pend(pw::async2::Context& cx)
. Let’s use it.
1. Add a CoinSlot
to the vending machine#
First, open //pw_async2/codelab/vending_machine.h. You’ll need to include
coin_slot.h
. Add a reference to a CoinSlot
as a member variable of your
VendingMachineTask
and update its constructor to initialize it.
In your //pw_async2/codelab/main.cc, we have provided a global CoinSlot
instance. Pass it into your updated task.
2. Wait for a coin#
Now, let’s modify the task’s DoPend
in
//pw_async2/codelab/vending_machine.cc. Following your welcome message from
Step 1, prompt the user to insert a coin.
To get a coin from the CoinSlot
, you’ll call its Pend
method using the
PW_TRY_READY_ASSIGN macro.
Use the macro to poll coin_slot_.Pend(cx)
. If it’s ready, log that a coin
was detected and that an item is being dispensed. Finally, return
pw::async2::Ready()
to finish the task.
3. Build and run: Spot the issue#
Run your vending machine as before:
bazelisk run //pw_async2/codelab
You will see the welcome message, and then the application will wait for your input.
INF Welcome to the Pigweed Vending Machine!
INF Please insert a coin.
To simulate inserting a coin, type c and press Enter in the same terminal. The hardware thread will call the coin slot Interrupt Service Routine (ISR), which wakes up your task. The dispatcher will run it again, and you’ll see… an unexpected result:
INF Welcome to the Pigweed Vending Machine!
INF Please insert a coin.
INF Welcome to the Pigweed Vending Machine!
INF Please insert a coin.
INF Received 1 coin. Dispensing an item.
The welcome message was printed twice! Why?
When a task is suspended and resumed, its DoPend
method is called again
from the beginning. The first time DoPend
ran, it printed the welcome
message and then returned Pending()
from inside the PW_TRY_READY_ASSIGN
macro. When the coin was inserted, the task was woken up and the dispatcher
called DoPend
again from the top. It printed the welcome message a second
time, and then when it called coin_slot_.Pend(cx)
, the coin was available,
so it returned Ready()
and the task completed.
This demonstrates a critical concept of asynchronous programming: tasks must manage their own state.
4. Managing the welcome state#
Because a task can be suspended and resumed at any Pending()
return, you
need a way to remember where you left off. For simple cases like this, a boolean
flag is sufficient.
Open //pw_async2/codelab/vending_machine.h and add a boolean to track whether
the welcome message has been displayed. Initialize it to false
.
Now, modify DoPend
in //pw_async2/codelab/vending_machine.cc. Gate the
two log calls for the welcome message behind your new boolean flag. Once the
message is printed, make sure to set the flag to true
so it won’t be printed
again.
5. Build and run: Verify the fix#
bazelisk run //pw_async2/codelab
Now, the output should be correct. The welcome message is printed once, the task waits, and then it finishes after you insert a coin.
INF Welcome to the Pigweed Vending Machine!
INF Please insert a coin.
Type c and press Enter:
INF Received 1 coin. Dispensing an item.
The task then completes, RunToCompletion
returns, and the program exits.
You’ve now implemented a task that can wait for an asynchronous event and correctly manages its state! In the next step, you’ll learn how to write your own pendable functions.
Step 3: Writing your own event handler#
In the last step, you created a task to dispense an item after a coin was inserted. Most vending machines at least allow you to choose what to buy. Let’s fix that by handling the keypad input ISR, and using the key press info in the task to dispense an item.
Along the way you will learn how to correctly wake up a task that is waiting for
input like this. You will also gain some experience implementing a Pend()
function yourself.
The provided hardware simulation will send you a keypad event via an
asynchronous call to the key_press_isr()
that should already be defined in
your //pw_async2/codelab/main.cc file. It will pass you an integer value in
the range (0-9) to indicate which keypad button was pressed. It is going to be
up to you to process that keypad event safely, and allow your task to wait for
the keypad number after receiving a coin to dispense an item.
A single digit should be enough, but if you want an extra challenge, you can choose to allow larger numbers to be entered.
Tip
Solution for this step: //pw_async2/codelab/solutions/step3
1. Define a stub Keypad
class#
Lets start with a minimal stub implementation. Add the following declaration to your //pw_async2/codelab/vending_machine.h header file:
class Keypad {
public:
constexpr Keypad() : key_pressed_(kNone) {}
// Pends until a key has been pressed, returning the key number.
//
// May only be called by one task.
pw::async2::Poll<int> Pend(pw::async2::Context& cx);
// Record a key press. Typically called from the keypad ISR.
void Press(int key);
private:
// A special internal value to indicate no keypad button has yet been
// pressed.
static constexpr int kNone = -1;
int key_pressed_;
};
Also add these stub implementations to the top of your //pw_async2/codelab/vending_machine.cc file:
pw::async2::Poll<int> Keypad::Pend(pw::async2::Context& cx) {
// This is a stub implementation!
static_cast<void>(cx);
return key_pressed_;
}
void Keypad::Press(int key) {
// This is a stub implementation!
static_cast<void>(key);
}
This should be a good starting stub. Notice how the Pend
member function just
immediately returns the value of key_pressed_
, which is only ever set to
kNone
. We will fix that later, but let’s integrate the keypad into the rest
of the code first.
2. Add the Keypad
to the vending machine#
In your //pw_async2/codelab/main.cc file, create a global instance of your
keypad type next to the coin slot instance, and then update your
VendingMachineTask
constructor to take a reference to it in the constructor,
and to save the reference as member data.
3. Wait for a key event in your task#
At this point, your task’s DoPend
function should look something like the
solution file for step 2 (though not necessarily identical):
1 if (!displayed_welcome_message_) {
2 PW_LOG_INFO("Welcome to the Pigweed Vending Machine!");
3 PW_LOG_INFO("Please insert a coin.");
4 displayed_welcome_message_ = true;
5 }
6
7 PW_TRY_READY_ASSIGN(unsigned coins, coin_slot_.Pend(cx));
8 PW_LOG_INFO(
9 "Received %u coin%s. Dispensing an item.", coins, coins > 1 ? "s" : "");
10
11 return pw::async2::Ready();
The logical place to handle the keypad input is after receiving a coin.
Update the coin received message to remove the “item is being dispensed” message. Instead we will wait for the keypad event.
Waiting for a keypad event is going to be very much like waiting for a coin.
Use the PW_TRY_READY_ASSIGN macro to poll keypad_.Pend(cx)
. If
it is ready, log the keypad key that was received, and that an item is
dispensing before returning pw::async2::Ready()
to finish the task.
4. Build and verify the stub#
Run your vending machine as before:
bazelisk run //pw_async2/codelab
You will see the welcome message, and you can insert a coin by again typing
c and pressing Enter. You should see a message that “-1” was
pressed. This is expected since the KeyPad::DoPend()
stub implementation
returns key_pressed_
, which was initialized to kNone
(-1).
INF Welcome to the Pigweed Vending Machine!
INF Please insert a coin.
c
INF Received 1 coin.
INF Please press a keypad key.
INF Keypad -1 was pressed. Dispensing an item.
So far so good! Next it is time to handle the hardware event, and have your task wait for the key press data.
5. Handle the event in your Keypad
implementation#
The first step should be trivial. Modify the stub key_press_isr
in your
//pw_async2/codelab/main.cc to pass the key number to the Keypad::Press
member function.
void key_press_isr(int raw_key_code) { keypad.Press(raw_key_code); }
The next step is harder, implementing the Keypad::Press
member function
correctly.
Since the keypad ISR is asynchronous, you will need to synchronize access to the stored event data. For this codelab, you we use pw::sync::InterruptSpinLock which is safe to acquire from an ISR in production use. Alternatively you can use atomic operations.
We’ll also use PW_GUARDED_BY
to add a compile-time check that the protected
members are accessed with the lock held.
Normally you would have to add the correct dependencies to the //pw_async2/codelab/BUILD.bazel file, but we’ve already included them to save you some work. But if something went wrong, they are straightforward:
"//pw_sync:interrupt_spin_lock",
"//pw_sync:lock_annotations",
Add an instance of the spin lock to your
Keypad
class, along with a data member to hold the key press data.pw::sync::InterruptSpinLock lock_; int key_pressed_ PW_GUARDED_BY(lock_);
The
PW_GUARDED_BY(lock_)
just tells the compiler (clang) that to accesskey_pressed_
,lock_
should be held first, otherwise it should emit a diagnostic.Add two includes at the top of your //pw_async2/codelab/vending_machine.h:
#include "pw_sync/interrupt_spin_lock.h" #include "pw_sync/lock_annotations.h"
Now you can implement
Keypad::Press
to save off the event data in a way that it can be safely read byKeypad::Pend
.std::lock_guard lock(lock_); key_pressed_ = key;
You can start off with this implementation for
Keypad::Pend
:std::lock_guard lock(lock_); int key = std::exchange(key_pressed_, kNone); if (key != kNone) { return key; } return pw::async2::Pending();
If you haven’t seen
std::exchange
used like this before, it just ensures that the key pressed event data is read only once by clearing it out tokNone
(-1) after reading the value ofkey_pressed_
.
It’s so simple… what could go wrong?
bazelisk run //pw_async2/codelab
INF Welcome to the Pigweed Vending Machine!
INF Please insert a coin.
c
INF Received 1 coin.
INF Please press a keypad key.
▄████▄ ██▀███ ▄▄▄ ██████ ██░ ██
▒██▀ ▀█ ▓██ ▒ ██▒ ▒████▄ ▒██ ▒ ▓██░ ██▒
▒▓█ 💥 ▄ ▓██ ░▄█ ▒ ▒██ ▀█▄ ░ ▓██▄ ▒██▀▀██░
▒▓▓▄ ▄██▒ ▒██▀▀█▄ ░██▄▄▄▄██ ▒ ██▒ ░▓█ ░██
▒ ▓███▀ ░ ░██▓ ▒██▒ ▓█ ▓██▒ ▒██████▒▒ ░▓█▒░██▓
░ ░▒ ▒ ░ ░ ▒▓ ░▒▓░ ▒▒ ▓▒█░ ▒ ▒▓▒ ▒ ░ ▒ ░░▒░▒
░ ▒ ░▒ ░ ▒░ ▒ ▒▒ ░ ░ ░▒ ░ ░ ▒ ░▒░ ░
░ ░░ ░ ░ ▒ ░ ░ ░ ░ ░░ ░
░ ░ ░ ░ ░ ░ ░ ░ ░
░
pw_async2/dispatcher_base.cc:151: PW_CHECK() or PW_DCHECK() FAILED!
FAILED ASSERTION
!task->wakers_.empty()
FILE & LINE
pw_async2/dispatcher_base.cc:151
FUNCTION
NativeDispatcherBase::RunOneTaskResult pw::async2::NativeDispatcherBase::RunOneTask(Dispatcher &, Task *)
MESSAGE
Task 0x7ffd8ddc2f40 returned Pending() without registering a waker
6. Fix the crash: Registering a waker#
The crash message is intentionally blatant about what went wrong. The
implementation of Keypad::Pend
I told you to write was intentionally
incomplete to show you what happens if you make this misstep.
Let’s set up the waiter.
First include
pw_async2/waker.h
at the top of your //pw_async2/codelab/vending_machine.h header.Add an instance as member data to your
Keypad
class.As this will ultimately be used by both
Pend()
andPress()
, it needs to be guarded by the spin lock.pw::async2::Waker waker_ PW_GUARDED_BY(lock_);
Setup the waker right before returning pw::async2::Pending()
To do this correctly, you use PW_ASYNC_STORE_WAKER, giving it the context argument passed in to the
Pend()
, the waker to store to, and await_reason_string
to help debug issues.Tip
Pass a meaningful string for last
wait_reason_string
, as this will help you debug issues.Here’s what the end of
Keypad::Pend
should look like:PW_ASYNC_STORE_WAKER(cx, waker_, "keypad press"); return pw::async2::Pending();
We haven’t yet modified Keypad::Press
to use the waker yet, and we will need
to. But first let’s show what happens if you forget this step. This time there
will not be a crash!
8. Forgetting to wake the task#
Let’s see what happens if you forget to wake the task.
Build and run the codelab, and then press c Enter 1 Enter….
bazelisk run //pw_async2/codelab
INF Welcome to the Pigweed Vending Machine!
INF Please insert a coin.
c
INF Received 1 coin.
INF Please press a keypad key.
1
As expected, nothing happens, not even an assertion. pw_async2
has no way of
knowing itself when the task is ready to be woken up as the pendable is ready.
You might wonder then how you would even debug this problem. Luckily, there is a way!
Try pressing d then Enter.
d
INF pw::async2::Dispatcher
INF Woken tasks:
INF Sleeping tasks:
INF - VendingMachineTask:0x7ffeec48fd90 (1 wakers)
INF * Waker 1: keypad press
This shows the state of all the tasks registered with the dispatcher.
Behind the scenes, the hardware.cc
implementation calls
LogRegisteredTasks on
the dispatcher which was registered via the HardwareInit()
function.
You can make this same call yourself to understand why your tasks aren’t doing anything, and investigate from there.
In this case we know we are sending a keypad press event, but obviously from
the Waker 1: keypad press
line in the output log, the task wasn’t properly
woken up.
To fix it, let’s add the missing Wake()
call to Keypad::Press
:
std::lock_guard lock(lock_);
key_pressed_ = key;
std::move(waker_).Wake();
We move the waker out of waker_
first to consume it. That way the next
interrupt won’t wake the task if the task is actually waiting on something else.
Note it is completely safe to make the Wake()
call if the waker is in
an “empty” state, such as when no waker has yet been stored using
PW_ASYNC_STORE_WAKER
. Doing that is a no-op.
Tip
You can also end up in a “task not waking up” state if you destroy or
otherwise clear the Waker
instance that pointed at the task to wake.
Again LogRegisteredTasks
will point to a problem waking your task,
and give the last reason message, so you know where to start looking.
Important
If you don’t see the reason messages, you may have configured
PW_ASYNC2_DEBUG_WAIT_REASON to 0
to disable them.
LogRegisteredTasks
will still print out what it can, but for more
information you may need to consider enabling them temporarily.
9. Verify your event handler#
bazelisk run //pw_async2/codelab
Does it work as you expect?
Tip
If you suspect you didn’t implement your Keypad
class correctly,
comparing your solution against the //pw_async2/codelab/coin_slot.cc
implementation might help before looking at the
//pw_async2/codelab/solutions/step3 solution.
Well, depending on how you arranged to wait on both the CoinSlot
and
Keypad
in your DoPend
implementation, you could have one more problem.
We will look at how to better handle multiple pendables in the next step. As a hint, you need to start thinking of your task as a state machine.