Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

Two tasks on one loop

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

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:

Interactive · stage by stage How the loop runs the tasks of blink_and_sample.py Step through the first turns of the run, then the moment main returns. Every name, reading and time is the program's.
How the loop runs the tasks of blink_and_sample.py, step 8 of 8. Showing Take the task due first, Run it to an await, Queue it by its due time (blink due at 1,000 ms), Idle until one is due. In focus: Run it to an await.another task is duea task is dueTake the task due firstthe earliest due timeRun it to an awaitthat has to waitQueue it by its due timeblink due at 1,000 msIdle until one is duethe processor waits

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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

  1. Question 1 of 2 In blink_and_sample.py, main() calls create_task() for blink and sample, and only then logs its line. Why does main's line come before the LED turns on?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: create_task() only queues the task; blink first runs when main awaits and gives the loop back

    A new task waits in the queue until the running task reaches an await that has to wait. main reached its await after logging, so the log came first, then blink and sample at 0 ms.

  2. Question 2 of 2 The queue holds sample due at 300 ms, blink due at 500 ms and main due at 1,100 ms. At 300 ms sample runs and awaits sleep_ms(300). At what time does the loop run a task next?

    Type a number.

    A hint

    sample is now due at 600 ms. Which due time is the earliest?

    Show the answer: Question 2 of 2

    Answer: 500 ms

    The loop always takes the task due first. After sample asks for 600 ms, blink at 500 ms is the earliest, so the loop idles until 500 ms and runs blink, as the recorded run shows.

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:

Interactive · message by message A job that blocks, and the same job awaited Step through the first 120 ms of the heartbeat and the job, first with time.sleep_ms(120), then with await, then in slices.
A job that blocks, and the same job awaited, step 6 of 6. Messages so far: Heartbeat task to The loop: sleep_ms(20); Job task to The loop: sleep_ms(380); Job task to The loop: await sleep_ms(120); Job task to The loop: sleep_ms(0) after 15 ms.Heartbeat taskdue every 20 msThe loopone task at a timeJob taskwaits 120 mssleep_ms(20)sleep_ms(380)await sleep_ms(120)sleep_ms(0) after 15 ms

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 heartbeat beside a job that blocks, awaits or works in slices Python · tasks/blocking.py
"""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

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

  1. Question 1 of 2 Which of these lines, inside a task, hold up every other task while they run? Pick every one that does.

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • time.sleep_ms(50)
    • while not button.value(): pass, a loop that reads a pin until it changes
    • crc = crc16(buffer), a 200 ms calculation with no await inside

    The loop can change tasks only at an await that has to wait. A sleep, a polling loop or a long calculation that never awaits keeps it for its whole length.

  2. Question 2 of 2 A task does 90 ms of work in 10 slices of 9 ms, with await asyncio.sleep_ms(0) after each slice. At most how late can a heartbeat be that falls due while the work runs?

    Type a number.

    A hint

    When does a heartbeat that falls due in the middle of a slice get its turn?

    Show the answer: Question 2 of 2

    Answer: 9 ms

    A heartbeat that falls due at the start of a slice waits until the slice ends and the task awaits sleep_ms(0): at most one slice, 9 ms. In the lesson's run, slices of 15 ms gave a worst wait of 15 ms.

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(), a task that fails alone, and what run() leaves Python · tasks/gather_and_run.py
"""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

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 raised OSError 19 at 120 ms, and gather() raised it in main at once. The light reading was not cancelled: it answered at 150 ms, with nobody left to take its value. The third task, made with create_task() and never awaited, raised the same error at 160 ms; the loop printed Task exception wasn't retrieved with the error, ended that task and let the heartbeat go on.
  • When main returned at 220 ms, so did run(), and nothing cancelled the heartbeat: the next run() woke it again at 300 and 400 ms, and only asyncio.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

  1. Question 1 of 2 read_a() answers 70 after 90 ms and read_b() answers 12 after 20 ms. What does await asyncio.gather(read_a(), read_b()), started at 0 ms, return, and when?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: [70, 12], at 90 ms

    gather() returns when the last task ends, here at 90 ms, with the results in argument order, as the three reads of gather_and_run.py returned [23.5, 61, 1009] at 80 ms.

  2. Question 2 of 2 main() makes a heartbeat task, sleeps 200 ms and returns. What happens to the heartbeat when asyncio.run(main()) returns on MicroPython 1.29?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It stays queued, not cancelled, and runs again if run() is called again, unless new_event_loop() empties the queue first

    In the lesson's run the heartbeat came back at 300 and 400 ms in the second run(), and new_event_loop() is what kept it out of the third.

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:

