Advanced uasyncio Patterns¶
Basic async def/await gets you concurrent I/O without threads. Real
device firmware needs more: coordinating multiple coroutines safely,
cancelling work that's taking too long, and structuring a program that
does several unrelated things at once without turning into a tangle of
callbacks. uasyncio (MicroPython's asyncio) supports most of
CPython's asyncio primitives with a few device-specific gaps worth
knowing. Coordination logic below runs under plain python3's
asyncio — the same primitives, close enough semantics — with
MicroPython-specific gaps called out explicitly.
Events — one-shot signaling between coroutines¶
import asyncio
async def waiter(event, name):
print(name, "waiting")
await event.wait()
print(name, "proceeding")
async def setter(event):
await asyncio.sleep(1)
print("setting event")
event.set()
async def main():
event = asyncio.Event()
await asyncio.gather(
waiter(event, "A"),
waiter(event, "B"),
setter(event),
)
asyncio.run(main())
Event.set() wakes every coroutine waiting on it, not just one —
that's what distinguishes it from a queue. Once set, event.wait()
returns immediately for any coroutine that awaits it afterward too,
until event.clear() resets it. A common bug is expecting set() to
behave like a single-consumer signal; if only one waiter should proceed
per signal, a Lock or a bounded Queue is the right primitive, not
Event.
Locks — protecting shared state across coroutines¶
import asyncio
counter = 0
lock = asyncio.Lock()
async def increment():
global counter
async with lock:
current = counter
await asyncio.sleep(0) # yield point — without the lock, a race here
counter = current + 1
async def main():
await asyncio.gather(*(increment() for _ in range(10)))
print(counter) # 10, reliably, because the lock serializes the critical section
asyncio.run(main())
Because uasyncio is cooperative single-threaded scheduling, a race
only becomes possible at an actual await point inside a "critical
section" — code with no await in it can't be interrupted by another
coroutine. This is different from the _thread/dual-core races covered
next module, where any line can be preempted. It's tempting to
conclude locks are unnecessary in uasyncio for that reason; they're
still needed whenever a critical section spans an await (as above),
which is common — any critical section that itself awaits I/O.
Queues — producer/consumer without polling¶
import asyncio
async def producer(queue):
for i in range(5):
await queue.put(i)
await asyncio.sleep(0.1)
await queue.put(None) # sentinel signaling "done"
async def consumer(queue):
while True:
item = await queue.get()
if item is None:
break
print("consumed:", item)
async def main():
queue = asyncio.Queue(maxsize=3)
await asyncio.gather(producer(queue), consumer(queue))
asyncio.run(main())
Queue(maxsize=3) makes put() block once the queue is full, which is
what provides backpressure — a producer that's faster than its
consumer is throttled automatically instead of growing the queue
unbounded (a real memory concern given how small the heap is; see the
memory module). A sentinel value (None here) is the idiomatic way to
signal "no more items" since uasyncio queues have no built-in
"closed" state.
Stream readers/writers¶
uasyncio supports asyncio.StreamReader/StreamWriter for
socket-based protocols (this maps closely onto MicroPython's
asyncio.open_connection / asyncio.start_server):
async def handle_client(reader, writer):
data = await reader.readline()
writer.write(b"echo: " + data)
await writer.drain()
await writer.wait_closed()
async def main():
server = await asyncio.start_server(handle_client, "0.0.0.0", 8080)
async with server:
await server.serve_forever()
await writer.drain() after a write is easy to skip and easy to
regret — without it, writes queue up in an internal buffer with no
backpressure, and a slow or stalled client can grow that buffer
indefinitely on a device with a few hundred KB of heap. Always pair
write() with drain() in a loop.
Graceful cancellation and timeouts¶
import asyncio
async def slow_sensor_read():
await asyncio.sleep(5)
return 42
async def main():
try:
result = await asyncio.wait_for(slow_sensor_read(), timeout=1)
print("got:", result)
except asyncio.TimeoutError:
print("sensor read timed out — falling back to cached value")
asyncio.run(main())
asyncio.wait_for cancels the inner coroutine by raising
CancelledError inside it at its next await point — cancellation is
cooperative, not immediate. Code holding a resource (a lock, an
open file, a peripheral in a half-configured state) needs a finally
block to release it, because cancellation is just another exception
being raised at an await point:
async def careful_read(lock):
async with lock:
try:
await asyncio.sleep(10) # cancellable here
finally:
print("cleanup runs even if cancelled")
Catching CancelledError and not re-raising it is a documented
anti-pattern — it silently defeats the cancellation the caller
requested. If a try/except around await needs cleanup logic, use
finally, and let CancelledError propagate unless there's a specific
reason to suppress it (rare, and worth a comment explaining why).
Structuring larger async applications¶
For anything beyond a couple of coroutines, structure the app as a set
of independent long-running tasks started once and supervised, rather
than nesting gather() calls:
async def sensor_task(state):
while True:
state["temp"] = read_temp()
await asyncio.sleep(1)
async def network_task(state):
while True:
await publish(state.get("temp"))
await asyncio.sleep(5)
async def main():
state = {}
tasks = [
asyncio.create_task(sensor_task(state)),
asyncio.create_task(network_task(state)),
]
await asyncio.gather(*tasks)
A shared plain dict (state above) is a common, simple way to pass
data between long-running tasks without a queue when the "freshest
value wins" semantics are fine — but it's a design choice, not a
default: use a Queue instead when every value must be seen exactly
once (an event, not a reading).
MicroPython-specific gaps versus CPython's asyncio¶
- No
asyncio.TaskGroup(a more recent CPython addition) on most MicroPython builds —gather()plus a supervising loop is the portable pattern. - Exception handling inside
gather(): an unhandled exception in one task doesn't necessarily cancel siblings the wayTaskGroupdoes in recent CPython — check the specificuasyncioversion's behavior before relying on it. - Testing async device code with no event loop of its own available
(i.e., off-device) generally means running the coroutine logic under
desktop
asynciowith hardware calls mocked out, exactly as done in the code above.
How It Actually Works¶
Event, Lock, and Queue are not separate scheduling mechanisms — they
are all thin bookkeeping layers built on top of the exact same
single-generator-resume loop that Level 1 module 9 introduced, which is
why understanding that loop explains every subtlety here.
asyncio.Lockdoesn't need OS-level mutex primitives (no compare- and-swap, no memory barriers) because there's only one real thread of execution — the lock is really just a plain Python boolean flag plus a small wait-list of coroutines to resume when it clears.async with lockcompiles to "if the flag is set, suspend this coroutine and add it to the lock's wait-list; otherwise set the flag and proceed," and releasing it resumes the next waiter. This is why locks inuasyncioonly matter across anawaitboundary, as the module says: code between twoawaitpoints runs as one uninterrupted turn of the scheduler (the same non-preemptive guarantee from Level 1 module 9), so there is no possible interleaving for the lock to prevent unless the critical section itself yields control mid-way.Event.set()waking every waiter, versus aQueuehanding one item to one consumer, follows directly from what each data structure stores. AnEventholds one boolean and a list of coroutines blocked onwait()— setting it resolves every one of those pending futures in the same scheduler pass, because there's nothing in the data structure distinguishing one waiter from another. AQueueinstead holds a bounded ring buffer of actual items and a separate wait-list for getters blocked on an empty queue and putters blocked on a full one — when an item arrives, the scheduler wakes exactly one getter and hands it that one item, because the queue's job is distributing discrete values, not broadcasting a signal.wait_for's cancellation is implemented by raisingCancelledErrorinside the target coroutine at its next suspension point, using exactly the same generator.throw()mechanism CPython's asyncio uses — the event loop holds a live reference to the coroutine's generator object; cancelling it calls.throw(CancelledError)on that generator instead of.send(None), which resumes execution at the lastawaitbut as an exception instead of a normal return. This is precisely why cancellation is cooperative and can only happen at anawait: there is no other point where the generator's frame is suspended and resumable at all — a tight loop with noawaitin it (module 1's starvation case) is equally immune to cancellation as it is to yielding.- The lack of
TaskGroupon most MicroPython builds is a code-size decision, not a fundamental scheduling gap.TaskGroup's structured- concurrency behavior (cancel every sibling when one task fails) needs extra bookkeeping — tracking a task's children, propagating exceptions across that tree, and coordinating group-wide cancellation — thatuasyncio's minimal core scheduler doesn't carry, in keeping with the same "smallest correct primitive" philosophy that givesumqtt.simpleno QoS 2 andjsonno streaming parser: the underlying single-generator- resume loop can support the feature, but the extra bookkeeping code costs flash space this port's maintainers chose not to spend by default.
Cheat sheet¶
| Primitive | Use for |
|---|---|
Event |
One-to-many, "something happened" signaling |
Lock |
Protecting a critical section that spans an await |
Queue(maxsize=n) |
Producer/consumer with automatic backpressure |
StreamReader/StreamWriter |
Socket protocols; always pair write() with drain() |
wait_for(coro, timeout=...) |
Bounding how long a coroutine can run |
finally around cancellable code |
Guaranteed cleanup regardless of cancellation |
asyncio.create_task() + gather() |
Structuring several independent long-running loops |
Exercise¶
Using plain python3's asyncio, write a small pipeline: a
producer() coroutine that pushes 20 integers onto an
asyncio.Queue(maxsize=5) with a small sleep between each, and two
consumer() coroutines running concurrently that each pull from the
queue and print what they consumed along with their own name, until a
sentinel None tells them to stop (the producer should put one
sentinel per consumer). Wrap the whole run in asyncio.wait_for with a
generous timeout, and add a try/finally in each consumer that prints
"consumer done" on exit — including if cancelled. Run it and confirm
all 20 items are consumed across the two consumers with no item lost
or duplicated.