Chapter 14: The Embassy Executor
September 17, 2026 · View on GitHub
Introduction
Futures run only when someone polls them. On a microcontroller that someone is
the executor: a tiny, never-returning loop that keeps a queue of ready
futures, polls each one, and sleeps when nothing is ready. Embassy's executor
is distributed as embassy-executor, and its face in our code is one attribute
and one parameter: #[embassy_executor::main] and the Spawner. This chapter
dissects both.
#[embassy_executor::main]
Every driver's entry point looks the same:
/// Main application entry point.
///
/// # Details
/// Initializes Embassy runtime and runs the main blink loop.
/// Uses BlinkController for state management.
///
/// # Arguments
/// * `_spawner` - Embassy task spawner (reserved for future async tasks).
///
/// # Returns
/// * `()` - Never returns (infinite loop).
#[embassy_executor::main]
async fn main(_spawner: Spawner) {
...
}
The attribute does three jobs at startup:
- Initializes the executor's interrupt-driven clock (the executor is
ticked by a timer interrupt on
arch-cortex-m). - Constructs a
Spawnerand passes it in as the first argument. - Spawns
mainitself as the first task and runs the executor forever.
Ignore the docstring for a moment — the signature carries real information. The
argument is a Spawner; in the blink driver it is unused (_spawner) because
the blink loop needs no extra tasks. The UART driver does not spawn extra tasks
either; our three drivers are single-task by design and pass the Spawner
through for future expansion, exactly as the docstring states.
The Spawner
A Spawner is a handle to the executor. With it, running an async task is a
function call:
let spawner: Spawner = ...; // obtained from #[embassy_executor::main]
spawner.spawn(my_task()).unwrap(); // register the future with the executor
What you can do with a Spawner:
spawn— Submit a task future to the executor's queue.- Clone and share —
Spawneris cheap to copy; it can be moved into closures and other tasks so they can start new tasks later.
The spark that never appears in our simple drivers is a task pool: each
#[embassy_executor::task] needs a fixed-size slot at compile time, so Embassy
allocates tasks statically, not on a heap. spawn fails only when a pool is
full — returning a SpawnError we would have to handle.
Tasks with #[embassy_executor::task]
A reusable async function (a "task") is annotated with
#[embassy_executor::task]. The attribute reserves the state the future needs
and wires the future into the executor's machinery:
#[embassy_executor::task]
async fn blink(peripherals) {
loop {
...
Timer::after_millis(500).await;
}
}
// later, in main:
let _ = spawner.spawn(blink(peripherals));
The syntax mirrors #[embassy_executor::main], which is itself just a special
task with a freely-given spawner. Our three drivers do not use the #[task]
variant — each main runs exactly one loop and needs nothing concurrent —
but understanding it demystifies the attribute on main and models what you
will write as soon as a second concurrent job appears.
Spawn and SpawnError
Spawning is fallible, and the type system says so:
pub enum SpawnError {
Busy, // the task is already spawned
NoPoolMemory, // small-capacity pool exhausted
LargePoolMemory,
}
let result: Result<(), SpawnError> = spawner.spawn(my_task());
Firmware that must not panic over a pool condition handles the Result. In a
single-task design there is nothing to fail, so our binaries keep the code
simple — but the pattern matters once you decompose work into multiple Embassy
tasks.
What the Executor Does
Conceptually, the executor's entire program is a loop:
+-------------+ +------------+ +-------------+
| Wake event | --> | Poll all | --> | Sleep (wfi)|
| (IRQ) | | ready tasks| | until next |
+-------------+ +------------+ +-------------+
- Wake event — An interrupt handler marks the affected future "ready."
- Poll ready tasks — The executor calls
pollon every ready future, yieldingReadyresults and re-queueing those that returnPending. - Sleep — With nothing ready, the CPU executes
wfi(wait-for-interrupt) and stops dead, burning microamps until the next event.
That third state is the gift of async: a not-yet-expired timer is not a spinning loop, it is a parked future. Two tasks, three tasks, ten tasks — as long as none is ready, the core costs nothing.
Summary
- The executor is a poll-and-sleep loop; futures make progress only when it polls them.
#[embassy_executor::main]initializes the executor, builds aSpawner, spawnsmain, and never returns.Spawner::spawnregisters new tasks; it isCopyand returns aResultwhen no pool slot is free.#[embassy_executor::task]turns anyasync fninto a spawnable task.- Nothing ready → the core sleeps in
wfi, drawing almost no power.
Next: time. What Timer::after_millis actually does.