Four probes, on the model of MicroPython 1.29 and on CPython 3.14.8

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

  1. Question 1 of 2 On MicroPython 1.29, what does await asyncio.wait_for(asyncio.sleep(2), 1) do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It raises TypeError: coroutine expected

    differences.py printed exactly this TypeError for probe 1, while CPython printed TimeoutError. Wrap the sleep: async def pause(s): await asyncio.sleep(s), then wait_for(pause(2), 1).

  2. Question 2 of 2 Which except clause catches a wait_for() timeout, and only the timeout, on both MicroPython and CPython?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: except asyncio.TimeoutError

    asyncio.TimeoutError derives from Exception on MicroPython and is the built-in TimeoutError on CPython 3.11 and later, so naming it through asyncio is portable.

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 every period_ms, the first time at once.
  • sample(adc, readings, period_ms) appends adc.read_u16() to the list readings every period_ms, the first time at once.
  • report(readings, out, period_ms) waits period_ms, then appends a pair (count, average) to the list out: how many readings are in readings and their average as a whole number, rounded down ((0, 0) when there are none). Then it empties readings and 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.

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.

  1. Question 1 of 14 What does asyncio.create_task(blink(500)) do?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: It puts a new task in the loop's queue and returns it at once; the task runs when the loop gets to it

    In blink_and_sample.py both tasks were created before main's log line, yet ran after it.

  2. Question 2 of 14 What does this main() do on the board?

    Read the code, then choose one answer.

    async def main():
        blink(500)
        await asyncio.sleep_ms(2000)
    Show the answer: Question 2 of 14

    Answer: It waits 2 seconds, and the LED never changes

    Write asyncio.create_task(blink(500)) to run it beside main, or await blink(500) to wait for it.

  3. Question 3 of 14 At 0 ms task A awaits sleep_ms(100), and task B awaits sleep_ms(40) three times in a row. In what order, and at what times, do they run after 0 ms?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: B at 40 ms, B at 80 ms, A at 100 ms, B at 120 ms

    Each sleep is a due time. Sorting them gives 40 (B), 80 (B), 100 (A), 120 (B).

  4. Question 4 of 14 Inside an async def task on a board, which call waits 100 ms without holding up the other tasks?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: await asyncio.sleep_ms(100)

    In blocking.py, await asyncio.sleep_ms(120) cost the heartbeat nothing, while time.sleep_ms(120) cost it 11 beats.

  5. Question 5 of 14 At 0 ms a heartbeat awaits sleep_ms(20), and then a job calls time.sleep_ms(120). How many milliseconds late does the heartbeat run?

    Type a number.

    A hint

    When does the job give the loop back?

    Show the answer: Question 5 of 14

    Answer: 100 ms

    The heartbeat is due at 20 ms but can run only when the job awaits, at 120 ms: 100 ms late, as the step-through showed.

  6. Question 6 of 14 A task runs one 400 ms calculation inside a single call that cannot be split, and a 20 ms heartbeat shares the loop. What helps the heartbeat?

    Choose one answer.

    Show the answer: Question 6 of 14

    Answer: Make the work divisible, for example process the samples in blocks with await asyncio.sleep_ms(0) between blocks, or move it off the loop

    sleep_ms(0) helps only between slices of work. A call that cannot be split keeps the loop for its whole length, so split the work, make it smaller, or run it where it does not hold the loop.

  7. Question 7 of 14 gather() runs three reads that take 30, 80 and 50 ms, starting at 0 ms. At what time does it return?

    Type a number.

    Show the answer: Question 7 of 14

    Answer: 80 ms

    The reads run side by side, so gather() returns when the slowest ends, at 80 ms, as in gather_and_run.py.

  8. Question 8 of 14 In await asyncio.gather(light(), missing()), missing() raises OSError at 40 ms and light() would answer at 150 ms. What happens?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: gather() raises the OSError at 40 ms, and light() keeps running and answers at 150 ms, its value unused

    With the default return_exceptions=False, the first exception reaches the caller of gather() at once, and the other tasks carry on with nobody waiting for them.

  9. Question 9 of 14 A task made with create_task() raises OSError, and nothing ever awaits it. What does MicroPython do?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: It reports "Task exception wasn't retrieved" with the error, ends that task, and runs the other tasks on

    gather_and_run.py shows the report at 160 ms, while the heartbeat goes on beating.

  10. Question 10 of 14 A main.py ends with asyncio.run(main()), where main() makes three tasks and returns. On the board nothing seems to run. Why, and what is the fix?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: main returned at once, so run() returned and the loop stopped; end main with an await that never returns, such as await on a task that loops for ever

    A common shape is main() creating the tasks and then awaiting a task that loops for ever, so that run() keeps going.

  11. Question 11 of 14 Why do MicroPython's sleep_ms() and sleep() return an object that is not a coroutine?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: So that a sleep allocates no memory, one shared object is reused by every sleep

    The shared object also explains probe 1: create_task(), and so wait_for() and gather(), accept only coroutines.

  12. Question 12 of 14 Code brought over from CPython calls asyncio.Queue(10). What happens on MicroPython 1.29?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: It fails with AttributeError, because MicroPython's asyncio has no Queue

    A bounded queue can be built from a list and two events, as the next lesson does.

  13. Question 13 of 14 What does differences.py print on the model of MicroPython 1.29?

    What does this program print? Choose one answer.

    """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)
    Show the answer: Question 13 of 14

    Answer: it prints

    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

    Probes 1, 3 and 4 differ from CPython; probe 2 is the same on both.

  14. Question 14 of 14 A traceback on a Pico shows a frame in asyncio's own core.py at line 1. Why line 1?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: The firmware freezes asyncio at optimisation level 3, which stores no line numbers

    Read the frames of your own code, which keep their line numbers, to find where things went wrong.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

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

  2. 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.

  3. 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().

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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 with await 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, until new_event_loop() empties the queue.
  • MicroPython’s asyncio is a subset of CPython’s: its sleeps are not coroutines, it has no Queue, Semaphore, TaskGroup or timeout(), and its asyncio.TimeoutError is 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.