11.2. Coroutines and tasks

Coroutines are the unit of work an asyncio program is built from; tasks are how an application runs several coroutines concurrently.

11.2.1. Coroutines

A coroutine is a function declared with async def:

import asyncio

async def heartbeat(interval_ms):
    while True:
        print("tick")
        await asyncio.sleep_ms(interval_ms)

The body looks like an ordinary function, with one extra ingredient: await. Wherever the coroutine has to wait for something – a sleep, a network read, an event being set – it awaits an expression that knows how to suspend the coroutine until the thing it is waiting for is ready. At each await the coroutine hands control back to asyncio; asyncio resumes it from the same point once the awaited operation has completed.

The asyncio module ships two sleeps:

  • asyncio.sleep() – argument in seconds, accepts a float.

  • asyncio.sleep_ms() – argument in milliseconds, takes an int. A MicroPython extension; usually the right choice on the camera because timing knobs in firmware are millisecond-shaped.

A bare async def does nothing on its own. Calling heartbeat(500) does not execute the body; it returns a coroutine object that asyncio has to schedule. The simplest way to schedule one is asyncio.run():

asyncio.run(heartbeat(500))

asyncio.run() starts the event loop, schedules the coroutine it was handed as the top-level entry point, drives the loop until that coroutine returns, then tears the loop down. For a single coroutine that’s the whole program. For several coroutines the application reaches for tasks.

11.2.2. Tasks

A task is asyncio’s wrapper around a coroutine that says schedule this concurrently with the current one and let me keep going. asyncio.create_task() makes one and returns a Task object representing the scheduled work:

task = asyncio.create_task(heartbeat("fast", 100))

The coroutine is now on the loop’s schedule; the caller has not waited for it. The returned Task is the handle the caller uses afterwards to interact with that running work.

Once the application has the handle it can do three things with it:

  • Wait for the task to finish. A Task is itself awaitable. result = await task suspends the current coroutine until task’s coroutine returns, then resumes with whatever that coroutine returned (or re-raises whatever it raised).

  • Cancel the task. task.cancel() schedules asyncio.CancelledError to be raised inside the task’s coroutine at its next await, giving it a chance to run cleanup code in a finally block. The page on timeouts and cancellation covers the details.

  • Identify it later. asyncio.current_task() returns the Task for the coroutine that is currently running. Most scripts never call it; it shows up in instrumentation and in exception handlers.

The script does not have to capture the handle every time. Throwaway background tasks the application starts and leaves running can drop the return value – the loop still schedules them:

import asyncio

async def heartbeat(name, interval_ms):
    while True:
        print(name)
        await asyncio.sleep_ms(interval_ms)

async def main():
    asyncio.create_task(heartbeat("fast", 100))
    asyncio.create_task(heartbeat("slow", 500))
    await asyncio.sleep(5)

asyncio.run(main())

The two create_task calls schedule both heartbeats without waiting for either of them. Control returns immediately to main, which then awaits a five-second sleep. While it sleeps the two heartbeat tasks make progress; the loop cycles through whichever task is ready to run. After five seconds main returns, the loop tears down whatever tasks are still alive, and asyncio.run() returns to the caller.

Capture the handle whenever the application actually needs one of the three operations above. In practice that means almost always, because cleanly shutting an application down means cancelling the background tasks it spawned – the cancellation page covers the pattern.

11.2.3. The two-line rule

The minimal asyncio program is the two lines the examples above end with:

async def main():
    ...

asyncio.run(main())

Everything else – the tasks the application creates, the primitives it coordinates them with, the streams it opens – happens inside main (and inside the coroutines main spawns). When a script outgrows the camera’s classic while True: csi0.snapshot() loop, the answer is not to call asyncio.run() in several places; it is to fold the new work into main as more tasks.