MicroPython for Microcontrollers Module 6 – Concurrency on devices: asyncio and threads
asyncio on a microcontroller: tasks and sleeps
Run several MicroPython asyncio tasks on one loop with create_task, sleep_ms and gather, find the call that blocks them all, and see what differs from CPython.
What you will learn
- Run several cooperative tasks with create_task(), sleep_ms() and gather()
- Find the call that blocks every task, and slice long work with sleep_ms(0)
- Explain what ends run() and gather(), and what happens to a task that fails alone
- Predict where MicroPython's asyncio differs from CPython's, and write code that works on both
Before you start
What you’ll learn · Before you start
What you will learn
- Run several cooperative tasks with create_task(), sleep_ms() and gather()
- Find the call that blocks every task, and slice long work with sleep_ms(0)
- Explain what ends run() and gather(), and what happens to a task that fails alone
- Predict where MicroPython's asyncio differs from CPython's, and write code that works on both
Before you start
On this page
A Pico that changes an LED every 500 ms and reads a sensor every 300 ms cannot do both with time.sleep_ms(): while
it sleeps for one job, the other waits. Worse, a driver that waits 120 ms with time.sleep_ms() twice a second makes
a 20 ms heartbeat lose 11 of its 50 beats. MicroPython’s asyncio runs each job as a
task: A coroutine that the asyncio loop runs beside others, made with asyncio.create_task(). It runs until it awaits something that has to wait, and then the loop runs another task. of its own on one loop, and each task hands the loop back whenever it waits.
This lesson starts tasks with create_task(), waits with sleep_ms(), collects results with gather(), finds the
call that freezes every task, and marks where the board’s asyncio, a smaller module than CPython’s, behaves
differently. The programs run here on a model of MicroPython 1.29’s asyncio with a simulated clock, built from its
documentation and source, so every time they print is the same on each run; one program runs on CPython’s own
asyncio to compare.
One loop runs every task, one at a time
Here are the LED and the sensor as two tasks. Each is an async def function with a loop that ends in
await asyncio.sleep_ms(...). main() hands both to the loop with asyncio.create_task() and then waits 1,100 ms
itself; asyncio.run(main()) starts the loop with main as its first task. The first tab is the program; the other
three are the model it runs on here. On a board, mpy_asyncio and mpy_time are the real asyncio and time,
machine is the board’s own, and the line marked as the bench goes:
tasks/blink_and_sample.py
"""Two tasks share one loop: an LED that changes every 500 ms and a sensor read every 300 ms."""
import machine
import mpy_asyncio as asyncio # on a board: import asyncio
import mpy_time as time # on a board: import time
machine.bench_adc(26, [41230, 41180, 40950, 40870]) # the bench: what the sensor reads (not on a board)
led = machine.Pin("LED", machine.Pin.OUT)
sensor = machine.ADC(26)
def log(task, text):
print("%5d ms %-6s %s" % (time.ticks_ms(), task, text))
async def blink(period_ms):
while True:
led.toggle()
log("blink", "LED on" if led.value() else "LED off")
await asyncio.sleep_ms(period_ms)
async def sample(period_ms):
while True:
log("sample", "read_u16() gave %d" % sensor.read_u16())
await asyncio.sleep_ms(period_ms)
async def main():
asyncio.create_task(blink(500))
asyncio.create_task(sample(300))
log("main", "made two tasks; now sleeps 1,100 ms")
await asyncio.sleep_ms(1100)
log("main", "returns, so run() returns")
asyncio.run(main()) Output
0 ms main made two tasks; now sleeps 1,100 ms
0 ms blink LED on
0 ms sample read_u16() gave 41230
300 ms sample read_u16() gave 41180
500 ms blink LED off
600 ms sample read_u16() gave 40950
900 ms sample read_u16() gave 40870
1000 ms blink LED on
1100 ms main returns, so run() returns
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 blink_and_sample.py
mpy_asyncio.py (the model) · tasks/mpy_asyncio.py
"""A model of MicroPython 1.29's asyncio, on the simulated clock of mpy_time.py.
It follows extmod/asyncio of v1.29.0 (core.py, funcs.py, event.py, lock.py) wherever a program can see it:
- one loop runs one task at a time, and a task gives the loop back only at an await that has to wait;
- sleep_ms() and sleep() return one shared object, not a coroutine, so that a sleep allocates nothing; create_task(),
gather() and wait_for() refuse it with TypeError("coroutine expected");
- run() returns as soon as its own task ends; the other tasks stay queued, nothing cancels them, and a later run()
goes on with them; new_event_loop() empties the queue;
- a task that ends with an exception while nothing waits for it prints "Task exception wasn't retrieved" (a board
prints it to standard error, with the traceback) and the other tasks go on;
- CancelledError derives from BaseException and TimeoutError from Exception; wait_for() cancels the awaitable when
its time is up and waits for it to finish; gather() returns the results in the order of its arguments, or raises
the first exception while the other tasks go on;
- Event.set() wakes every waiting task; Lock hands itself to the next waiting task; ThreadSafeFlag is one bit that
an interrupt handler may set and wait() clears, with one waiting task.
The clock moves on only while no task can run, or inside a blocking call (mpy_time.sleep_ms()), so every run prints
the same times. Not MicroPython: a model for computers.
"""
import atexit
import heapq
import inspect
import mpy_time as _time
class CancelledError(BaseException):
pass
class TimeoutError(Exception):
pass
_queue = [] # (due, order, task, value): tasks that run once the clock reaches due
_order = 0
_ended = 0
_current = None
_polled = [] # objects the loop polls before each task: a ThreadSafeFlag, a stream with a reader waiting
_ERRNO = {19: "ENODEV", 104: "ECONNRESET", 110: "ETIMEDOUT", 111: "ECONNREFUSED", 113: "EHOSTUNREACH"}
atexit.register(lambda: _close_unstarted([entry[2] for entry in _queue]))
class _Request:
"""What a task hands the loop when it has to wait."""
def __init__(self, *what):
self.what = what
def __await__(self):
return (yield self.what)
class Task:
"""A coroutine that the loop runs. create_task() makes one; awaiting it waits for its end."""
def __init__(self, coro):
self.coro = coro
self.name = getattr(coro, "__name__", "coro")
self._done = False
self._result = None
self._error = None
self._ended = 0
self._waiters = [] # tasks waiting for this one to end
self._throw = None # raised inside the task when it next runs: cancel()
self._value = None # what the loop sends in when it next runs
self._joined = None # the task it awaits, to which cancel() passes on
self._token = 0 # its live entry in the queue
self._undo = [] # what to undo when it stops waiting
self._watched = 0 # gather() or wait_for() collects its result
def done(self):
return self._done
def cancel(self):
"""Raises CancelledError inside the task at the await where it waits. A task that awaits another task passes
the cancel on to that one, as in MicroPython, and receives its CancelledError when it ends."""
if self._done:
return False
if self is _current:
raise RuntimeError("can't cancel self")
target = self
while target._joined is not None:
target = target._joined
target._throw = CancelledError()
_wake(target)
return True
def __await__(self):
if not self._done:
yield ("join", self)
if self._error is not None:
raise self._error
return self._result
def __repr__(self):
return "<Task>"
def _push(task, due, value=None):
global _order
_order += 1
task._token = _order
heapq.heappush(_queue, (due, _order, task, value))
def _unpark(task):
task._joined = None
undo, task._undo = task._undo, []
for step in undo:
step()
def _wake(task, value=None):
_unpark(task)
_push(task, _time._now, value)
def _drop(waiting, task):
if task in waiting:
waiting.remove(task)
def _unpoll(obj, task):
if obj._waiter is task:
obj._waiter = None
if obj in _polled and obj._waiter is None:
_polled.remove(obj)
def _park(task, what):
kind = what[0]
if kind == "sleep":
_push(task, what[1])
elif kind in ("join", "join-any", "join-or-time"):
others = what[1] if kind == "join-any" else [what[1]]
for other in others:
other._waiters.append(task)
task._undo.append(lambda other=other: _drop(other._waiters, task))
if kind == "join":
task._joined = what[1]
if kind == "join-or-time":
_push(task, what[2], False) # still waiting at that time: the time is up
elif kind == "wait": # an Event or a Lock: its list of waiting tasks
what[1].append(task)
task._undo.append(lambda: _drop(what[1], task))
elif kind == "poll": # one slot for a waiting task: a second waiter takes the first one's place
obj = what[1]
obj._waiter = task
if obj not in _polled:
_polled.append(obj)
task._undo.append(lambda: _unpoll(obj, task))
else:
raise RuntimeError("the model cannot wait for %r" % (what,))
def _text(error):
if isinstance(error, OSError) and error.args and isinstance(error.args[0], int):
return "[Errno %d] %s" % (error.args[0], _ERRNO.get(error.args[0], ""))
return str(error)
def _finish(task, result=None, error=None):
global _ended
_ended += 1
task._done, task._result, task._error, task._ended = True, result, error, _ended
waiters, task._waiters = task._waiters, []
for waiter in list(waiters):
_wake(waiter, True)
if error is not None and not waiters and not task._watched and not isinstance(error, CancelledError):
print("Task exception wasn't retrieved")
print("future: <Task> coro= <generator object '%s'>" % task.name)
print("%s: %s" % (type(error).__name__, _text(error)))
def _step(task):
global _current
_current = task
try:
if task._throw is not None:
error, task._throw = task._throw, None
what = task.coro.throw(error)
else:
what = task.coro.send(task._value)
except StopIteration as stop:
_finish(task, result=stop.value)
return
except BaseException as error: # CancelledError too: the task has ended
_finish(task, error=error)
return
finally:
_current = None
_park(task, what)
def _poll():
for obj in list(_polled):
if obj._waiter is not None and obj._ready():
_wake(obj._waiter)
def _run_until_complete(main):
while True:
_poll()
if _queue and _queue[0][2]._token != _queue[0][1]:
heapq.heappop(_queue) # an entry left behind when the task was woken another way
continue
if not _queue or _queue[0][0] > _time._now:
due = _queue[0][0] if _queue else None
nxt = _time.next_event()
if due is None and nxt is None:
raise RuntimeError("model: every task waits for something that never comes")
_time._advance(min(t for t in (due, nxt) if t is not None))
continue
_, _, task, value = heapq.heappop(_queue)
task._token = 0
_unpark(task)
task._value = value
_step(task)
if task is main and main._done:
if main._error is not None:
raise main._error
return main._result
def _promote(aw):
return aw if isinstance(aw, Task) else create_task(aw)
class _Sleep:
"""What sleep_ms() and sleep() return: one object for every sleep, as in MicroPython. It is not a coroutine."""
def __init__(self):
self.due = None
def __await__(self):
due, self.due = self.due, None
yield ("sleep", due)
_sleep = _Sleep()
def sleep_ms(t):
_sleep.due = _time._now + max(0, int(t))
return _sleep
def sleep(t):
return sleep_ms(int(t * 1000))
def create_task(coro):
if not hasattr(coro, "send"):
raise TypeError("coroutine expected")
task = Task(coro)
_push(task, _time._now)
return task
def current_task():
if _current is None:
raise RuntimeError("no running event loop")
return _current
def run(coro):
return _run_until_complete(create_task(coro))
def _close_unstarted(tasks):
# A coroutine that never started holds no code to run; closing it keeps CPython from warning about it, which a
# board never does. Coroutines that started are left as they are.
for task in tasks:
if inspect.iscoroutine(task.coro) and inspect.getcoroutinestate(task.coro) == inspect.CORO_CREATED:
task.coro.close()
def new_event_loop():
"""Empties the loop: tasks still queued from an earlier run() are forgotten."""
global _queue, _polled
_close_unstarted([entry[2] for entry in _queue])
_queue, _polled = [], []
async def gather(*aws, return_exceptions=False):
tasks = [_promote(aw) for aw in aws]
for t in tasks:
t._watched += 1
try:
while True:
failed = [t for t in tasks if t._done and t._error is not None]
if failed and not return_exceptions:
raise min(failed, key=lambda t: t._ended)._error
running = [t for t in tasks if not t._done]
if not running:
return [t._result if t._error is None else t._error for t in tasks]
try:
await _Request("join-any", running)
except CancelledError:
for t in running:
t.cancel()
raise
finally:
for t in tasks:
t._watched -= 1
async def _wait_for(aw, timeout):
task = _promote(aw)
if timeout is None:
return await task
task._watched += 1
try:
try:
ended = await _Request("join-or-time", task, _time._now + max(0, timeout))
except CancelledError:
task.cancel()
raise
if not ended:
task.cancel()
try:
await task
except BaseException: # its CancelledError, or whatever its clean-up raised
pass
raise TimeoutError
return await task
finally:
task._watched -= 1
def wait_for(aw, timeout):
"""Returns a coroutine, as in MicroPython: its argument is promoted to a task only when it runs."""
return _wait_for(aw, None if timeout is None else int(timeout * 1000))
def wait_for_ms(aw, timeout):
return _wait_for(aw, timeout)
class Event:
def __init__(self):
self._set = False
self._waiting = []
def is_set(self):
return self._set
def set(self):
"""Wakes every waiting task. Call it from a task only, never from an interrupt or another thread."""
waiting, self._waiting = self._waiting, []
for task in list(waiting): # waking a task takes it out of the list
_wake(task)
self._set = True
def clear(self):
self._set = False
async def wait(self):
if not self._set:
await _Request("wait", self._waiting)
return True
class ThreadSafeFlag:
def __init__(self):
self._state = 0
self._waiter = None
def set(self):
"""Safe from an interrupt handler: it only sets a bit, which the loop polls before each task."""
self._state = 1
def clear(self):
self._state = 0
def _ready(self):
return self._state == 1
async def wait(self):
if not self._state:
await _Request("poll", self)
self._state = 0
class Lock:
def __init__(self):
self._state = 0 # 0 free, 1 held, or the task it has been handed to
self._waiting = []
def locked(self):
return self._state == 1
def release(self):
if self._state != 1:
raise RuntimeError("Lock not acquired")
if self._waiting:
task = self._waiting.pop(0)
self._state = task
_wake(task)
else:
self._state = 0
async def acquire(self):
if self._state != 0:
try:
await _Request("wait", self._waiting)
except CancelledError:
if self._state is _current: # handed to this task, then cancelled: pass it on
self._state = 1
self.release()
raise
self._state = 1
return True
async def __aenter__(self):
return await self.acquire()
async def __aexit__(self, *exc):
self.release()
def __getattr__(name):
"""Loads the streams the first time a program asks for them, as MicroPython's asyncio loads its stream module."""
if name in ("open_connection", "start_server", "StreamReader", "StreamWriter"):
import mpy_stream
return getattr(mpy_stream, name)
raise AttributeError(name) mpy_time.py (the model) · tasks/mpy_time.py
"""The time functions of MicroPython 1.29 that asyncio uses, on a simulated clock (extmod/modtime.c).
asyncio, machine.Timer and these functions share one clock of whole milliseconds, as they share time.ticks_ms() on a
board. ticks_ms() wraps at 2**30 on the rp2 and esp32 ports, like these functions; ticks_diff() and ticks_add() work
across the wrap. sleep_ms() and sleep() block: the clock moves on and nothing else runs except interrupts (the hard
timer callbacks of machine.py), as a blocking call holds a board's whole program. Not MicroPython: a model for
computers. at() is the bench's own: something that happens at a set time, an interrupt or a packet arriving.
"""
import heapq
TICKS_PERIOD = 1 << 30
_now = 0 # milliseconds since the bench started
_events = [] # (time, order, function): what the bench makes happen at a set time
_order = 0
def ticks_ms():
return _now % TICKS_PERIOD
def ticks_add(ticks, delta):
return (ticks + delta) % TICKS_PERIOD
def ticks_diff(end, start):
half = TICKS_PERIOD // 2
return ((end - start + half) % TICKS_PERIOD) - half
def at(when, function):
"""The bench calls function() when the clock reaches when (a time in milliseconds since the start)."""
global _order
_order += 1
heapq.heappush(_events, (when, _order, function))
def next_event():
return _events[0][0] if _events else None
def _advance(to):
"""Let the clock run to `to`, firing every interrupt and delivery due on the way, in time order."""
global _now
while _events and _events[0][0] <= to:
when, _, function = heapq.heappop(_events)
_now = max(_now, when)
function()
_now = max(_now, to)
def sleep_ms(ms):
"""Blocks for ms milliseconds: no task runs meanwhile, only interrupts."""
if ms > 0:
_advance(_now + ms)
def sleep(seconds):
sleep_ms(int(seconds * 1000))
def reset():
global _now, _events, _order
_now, _events, _order = 0, [], 0
def bench_busy(ms):
"""The bench's own: ms milliseconds of work that never gives the loop back (a computation, a blocking driver)."""
sleep_ms(ms) machine.py (the model) · tasks/machine.py
"""A model of the parts of machine that these programs use, on the simulated clock of mpy_time.py (MicroPython 1.29,
rp2 port): Pin outputs, an ADC that returns the bench's readings, and Timer with hard callbacks.
A hard callback runs inside the timer's interrupt at the moment the timer expires, even while a task is in a blocking
call, and an exception it does not catch prints "Uncaught exception in IRQ callback handler" and stops the timer
(ports/rp2/machine_timer.c, shared/runtime/mpirq.c). This model has hard callbacks only. Not MicroPython: a model
for computers. bench_adc() is the bench's own: the readings a sensor will give.
"""
import mpy_time as _time
_adc_values = {}
def bench_adc(channel, values):
"""The readings ADC(channel).read_u16() gives, one per call, starting again after the last."""
_adc_values[channel] = list(values)
class Pin:
IN = 0
OUT = 1
def __init__(self, id, mode=IN, value=None):
self.id = id
self._value = 1 if value else 0
def value(self, v=None):
if v is None:
return self._value
self._value = 1 if v else 0
def on(self):
self._value = 1
def off(self):
self._value = 0
def toggle(self):
self._value = 1 - self._value
def __repr__(self):
return "Pin(%s)" % (self.id,)
class ADC:
def __init__(self, channel):
self.channel = channel
self._n = 0
def read_u16(self):
values = _adc_values.get(self.channel, [32768])
value = values[self._n % len(values)]
self._n += 1
return value
class Timer:
ONE_SHOT = 0
PERIODIC = 1
def __init__(self, id=-1, **kwargs):
self._run = 0 # which init() the queued expiries belong to
self._active = False
if kwargs:
self.init(**kwargs)
def init(self, *, mode=PERIODIC, period=-1, freq=None, callback=None, hard=False):
if not hard:
raise ValueError("this model runs hard callbacks only")
self._run += 1
self._period = int(1000 / freq) if freq else period
self._mode, self._callback, self._active = mode, callback, True
self._due = _time._now + self._period
_time.at(self._due, lambda run=self._run: self._expire(run))
def _expire(self, run):
if not self._active or run != self._run:
return
try:
self._callback(self)
except Exception as e:
print("Uncaught exception in IRQ callback handler")
print("%s: %s" % (type(e).__name__, e))
self._active = False
return
if self._mode == Timer.PERIODIC:
self._due += self._period
_time.at(self._due, lambda: self._expire(run))
else:
self._active = False
def deinit(self):
self._active = False Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
main printed its line before either task ran, although it made both tasks first. create_task() puts a
task in the loop’s queue and returns at once; the task starts only when the running
task gives the loop back, here at main’s await. Each task wraps a coroutine: The object an async def function returns when it is called. None of its code runs until a loop runs it as a task or another coroutine awaits it, and it stops at each await that has to wait., the
object an async def function returns, and the event loop: The part of asyncio that runs the tasks: it takes the task that is due first, runs it to an await that has to wait, and idles when no task is due. MicroPython has a single one. does one thing over and
over: it takes the task that is due first, runs it until it awaits something that has to wait, queues it by the time
it is due again, and idles when no task is due. No task is stopped halfway; each one decides where others may run.
That is cooperative multitasking: Running several jobs by letting each one hand over the processor at points it chooses, such as an await. Nothing interrupts a job, so one that never hands over holds up all the others.. Step through the turns of this run:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- main runs first. asyncio.run(main()) makes main a task and runs it. main calls create_task() twice, which only puts blink and sample in the queue, due at 0 ms, and then logs its own line at 0 ms.
- main awaits. main reaches await asyncio.sleep_ms(1100). The loop queues main, due again at 1,100 ms, and main stays where it is until then.
- blink, due at 0 ms. Two tasks are due now, and blink was queued first. It turns the LED on and awaits sleep_ms(500), so the loop queues it for 500 ms.
- sample, due at 0 ms. sample runs next, reads 41230 and awaits sleep_ms(300). The queue now holds sample at 300 ms, blink at 500 ms and main at 1,100 ms.
- Nothing is due. Every task waits for a time still to come, so the loop idles until 300 ms, the earliest due time. On a board the processor waits there and uses no time on the tasks.
- 300 ms, sample. sample is due first. It reads 41180 and asks to run again at 600 ms. blink, at 500 ms, is now the earliest, so the loop idles again.
- 500 ms, blink. blink turns the LED off and asks for 1,000 ms. The run goes on in the order of the due times, sample at 600 and 900 ms, blink at 1,000 ms.
- 1,100 ms, main returns. main is due. Its sleep is over, it logs its last line and returns, and run() returns with it. sample was due at 1,200 ms and never ran again.
After the tasks’ first turns at 0 ms every task was waiting, so the loop let the clock run to 300 ms, the earliest
time any task was due. sleep_ms(500) is not a pause inside blink: it is blink telling the loop when to run it
next. At 1,100 ms main returned, run() returned with it, and the reading due at 1,200 ms never happened.
Calling a task's function without the loop
blink(500) on a line of its own runs none of blink: calling an async def function only makes a coroutine.
Give it to the loop with asyncio.create_task(blink(500)), or wait for it to finish with await blink(500).
So a coroutine runs in one of two ways: beside the task that made it, as a task of its own from create_task(), or
inside that task, with await, which waits for it to finish before going on. main used the first for blink
and sample, because neither ever finishes.
Key idea
A task keeps the loop until it awaits something that has to wait; the loop then runs whichever task is due first.
Checkpoint
Check: Tasks taking turns on one loop
A call that blocks stops every task
The loop can change tasks only at an await. A blocking call: A call that waits without letting other code run, such as time.sleep_ms() or a driver that polls a device until it answers. Inside an asyncio task it holds up every other task., such as
time.sleep_ms(), a driver that polls a sensor until it answers or a long calculation, keeps the loop for as long as
it takes, and every other task waits with it. Step through a 20 ms heartbeat beside a job that waits 120 ms, first
with time.sleep_ms(), then written the way that hands the loop back:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The heartbeat waits. At 0 ms the heartbeat awaits sleep_ms(20). The loop queues it, due at 20 ms, and runs the job, the next task due.
- The job blocks. The job calls time.sleep_ms(120). That call waits inside the job itself, so the loop gets no turn to give to anyone until it returns.
- 20 ms passes. The heartbeat is due at 20 ms, but the loop is still inside the job's call, so nothing can run it. An LED that the heartbeat drives stays as it was.
- The job awaits at last. At 120 ms the job reaches await asyncio.sleep_ms(380) and hands the loop back. The loop runs the heartbeat at once, 100 ms late.
- The same wait, awaited. Written as await asyncio.sleep_ms(120), the job's wait hands the loop back at 0 ms. The heartbeat runs at 20, 40, 60, 80 and 100 ms, on time, while the job waits.
- Work in slices. A job that computes for 120 ms cannot await its work away. In slices of 15 ms with await asyncio.sleep_ms(0) after each, the loop runs a heartbeat that is due between slices, at most 15 ms late.
The next program measures it. For one second a heartbeat counts its beats and how late each one woke, beside the same 120 ms job written three ways. Press Edit, change the third job to 4 slices of 30 ms, and run it again:
"""A 20 ms heartbeat beside a 120 ms job, three ways: the job blocks, it awaits, or it works in slices."""
import mpy_asyncio as asyncio # on a board: import asyncio
import mpy_time as time # on a board: import time
beats = 0
worst = 0
async def heartbeat():
global beats, worst
while True:
due = time.ticks_add(time.ticks_ms(), 20)
await asyncio.sleep_ms(20)
beats += 1
worst = max(worst, time.ticks_diff(time.ticks_ms(), due))
async def job_blocks():
while True:
time.sleep_ms(120) # a driver that waits by sleeping: nothing else runs
await asyncio.sleep_ms(380)
async def job_awaits():
while True:
await asyncio.sleep_ms(120) # the same wait, handed to the loop
await asyncio.sleep_ms(380)
async def job_slices():
while True:
for _ in range(8): # 120 ms of work in slices of 15 ms
time.bench_busy(15) # the bench: 15 ms of filtering (on a board, the work itself)
await asyncio.sleep_ms(0) # let every task that is due run
await asyncio.sleep_ms(380)
async def one_second(job):
asyncio.create_task(heartbeat())
asyncio.create_task(job())
await asyncio.sleep_ms(1005)
print("%-34s %5s %s" % ("the 120 ms job", "beats", "worst late"))
for job, label in [
(job_blocks, "time.sleep_ms(120)"),
(job_awaits, "await asyncio.sleep_ms(120)"),
(job_slices, "8 slices of 15 ms, sleep_ms(0)"),
]:
beats = worst = 0
asyncio.new_event_loop() # start each version with an empty loop
asyncio.run(one_second(job))
print("%-34s %5d %d ms" % (label, beats, worst)) Output
the 120 ms job beats worst late time.sleep_ms(120) 39 120 ms await asyncio.sleep_ms(120) 50 0 ms 8 slices of 15 ms, sleep_ms(0) 45 15 ms
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 blocking.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
With time.sleep_ms(120) the heartbeat beat 39 times instead of 50, and one beat came 120 ms late: the whole length
of the job. Handed to the loop as await asyncio.sleep_ms(120), the same wait cost the heartbeat nothing. Work that
keeps the processor busy, here 120 ms of filtering, cannot be awaited away, but it can be cut into slices with
await asyncio.sleep_ms(0) after each: sleep_ms(0) lets every task that is already due run before the next slice,
so the heartbeat was never more than one slice, 15 ms, late. With your 4 slices of 30 ms, the worst wait doubles to
30 ms.
time.sleep_ms() inside a task
It puts the whole program to sleep, not the task. Inside async def, wait with await asyncio.sleep_ms(), and keep
time.sleep_ms() for code that runs before the loop starts or after it ends.
The same holds for every other kind of waiting. A plain socket read or a UART read that waits for its data waits
inside the task too, which is why the lessons that follow wait for the network with asyncio’s own streams.
Key idea
Every wait inside a task is awaited, and work that computes for long gives the loop a turn with
await asyncio.sleep_ms(0) between slices.
Checkpoint
Check: Calls that block every task
gather() collects results, and run() ends with its task
Tasks often produce results. await asyncio.gather(a, b, c) runs the coroutines as tasks side by side and returns
their results as a list, in the order you gave them, once the last one is done. This program reads three sensors that
answer after 30, 80 and 50 ms, then shows two ways a task can fail and what run() leaves behind it:
"""gather() collects results in order; a failing task is reported and ends alone; run() leaves the rest queued."""
import mpy_asyncio as asyncio # on a board: import asyncio
import mpy_time as time # on a board: import time
def log(text):
print("%5d ms %s" % (time.ticks_ms(), text))
async def read(name, ms, value):
await asyncio.sleep_ms(ms) # a sensor that answers after ms
log("%s answered %s" % (name, value))
return value
async def missing_sensor():
await asyncio.sleep_ms(40)
raise OSError(19) # ENODEV: no such device on the bus
async def heartbeat():
n = 0
while True:
n += 1
log("heartbeat %d" % n)
await asyncio.sleep_ms(100)
async def main():
asyncio.create_task(heartbeat())
got = await asyncio.gather(read("temp", 30, 23.5), read("humidity", 80, 61), read("pressure", 50, 1009))
log("gather returned %s" % got)
try:
await asyncio.gather(read("light", 70, 412), missing_sensor())
except OSError as e:
log("gather raised OSError %d; light goes on" % e.args[0])
asyncio.create_task(missing_sensor()) # nothing awaits this task
await asyncio.sleep_ms(100)
log("main returns")
async def pause(ms):
await asyncio.sleep_ms(ms)
asyncio.run(main())
log("run() returned; a second run() of 250 ms:")
asyncio.run(pause(250))
asyncio.new_event_loop()
log("new_event_loop(), then a third run() of 250 ms:")
asyncio.run(pause(250))
log("done") Output
0 ms heartbeat 1 30 ms temp answered 23.5 50 ms pressure answered 1009 80 ms humidity answered 61 80 ms gather returned [23.5, 61, 1009] 100 ms heartbeat 2 120 ms gather raised OSError 19; light goes on 150 ms light answered 412 Task exception wasn't retrieved future: <Task> coro= <generator object 'missing_sensor'> OSError: [Errno 19] ENODEV 200 ms heartbeat 3 220 ms main returns 220 ms run() returned; a second run() of 250 ms: 300 ms heartbeat 4 400 ms heartbeat 5 470 ms new_event_loop(), then a third run() of 250 ms: 720 ms done
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gather_and_run.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Read the output in three parts:
- The three reads answered at 30, 50 and 80 ms, and
gather()returned[23.5, 61, 1009], in the order of its arguments, at 80 ms. The reads overlapped, so together they took as long as the slowest, not 160 ms. - In the second
gather(), which began at 80 ms, the missing sensor raisedOSError19 at 120 ms, andgather()raised it inmainat once. The light reading was not cancelled: it answered at 150 ms, with nobody left to take its value. The third task, made withcreate_task()and never awaited, raised the same error at 160 ms; the loop printedTask exception wasn't retrievedwith the error, ended that task and let the heartbeat go on. - When
mainreturned at 220 ms, so didrun(), and nothing cancelled the heartbeat: the nextrun()woke it again at 300 and 400 ms, and onlyasyncio.new_event_loop()emptied the queue.
On a board, that report goes to the console with a traceback, and the program carries on without the task, so a sensor task that dies leaves a few lines on the console and nothing more.
Keep main() running, and start clean
End main() with something that never returns, such as await on a task that loops for ever, so that run()
keeps every task going. Call asyncio.new_event_loop() before a test or a new run() at the REPL, so that no task
of an earlier run comes back.
Checkpoint
Check: How gather() and run() end
Where MicroPython’s asyncio differs from CPython’s
MicroPython’s asyncio follows CPython’s, but its documentation describes it as implementing only part of it, and
some of what it has works differently. These four probes run twice, on the model of MicroPython 1.29 and on
CPython 3.14.8’s own asyncio:
MicroPython 1.29 (the model) · tasks/differences.py
"""Four probes of asyncio, on the model of MicroPython 1.29's asyncio. on_cpython.py runs the same on CPython."""
import mpy_asyncio as asyncio # on a board: import asyncio
async def pause(seconds):
await asyncio.sleep(seconds)
cleaned_up = False
async def forever():
global cleaned_up
try:
while True:
await asyncio.sleep(0.01)
finally:
cleaned_up = True # runs only if something cancels or closes the task
async def main():
try:
await asyncio.wait_for(asyncio.sleep(1), 0.05)
print("1. wait_for(sleep(1), 0.05) returned")
except TypeError as e:
print("1. wait_for(sleep(1), 0.05): TypeError:", e)
except asyncio.TimeoutError:
print("1. wait_for(sleep(1), 0.05): TimeoutError")
try:
await asyncio.wait_for(pause(1), 0.05)
except asyncio.TimeoutError:
print("2. wait_for(pause(1), 0.05): TimeoutError")
names = ["sleep_ms", "wait_for_ms", "ThreadSafeFlag", "Queue", "Semaphore", "TaskGroup", "timeout"]
print("3.", ", ".join("%s %s" % (n, "yes" if hasattr(asyncio, n) else "no") for n in names))
asyncio.create_task(forever())
await asyncio.sleep(0.05)
asyncio.run(main())
print("4. after run(), the finally block of forever() has run:", cleaned_up) Output
1. wait_for(sleep(1), 0.05): TypeError: coroutine expected 2. wait_for(pause(1), 0.05): TimeoutError 3. sleep_ms yes, wait_for_ms yes, ThreadSafeFlag yes, Queue no, Semaphore no, TaskGroup no, timeout no 4. after run(), the finally block of forever() has run: False
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 differences.py
CPython 3.14.8 · tasks/on_cpython.py
"""The four probes of differences.py, on CPython's own asyncio."""
import asyncio
async def pause(seconds):
await asyncio.sleep(seconds)
cleaned_up = False
async def forever():
global cleaned_up
try:
while True:
await asyncio.sleep(0.01)
finally:
cleaned_up = True # runs only if something cancels or closes the task
async def main():
try:
await asyncio.wait_for(asyncio.sleep(1), 0.05)
print("1. wait_for(sleep(1), 0.05) returned")
except TypeError as e:
print("1. wait_for(sleep(1), 0.05): TypeError:", e)
except asyncio.TimeoutError:
print("1. wait_for(sleep(1), 0.05): TimeoutError")
try:
await asyncio.wait_for(pause(1), 0.05)
except asyncio.TimeoutError:
print("2. wait_for(pause(1), 0.05): TimeoutError")
names = ["sleep_ms", "wait_for_ms", "ThreadSafeFlag", "Queue", "Semaphore", "TaskGroup", "timeout"]
print("3.", ", ".join("%s %s" % (n, "yes" if hasattr(asyncio, n) else "no") for n in names))
asyncio.create_task(forever())
await asyncio.sleep(0.05)
asyncio.run(main())
print("4. after run(), the finally block of forever() has run:", cleaned_up) Output
1. wait_for(sleep(1), 0.05): TimeoutError 2. wait_for(pause(1), 0.05): TimeoutError 3. sleep_ms no, wait_for_ms no, ThreadSafeFlag no, Queue yes, Semaphore yes, TaskGroup yes, timeout yes 4. after run(), the finally block of forever() has run: True
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 on_cpython.py
The four lines differ in every probe but the second:
| Probe | MicroPython 1.29 | CPython 3.14 |
|---|---|---|
1. wait_for(asyncio.sleep(1), 0.05) |
TypeError: coroutine expected |
TimeoutError after 50 ms |
2. the sleep inside an async def |
TimeoutError |
TimeoutError |
3. sleep_ms, wait_for_ms, ThreadSafeFlag |
yes | no |
3. Queue, Semaphore, TaskGroup, timeout |
no | yes |
4. tasks left when run() returns |
stay queued, not cancelled | cancelled, finally runs |
The first probe fails because sleep_ms() and sleep() do not return a coroutine. So that a sleep allocates no
memory, both hand back one shared object that every sleep reuses, and wait_for() and gather() turn what they are
given into a task with create_task(), which accepts only coroutines. The second probe wraps the same sleep in an
async def function, and works on both. Since every sleep reuses that one object, await each sleep where you create
it, on the same line.
Two more differences sit outside the probes. asyncio.TimeoutError is the built-in TimeoutError on CPython since
3.11, but on MicroPython it is a class of asyncio’s own, derived from Exception, so except asyncio.TimeoutError:
is the clause that works on both. CancelledError derives from BaseException on both. And the firmware freezes
asyncio at optimisation level 3, which stores no line numbers, so a traceback that passes through asyncio’s own
code shows its lines as line 1.
Code that runs on both
Await sleeps directly and wrap them in an async def before giving them to wait_for() or gather(), catch
asyncio.TimeoutError, and end your tasks yourself before run() returns rather than counting on run() to do it.
Checkpoint
Check: Where MicroPython's asyncio differs
Practise: three tasks for a greenhouse node
Write the three tasks of a node that blinks a status LED, samples a soil sensor and reports averages, each waiting
only with await asyncio.sleep_ms(). The sample tests run them for 2,050 ms of board time on a virtual clock and
check when each one ran:
Exercise · Medium · Python
Three tasks for a greenhouse node
A greenhouse node on a Pico has three jobs. Write each as an async def task in tasks.py, so that one loop runs all three:
blink(led, period_ms)toggles the status LED everyperiod_ms, the first time at once.sample(adc, readings, period_ms)appendsadc.read_u16()to the listreadingseveryperiod_ms, the first time at once.report(readings, out, period_ms)waitsperiod_ms, then appends a pair(count, average)to the listout: how many readings are inreadingsand their average as a whole number, rounded down ((0, 0)when there are none). Then it emptiesreadingsand waits again.
Each task loops for ever and waits only with await asyncio.sleep_ms(...), never with time.sleep_ms().
The sample tests replace asyncio inside tasks.py with a stand-in on a virtual clock, then run your tasks for up to 2,050 ms of board time: they check when the LED changes, when the readings are taken, and what each report holds.
Starter code · tasks.py
import asyncio
async def blink(led, period_ms):
"""Toggles the LED every period_ms, the first time at once."""
async def sample(adc, readings, period_ms):
"""Appends adc.read_u16() to readings every period_ms, the first time at once."""
async def report(readings, out, period_ms):
"""Every period_ms, from period_ms on: appends (count, average) of the readings to out, then empties readings.
The average is a whole number, rounded down; with no readings the report is (0, 0).
""" The sample tests · test_tasks.py
"""Sample tests for tasks.py. A stand-in for asyncio runs the tasks on a virtual clock that moves only when every task
waits, so 2,000 ms of board time take a moment and every run gives the same times. On a board, tasks.py runs
unchanged on MicroPython's asyncio."""
import heapq
import tasks
class VirtualAsyncio:
"""sleep_ms() and sleep() of asyncio, on a virtual clock in milliseconds."""
def __init__(self):
self.now = 0
self._queue = []
self._order = 0
def sleep_ms(self, ms):
return _WakeAt(self.now + max(0, int(ms)))
def sleep(self, seconds):
return self.sleep_ms(int(seconds * 1000))
def start(self, coro):
self._push(self.now, coro)
def _push(self, when, coro):
self._order += 1
heapq.heappush(self._queue, (when, self._order, coro))
def run_until(self, end_ms):
while self._queue and self._queue[0][0] <= end_ms:
when, _, coro = heapq.heappop(self._queue)
self.now = when
try:
wake = coro.send(None)
except StopIteration:
continue
self._push(wake, coro)
self.now = end_ms
class _WakeAt:
def __init__(self, when):
self.when = when
def __await__(self):
yield self.when
class FakeLed:
"""A Pin output that remembers when it changed."""
def __init__(self, clock):
self.clock = clock
self.level = 0
self.changes = []
def value(self, v=None):
if v is None:
return self.level
self.level = 1 if v else 0
self.changes.append((self.clock.now, self.level))
def toggle(self):
self.value(1 - self.level)
def on(self):
self.value(1)
def off(self):
self.value(0)
class FakeAdc:
"""An ADC whose readings go up by 100 each time: 30000, 30100, 30200 …"""
def __init__(self):
self.reads = 0
def read_u16(self):
self.reads += 1
return 30000 + 100 * (self.reads - 1)
def bench():
clock = VirtualAsyncio()
tasks.asyncio = clock # the stand-in replaces asyncio inside tasks.py
return clock
def test_blink_every_period():
"""blink(led, 250) changes the LED at 0, 250, 500, 750 and 1,000 ms"""
clock = bench()
led = FakeLed(clock)
clock.start(tasks.blink(led, 250))
clock.run_until(1000)
assert led.changes == [(0, 1), (250, 0), (500, 1), (750, 0), (1000, 1)]
def test_sample_every_period():
"""sample(adc, readings, 150) reads at 0, 150 … 900 ms: 7 readings by 1,000 ms, in order"""
clock = bench()
readings = []
clock.start(tasks.sample(FakeAdc(), readings, 150))
clock.run_until(1000)
assert readings == [30000, 30100, 30200, 30300, 30400, 30500, 30600]
def test_report_empty():
"""a report with no readings is (0, 0)"""
clock = bench()
out = []
clock.start(tasks.report([], out, 1000))
clock.run_until(1000)
assert out == [(0, 0)]
def test_three_tasks_together():
"""all three tasks share 2,050 ms: 9 LED changes, and two reports of 7 readings each"""
clock = bench()
led, readings, out = FakeLed(clock), [], []
clock.start(tasks.blink(led, 250))
clock.start(tasks.sample(FakeAdc(), readings, 150))
clock.start(tasks.report(readings, out, 1000))
clock.run_until(2050)
assert [t for t, _ in led.changes] == [0, 250, 500, 750, 1000, 1250, 1500, 1750, 2000]
assert out == [(7, 30300), (7, 31000)]
assert readings == []
def test_two_blinkers():
"""two blink tasks keep their own periods: 100 ms and 300 ms over 600 ms"""
clock = bench()
fast, slow = FakeLed(clock), FakeLed(clock)
clock.start(tasks.blink(fast, 100))
clock.start(tasks.blink(slow, 300))
clock.run_until(600)
assert [t for t, _ in fast.changes] == [0, 100, 200, 300, 400, 500, 600]
assert [t for t, _ in slow.changes] == [0, 300, 600] A hint
Each task is a while True: loop with one await asyncio.sleep_ms(period_ms) in it. blink and sample do their work before the await, so their first turn happens at 0 ms; report puts its await first. When readings is empty, len(readings) is 0, so work out the average only when there is something to divide by.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on the four ideas of this lesson, from the loop’s turns to the differences from CPython. The questions start where your answers have been weakest so far:
Practice that adapts to you
Practise this lesson
14 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What does await asyncio.sleep_ms(0) do in a MicroPython task, and when would you write it?
It ends the task’s turn without waiting for any time to pass. The loop queues the task again as due now, behind every
task that is already due, so those run first and the task carries on after them. Nothing else changes: no memory is
allocated, and if no other task is due the task continues at once. You write it inside long work that would otherwise
keep the loop, such as a filter over a large buffer or a loop that parses a long reply, cutting the work into slices
with await asyncio.sleep_ms(0) after each. Every other task is then at most one slice late; in the lesson’s run,
slices of 15 ms kept a 20 ms heartbeat within 15 ms. It does not help with a single long call that cannot be split.
Two longer interview questions on tasks start from this lesson:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A Pico W runs three asyncio tasks: a status LED that blinks every 100 ms, an MQTT client that sends a reading every minute, and a soil sensor read every 5 seconds. Every few minutes the LED freezes for about 2 seconds, then carries on. How do you find the cause, and what do you change?
-
Mid to senior
You port a CPython asyncio program to a Pico. It calls asyncio.wait_for(asyncio.sleep(5), 1), passes readings through asyncio.Queue(20), runs its workers in an asyncio.TaskGroup, and relies on asyncio.run() cancelling its tasks at exit so that a finally block switches a heater off. What breaks on MicroPython 1.29, and how do you rewrite each part?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
When does a task made with asyncio.create_task() start running?
Only when the running task gives the loop back at an await: create_task() puts the task in the loop's queue and returns at once.
-
What does blink(500) run when blink is an async def function?
Nothing of blink: calling it only makes a coroutine. Use asyncio.create_task(blink(500)) to run it beside you, or await blink(500) to wait for it.
-
Why must a task never call time.sleep_ms()?
It stops the whole program, not the task, because the loop can change tasks only at an await. Inside async def, wait with await asyncio.sleep_ms().
-
How do you keep long computation from freezing the other tasks?
Cut it into slices with await asyncio.sleep_ms(0) after each; every task already due then runs, so the others are at most one slice late.
-
What does gather() return, and what happens when one of its coroutines fails?
A list of their results, in the order you passed them, once the last one is done. If one fails, gather() raises that first error at once, and the other tasks keep running: nothing cancels them.
-
What does asyncio.run() leave behind when its main task returns?
Every other task stays queued, not cancelled, and wakes again at the next run(). asyncio.new_event_loop() empties the queue.
-
Why does wait_for(asyncio.sleep(1), 0.05) raise TypeError on MicroPython?
Its sleeps return one shared object that every sleep reuses, not a coroutine, and wait_for() and gather() accept only coroutines. Wrap the sleep in an async def, and await each sleep where you create it.
-
Which except clause catches an asyncio timeout on both Pythons?
except asyncio.TimeoutError. On MicroPython it is asyncio's own class, derived from Exception, not the built-in TimeoutError that CPython uses.
Key takeaways
- One loop runs one task at a time: a task gives the loop back only at an await that has to wait, and
create_task()only queues a task. time.sleep_ms(), a driver that polls or a long calculation inside a task holds up every task, so await every wait and slice long work withawait asyncio.sleep_ms(0).gather()returns the results in the order of its arguments when the last task ends, and raises the first error while the other tasks go on; a task that nobody awaits and that fails is reported once and is gone.run()returns when its own task returns and leaves the other tasks queued, not cancelled, untilnew_event_loop()empties the queue.- MicroPython’s
asynciois a subset of CPython’s: its sleeps are not coroutines, it has noQueue,Semaphore,TaskGrouportimeout(), and itsasyncio.TimeoutErroris its own class.
Terms in this lesson
- blocking call
- A call that waits without letting other code run, such as time.sleep_ms() or a driver that polls a device until it answers. Inside an asyncio task it holds up every other task.
- cooperative multitasking
- Running several jobs by letting each one hand over the processor at points it chooses, such as an await. Nothing interrupts a job, so one that never hands over holds up all the others.
- coroutine
- The object an async def function returns when it is called. None of its code runs until a loop runs it as a task or another coroutine awaits it, and it stops at each await that has to wait.
- event loop
- The part of asyncio that runs the tasks: it takes the task that is due first, runs it to an await that has to wait, and idles when no task is due. MicroPython has a single one.
- task
- A coroutine that the asyncio loop runs beside others, made with asyncio.create_task(). It runs until it awaits something that has to wait, and then the loop runs another task.
References
- asyncio – asynchronous I/O scheduler (MicroPython project)
- The loop, sleep_ms(), create_task() and run() (extmod/asyncio/core.py, v1.29.0) (MicroPython project)
- wait_for() and gather() (extmod/asyncio/funcs.py, v1.29.0) (MicroPython project)
- The names the asyncio package provides (extmod/asyncio/__init__.py, v1.29.0) (MicroPython project)
- asyncio frozen at optimisation level 3 (extmod/asyncio/manifest.py, v1.29.0) (MicroPython project)
- micropython – opt_level() and what each level stores (MicroPython project)
- time – ticks_ms(), ticks_add() and ticks_diff() (MicroPython project)
- Python 3.8 differences: asyncio.CancelledError (MicroPython project)
- CPython: Coroutines and Tasks (Python 3.14) (Python Software Foundation)
- CPython: Runners, asyncio.run() (Python 3.14) (Python Software Foundation)
- CPython: asyncio.run() cancels the tasks left at the end (Lib/asyncio/runners.py, 3.14) (Python Software Foundation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress