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 and pw::async2::OnceReceiver for basic inter-task communication.

  • Use pw::async2::TimeProvider and pw::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.

  1. Open //pw_async2/codelab/vending_machine.h

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.

  1. Open //pw_async2/codelab/vending_machine.cc

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.

  1. Open //pw_async2/codelab/main.cc

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.

  1. 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 the value.

  • If it’s Pending(), the operation is not yet complete. The task will generally stop and return Pending() 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",
  1. 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 access key_pressed_, lock_ should be held first, otherwise it should emit a diagnostic.

  2. 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"
    
  3. Now you can implement Keypad::Press to save off the event data in a way that it can be safely read by Keypad::Pend.

    std::lock_guard lock(lock_);
    key_pressed_ = key;
    
  4. 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 to kNone (-1) after reading the value of key_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.

  1. First include pw_async2/waker.h at the top of your //pw_async2/codelab/vending_machine.h header.

  2. Add an instance as member data to your Keypad class.

    As this will ultimately be used by both Pend() and Press(), it needs to be guarded by the spin lock.

    pw::async2::Waker waker_ PW_GUARDED_BY(lock_);
    
  3. 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 a wait_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.