Handbooks / Python asyncio / Chapter 1

Event Loop & Coroutines

43 pages · ~71 min✓ Reviewed

Start here. Next up: Tasks, Cancellation & Timeouts.

Part 1 · Introduction

Event Loop & Coroutines: Concurrency on One Thread

Most programs spend far more time waiting than computing: for a web service to answer, a database to reply, a socket to deliver bytes. If your code sits idle during every wait, a server handling thousands of connections needs thousands of threads, and each one costs megabytes of stack. asyncio takes a different route. It runs many tasks on one thread and lets each task hand control back whenever it has to wait, so the waiting itself is almost free.

This is cooperative concurrency: a task keeps running until it reaches an await, and only there can another task take over. That makes switch points visible in your code and removes many of the races that plague threads. It also comes with a catch that this chapter keeps coming back to. A task that never awaits, such as a time.sleep() call or a tight CPU loop, freezes everything on the loop, and asyncio does not make CPU-bound work any faster.

By the end of the chapter you will be able to write coroutines with async / await, start a program with asyncio.run, and tell coroutines, Tasks and Futures apart. You will see how an await suspends and resumes a coroutine, and run work concurrently. You will also keep blocking calls off the loop with to_thread and run_in_executor, and pick the right tool (asyncio, threads or processes) for a given workload.

Before you start

You need Python 3.11 or newer and nothing beyond the standard library; every example runs with plain python script.py. Be comfortable with functions, exceptions and context managers (with). Knowing what a generator is helps in the chapter's "under the hood" parts but is not required.

Part 2 · Why asyncio? The Concurrency Model

Concurrency is not parallelism

Two words get mixed up all the time. Concurrency means juggling many tasks so that their progress overlaps in time: while one is waiting, another moves forward. Parallelism means tasks literally running at the same instant, each on its own CPU core. You can have the first without the second. A single cook who starts the rice, chops vegetables while it simmers, and checks the oven is concurrent. Three cooks working at three stoves are parallel.

asyncio gives you concurrency only. Everything runs on one thread, so at any single moment exactly one piece of your Python code is executing. The speedup comes from not wasting the time a task spends waiting.

ConcurrencyParallelism
MeaningProgress overlaps in timeWork happens at the same instant
Hardware neededOne core is enoughSeveral cores
Does asyncio give it?YesNo
Wins forWork that spends its time waiting on I/OWork that burns CPU

The way asyncio achieves this is called cooperative multitasking. A task keeps the thread until it chooses to give it up, and the place where it chooses is an await. The diagram below follows one task through that cycle.

One turn of a task on the single thread
  1. 1Task runsit owns the only thread
  2. 2Reaches an awaitthe result is not ready yet
  3. 3Task steps asideit is parked until its result arrives
  4. 4Another task runsany task that is ready
  5. 5Result arrivesthe first task resumes just after its await

Cooperative switching you can see

Threads are preemptive: the operating system may pause one at almost any bytecode boundary and run another, whether the code is ready or not. You cannot point at a line and say where the switch will happen. asyncio works the opposite way. A switch can only happen at an await that you can see written in the source, and every other line runs straight through without interruption.

python
async def handle(conn):
    req = await conn.read()    # may switch here
    n = count(req)             # cannot switch: no await
    await db.save(n)           # may switch here
    await conn.write(n)        # may switch here

Only the three awaits are switch points; count(req) always runs to completion.

Threadsasyncio
Who switchesThe OS schedulerThe task itself
WhenAny bytecodeOnly at an await
Visible in the code?NoYes
StylePreemptiveCooperative

Here is the idea in a program you can run. Two workers start together, but each hands control back while it sleeps. Worker B has the shorter sleep, so it finishes first even though A started first.

python
import asyncio

async def worker(name, delay):
    print(f"{name} start")
    await asyncio.sleep(delay)
    print(f"{name} done")

async def main():
    await asyncio.gather(worker("A", 0.2), worker("B", 0.1))

asyncio.run(main())
output
A start
B start
B done
A done

Both workers print start before either prints done, because A reached its await and let B begin. The whole run takes about 0.2 seconds, not 0.3.

Where it fits and what waiting costs

asyncio suits I/O-bound workloads: calls to other services over HTTP, database queries that wait for a reply, raw sockets, chat servers and websockets. The classic case is a server holding thousands of mostly idle connections, each of which spends nearly all its time waiting for the next message.

The reason it works so well is that waiting costs almost nothing. When a coroutine awaits a socket, it is parked. No thread is blocked and no CPU is burned. The event loop simply runs whichever other coroutines are ready, and when the data arrives the parked one resumes right after its await.

The second saving is memory. An operating system thread reserves roughly 1 to 8 MB of stack space up front, while a coroutine object is only about 1 to 2 KB. That difference is what makes ten thousand simultaneous connections practical.

UnitMemory each10,000 at once
OS threadAbout 1-8 MB of stackAbout 10-80 GB reserved
CoroutineAbout 1-2 KBAbout 10-20 MB

To see this, the program below starts ten thousand tasks that each wait a tenth of a second. Because the waiting overlaps, the whole thing finishes in roughly a tenth of a second rather than seventeen minutes.

python
import asyncio

async def idle_connection(i):
    await asyncio.sleep(0.1)
    return i

async def main():
    results = await asyncio.gather(*(idle_connection(i) for i in range(10_000)))
    print(len(results), "connections finished")

asyncio.run(main())
output
10000 connections finished

Races, locks and choosing a model

Because switches happen only at an await, the code between two awaits is effectively atomic: no other task can interrupt it. That removes most of the race conditions you would worry about with threads. It does not remove all of them. If a task reads shared state, awaits, and then writes the state back, other tasks can change it during that await and the write is based on stale data. Updates that span an await need an asyncio.Lock.

python
import asyncio

state = {"balance": 100}

async def withdraw_unsafe(amount):
    current = state["balance"]
    await asyncio.sleep(0.01)          # other tasks run here
    state["balance"] = current - amount

async def withdraw_locked(amount, lock):
    async with lock:
        current = state["balance"]
        await asyncio.sleep(0.01)
        state["balance"] = current - amount

async def main():
    await asyncio.gather(withdraw_unsafe(10), withdraw_unsafe(10))
    print("unsafe:", state["balance"])

    state["balance"] = 100
    lock = asyncio.Lock()
    await asyncio.gather(withdraw_locked(10, lock), withdraw_locked(10, lock))
    print("locked:", state["balance"])

asyncio.run(main())
output
unsafe: 90
locked: 80

Two withdrawals of 10 from 100 should leave 80. The unsafe version loses one of them because both tasks read 100 before either wrote back. The lock makes the read, the await and the write happen as one unit.

Common mistake: assuming no awaits means no locks

Code with no await inside the update is safe without a lock. The moment a read-modify-write contains an await, you are back to needing asyncio.Lock.

Now compare the three ways of getting concurrency in Python. Each trades something different for what it gives you.

ModelSwitchingStrengthCatch
ThreadsPreemptiveCheap to write in a blocking styleGIL-bound for Python code, and you need locks everywhere
ProcessesTrue parallelismReal speedup for CPU workHeavy to start, and every argument must be pickled across IPC
asyncioExplicit, at each awaitLightest cost per taskThe whole call chain has to be async (function colouring)

Function colouring is the catch in that last row. A plain function cannot await, so calling a coroutine from sync code just creates a coroutine object that never runs. Once one function in a call chain becomes async def, its callers must become async too, all the way up. One blocking call hidden inside stalls every task, so you also need async libraries all the way down.

Which model fits the workload?
Remember

asyncio is concurrency on one thread, not parallelism. Your awaits are the only switch points, and CPU-bound work gets no speedup from it.

Part 3 · Coroutines and async / await

What a coroutine is

Writing async def in front of a function changes what calling it means. The result is a coroutine function, and when you call it, Python does not run a single line of the body. Instead the call hands back a coroutine object, a suspendable computation that is waiting to be started.

The example below defines a coroutine function, calls it, and prints nothing from the body until the coroutine is actually run. asyncio.run is used here only as a way to start the coroutine. The event loop behind it gets its own section later.

python
import asyncio

async def greet():
    print('hi')
    return 'done'

c = greet()
print(type(c))
print(asyncio.run(c))

The call alone prints nothing from the body

output
<class 'coroutine'>
hi
done

Notice the order. The line print(type(c)) ran first and showed a coroutine object, and the word hi appeared only once asyncio.run started the coroutine. A coroutine object runs in exactly two situations: another coroutine awaits it, or it is wrapped in a Task and scheduled on the event loop.

Calling is not running

async def gives you a recipe, not a result. The body starts only when the coroutine is awaited or scheduled as a Task.

How await works

Inside a coroutine, await expr pauses the current coroutine until the awaitable expr has finished. The whole await expression then evaluates to the awaitable's result. If the awaitable failed, the exception is re-raised at the await line, so you can wrap it in an ordinary try and except.

Because await can pause a function, Python only accepts it in places that are allowed to pause. That means the body of an async def, or the top level of the python -m asyncio REPL. Anywhere else it is a SyntaxError, even in a module-level script.

Where the await isOutcome
Inside an async defWorks
Top level of the python -m asyncio REPLWorks
Inside a plain defSyntaxError
Top level of a normal script or moduleSyntaxError

Here is the classic shape. fetch waits one second and returns a value, and a second coroutine awaits it to get that value. Only a coroutine can await another coroutine, which is why the caller is itself an async def.

python
import asyncio

async def fetch():
    await asyncio.sleep(1)
    return 'data'

async def main():
    result = await fetch()
    print(result)

asyncio.run(main())
output
data

Coroutines are built on the same machinery as generators, which is why they can pause and resume. Calling send(None) on a coroutine object advances it to its next suspension point. When the body finishes, the coroutine raises StopIteration, and the return value is stored in its value attribute. The event loop does exactly this for you behind the scenes. Doing it by hand shows the mechanism.

python
async def add():
    return 1 + 2

c = add()
try:
    c.send(None)
except StopIteration as e:
    print(e.value)

Driving a coroutine by hand, the way the loop does

output
3

This coroutine never suspends, so a single send(None) ran it to the end. A coroutine that awaits something pending would stop at that point instead, and the loop would send again later to resume it.

async with, async for and async generators

Ordinary with and for statements call methods that cannot pause. When setting up a resource or fetching the next item needs to wait on I/O, Python offers async versions of both. They use their own special methods, which are themselves coroutines.

StatementMethods it callsTypical use
async with__aenter__ and __aexit__Acquire and release a connection or lock
async for__aiter__ and __anext__Read rows or messages as they arrive
Async generatorasync def containing yieldProduce values that need awaits between them

An async generator is simply an async def that uses yield. You cannot loop over it with a plain for. You consume it with async for, which awaits each next value, and that in turn can only happen inside another coroutine. The example combines an async with block and an async for loop over an async generator.

python
import asyncio

class Session:
    async def __aenter__(self):
        print('open')
        return self

    async def __aexit__(self, exc_type, exc, tb):
        print('close')

async def ticks(n):
    for i in range(n):
        await asyncio.sleep(0)
        yield i

async def main():
    async with Session():
        async for t in ticks(3):
            print('tick', t)

asyncio.run(main())
output
open
tick 0
tick 1
tick 2
close

The open line comes from __aenter__ and the close line from __aexit__, which runs even if the loop body raises. Between them, each pass of async for waits for the next value from ticks.

Common mistakes and checking what you have

Calling a coroutine without await

Writing fetch() on its own line creates a coroutine object and throws it away. Python warns RuntimeWarning: coroutine was never awaited, the body never runs, and any result is lost. Write await fetch(), or schedule it as a Task.

python
async def main():
    fetch()        # RuntimeWarning: coroutine 'fetch' was never awaited
    await fetch()  # this one actually runs

The first line only builds an object

The opposite mistake is awaiting something that is not awaitable. If you await the result of an ordinary function, you are awaiting its return value, such as an int, and Python refuses with a TypeError.

python
import asyncio

def load():
    return 5

async def main():
    try:
        await load()
    except TypeError as e:
        print(e)

asyncio.run(main())
output
object int can't be used in 'await' expression
Awaiting a plain function's result

await g() only works when g is an async def or otherwise returns an awaitable. If g is a plain def, drop the await, or run it with asyncio.to_thread if it blocks.

When you are unsure what you are holding, ask Python. inspect.iscoroutinefunction takes the function itself, the thing you wrote after async def. inspect.iscoroutine takes the object you get back from calling it. asyncio.iscoroutine answers the same question as inspect.iscoroutine. The code below runs both checks on both objects.

python
import asyncio
import inspect

async def fetch():
    return 'data'

c = fetch()
print(inspect.iscoroutinefunction(fetch))
print(inspect.iscoroutine(fetch))
print(inspect.iscoroutine(c))
print(asyncio.iscoroutine(c))
c.close()

c.close() quietly discards the unused coroutine

output
True
False
True
True

The table lines up each check with the two things you might pass to it. Each column is named after what you hand to the check. Mixing the two up gives False.

CheckCalled on fetch (the function)Called on fetch() (the coroutine object)
inspect.iscoroutinefunctionTrueFalse
inspect.iscoroutineFalseTrue
asyncio.iscoroutineFalseTrue

Part 4 · The Event Loop

What the Loop Is

Every await you have written so far hands control to something, and that something is the event loop. It is asyncio's scheduler: one object, running on one thread, that decides which piece of your program runs next. It does not run anything in parallel. It simply keeps picking the next piece of work and running it to its next suspension point.

To do that job the loop keeps three collections. Think of them as the loop's whole memory.

CollectionWhat it holdsExample entry
Ready queueCallbacks that can run right now, in first-in first-out orderA task's next step after its await finished
Timer heapCallbacks due at a future time, ordered so the nearest one is on topA wake-up for asyncio.sleep(2)
Watched file descriptorsSockets and pipes the loop is waiting on for I/OA client socket that should become readable

Everything asyncio does, from sleep to network reads, comes down to putting entries into these three places and reacting when they become ready. A coroutine that is waiting is not stored in a busy list that the loop polls. It is parked behind a timer or a file descriptor, and it costs nothing until that event fires.

One loop per thread

A thread can have at most one running loop at a time. asyncio.get_running_loop() returns the loop that is running in the current thread, and it raises RuntimeError when there is none. The first snippet below shows both cases.

python
import asyncio

try:
    asyncio.get_running_loop()
except RuntimeError as e:
    print('outside:', e)

async def main():
    loop = asyncio.get_running_loop()
    print('inside:', loop is not None)

asyncio.run(main())

The same call fails outside a loop and succeeds inside one

output
outside: no running event loop
inside: True
Remember

The loop is a ready queue, a timer heap and a set of watched file descriptors, and each thread runs at most one loop.

One Iteration, and How Tasks Run

The loop is just a cycle. Each trip around it is called an iteration, and every iteration does the same four things in the same order.

One loop iteration
  1. 1Run ready callbacksevery callback in the ready queue, one after another
  2. 2Compute a timeouttime until the nearest timer on the heap
  3. 3Wait in the selectorblock until I/O arrives or the timeout expires
  4. 4Queue what became readyI/O callbacks and due timers join the ready queue

The timeout step is what keeps the loop cheap. If the ready queue is empty and the nearest timer is 2 seconds away, the loop sleeps in the selector for at most 2 seconds, and wakes earlier if a socket becomes readable. If callbacks are already waiting, the timeout is zero and the loop does not block at all.

The OS does the waiting

The selector wait is not a loop of polling calls in Python. It is a single call into an operating system facility that watches many file descriptors at once and returns when any of them is ready. Which facility is used depends on the platform.

PlatformOS multiplexerLoop class
LinuxepollSelector-based loop
macOS and BSDkqueueSelector-based loop
WindowsIOCP (I/O completion ports)ProactorEventLoop, the default since Python 3.8

Tasks are driven by callbacks

A Task is not a thread and not a special object that the loop understands. It is driven by ordinary callbacks. When a task is created, the loop puts a call to Task.__step() on the ready queue. When that callback runs, it advances the coroutine by exactly one send(), which runs your code up to its next suspension point. Then control returns to the loop.

When whatever the task was waiting for finishes, another callback puts Task.__step() back on the ready queue, and the cycle repeats. A coroutine with five suspension points is therefore advanced by five separate callbacks.

Scheduling Callbacks and Crossing Threads

You rarely touch the ready queue directly, but the loop exposes the same mechanism that tasks use. Three methods put a plain function on the loop's schedule. They are all called from the loop's own thread.

MethodWhen the callback runsCalled from
loop.call_soon(cb)On the next iteration, after callbacks already queuedThe loop thread
loop.call_later(delay, cb)After delay secondsThe loop thread
loop.call_at(when, cb)When the loop's clock reaches whenThe loop thread
loop.call_soon_threadsafe(cb)On the next iterationAny thread

The first example shows an important detail about call_soon. The call comes before the print in main, but it only queues the callback. Nothing else can run while main is executing, so the callback has to wait until main suspends at its await.

python
import asyncio

async def main():
    loop = asyncio.get_running_loop()
    loop.call_soon(print, 'callback: ran from the ready queue')
    print('main: before the first await')
    await asyncio.sleep(0)
    print('main: after the first await')

asyncio.run(main())

call_soon is queued first, yet it prints second

output
main: before the first await
callback: ran from the ready queue
main: after the first await

Timers work the same way, except the callback waits on the heap instead of the ready queue. call_at takes an absolute time on the loop's own clock, so build it from loop.time(), never from time.time(). Here the callbacks fire in time order, not in the order they were scheduled.

python
import asyncio

async def main():
    loop = asyncio.get_running_loop()
    start = loop.time()
    loop.call_later(0.2, print, 'call_later 0.2')
    loop.call_at(start + 0.1, print, 'call_at start+0.1')
    loop.call_soon(print, 'call_soon')
    await asyncio.sleep(0.3)
    print('done')

asyncio.run(main())
output
call_soon
call_at start+0.1
call_later 0.2
done

Calling in from another thread

The loop is not thread-safe. Its ready queue and timer heap are plain Python structures with no locking, and the loop may be in the middle of using them when another thread touches them. A plain call_soon from a worker thread can corrupt the queue, or leave the loop asleep in the selector with no wake-up. call_soon_threadsafe fixes both: it queues the callback safely and wakes the loop.

In this example, a worker thread hands a print to the loop, then signals an asyncio.Event the same way. Note that Event.set must also go through call_soon_threadsafe, because the event belongs to the loop.

python
import asyncio
import threading

async def main():
    loop = asyncio.get_running_loop()
    done = asyncio.Event()

    def worker():
        loop.call_soon_threadsafe(print, 'from the worker thread')
        loop.call_soon_threadsafe(done.set)

    threading.Thread(target=worker).start()
    await done.wait()

asyncio.run(main())
output
from the worker thread

When the thread needs a result back, use run_coroutine_threadsafe. It returns a concurrent.futures.Future, and calling result(timeout) on it blocks the worker thread, not the loop, until the coroutine finishes.

python
import asyncio
import threading

async def double(x):
    await asyncio.sleep(0.01)
    return x * 2

async def main():
    loop = asyncio.get_running_loop()
    results = []

    def worker():
        cf = asyncio.run_coroutine_threadsafe(double(21), loop)
        results.append(cf.result(timeout=1))

    t = threading.Thread(target=worker)
    t.start()
    while t.is_alive():
        await asyncio.sleep(0.01)
    print(results)

asyncio.run(main())
output
[42]

Debugging, Pitfalls and uvloop

Debug mode

Because the loop runs one callback at a time, any callback that runs for a long time delays everything else. Debug mode makes these problems visible. Turn it on with asyncio.run(main(), debug=True) or by setting the environment variable PYTHONASYNCIODEBUG=1. It logs every callback that runs longer than 100 ms, and it reports coroutines that were created but never awaited.

bash
PYTHONASYNCIODEBUG=1 python app.py

Same effect as passing debug=True to asyncio.run

Common mistake: get_event_loop()

Using asyncio.get_event_loop() to obtain a loop outside a running loop is deprecated. Inside coroutines and callbacks, always use asyncio.get_running_loop(), which returns the loop that is actually running and fails loudly otherwise.

Common mistake: touching the loop from a thread

Calling loop.call_soon from a worker thread is unsafe. Use call_soon_threadsafe, or run_coroutine_threadsafe when you need a coroutine's result.

Common mistake: a slow callback

A callback or a coroutine step that runs for more than about 100 ms stalls every other task, timer and socket on that loop. Keep each step short, and let debug mode tell you which one is slow.

A faster loop: uvloop

The default loop is written in Python on top of the selector. uvloop is a drop-in replacement built on libuv, the same C library that powers Node.js. For network-heavy servers it is often about 2-4x faster. With version 0.18 or later you use it by calling uvloop.run(main()) instead of asyncio.run(main()). It supports Linux and macOS only, not Windows.

Check yourself

This program should print tick about every 0.1 seconds, and report() should take roughly two seconds. In practice no tick appears while report() is running. Why, and what is the smallest fix?
  • main awaits report(), and awaiting a coroutine just runs it inside the caller. The loop gets no turn until report() suspends.
  • report() contains only CPU work and never reaches an await that suspends, so the heartbeat task sits in the ready queue and cannot run.
  • Fix: put await asyncio.sleep(0) inside the for loop of report(). Each pass then hands control back to the loop, so heartbeat gets its turn between slices.
  • Running with debug=True would log the long step of report() as a callback over 100 ms.
import asyncio

async def heartbeat():
    while True:
        print('tick')
        await asyncio.sleep(0.1)

async def report():
    total = 0
    for i in range(50):
        total += sum(range(1_000_000))
    print(total)

async def main():
    asyncio.create_task(heartbeat())
    await report()

asyncio.run(main())
Key takeaways

The loop is a ready queue, a timer heap and a selector. One loop runs per thread, and you get it with get_running_loop(). Cross threads only through call_soon_threadsafe or run_coroutine_threadsafe, and keep every callback well under 100 ms.

Part 5 · asyncio.run: The Entry Point

The bridge from sync code into the loop

A Python program starts in ordinary synchronous code, but a coroutine can only run on an event loop. asyncio.run(main()) is the standard bridge between the two. Plain sync code calls it, hands it a coroutine, and gets that coroutine's result back as if it had made a normal function call. It is normally called once per program, at the very top level, and everything else in your app runs inside the loop it creates.

What asyncio.run(main()) does
  1. 11. Createnew event loop, set as current for this thread
  2. 22. Rundrive main() to completion, return its result or raise its exception
  3. 33. Clean upcancel leftovers, shut down async generators and executor, close the loop

The first step builds a brand-new loop and makes it the current loop for the calling thread, so asyncio.get_running_loop() works inside your coroutines. The second step runs main() until it finishes. Whatever main() returns becomes the return value of asyncio.run, and if main() raises, the same exception comes out of asyncio.run into your sync code.

The usual shape of a script is an async def main() that holds the program, followed by a guarded call at the bottom of the file. The if __name__ == '__main__': guard keeps the loop from starting when another module merely imports your file.

python
import asyncio

async def work():
    await asyncio.sleep(0.1)
    return 'done'

async def main():
    result = await work()
    return result.upper()

if __name__ == '__main__':
    value = asyncio.run(main())   # once, at the top
    print(value)

The result of main() comes back as a plain value

output
DONE

What happens when main() finishes

The third step is cleanup, and it runs whether main() returned normally or raised. asyncio.run does not simply drop the loop. It works through a short list of chores so that nothing is left half-finished before the loop is closed.

Cleanup stepPurpose
Cancel leftover tasksStop any task that is still pending when main() ends
loop.shutdown_asyncgens()Properly finalize async generators that were not run to the end
shutdown_default_executor()Wait for the default thread pool to finish its threads (Python 3.9+)
loop.close()Release the loop and its resources

Exceptions travel the same road as results. The loop is already current while main() runs, and an error raised inside it reaches your calling code only after cleanup has finished.

python
import asyncio

async def main():
    loop = asyncio.get_running_loop()
    print('running:', loop.is_running())
    raise ValueError('boom')

try:
    asyncio.run(main())
except ValueError as e:
    print('caught', e)

A failure in main() is re-raised by asyncio.run

output
running: True
caught boom

The cancellation step has a consequence that surprises many people. When main() returns, every task still pending is cancelled, so fire-and-forget work is killed silently at shutdown. In the next example the background task asks for ten seconds, but main() only waits a tenth of a second.

python
import asyncio

async def background():
    try:
        await asyncio.sleep(10)
        print('finished')
    except asyncio.CancelledError:
        print('background cancelled')
        raise

async def main():
    asyncio.create_task(background())
    await asyncio.sleep(0.1)
    print('main returns')

asyncio.run(main())

The task never gets to print 'finished'

output
main returns
background cancelled

Without that print in the except block you would see nothing at all, which is why this failure is so quiet. The fix is to keep a reference to the task and await it before main() returns.

python
import asyncio

async def background():
    await asyncio.sleep(0.2)
    print('finished')

async def main():
    task = asyncio.create_task(background())
    print('main waiting')
    await task
    print('main returns')

asyncio.run(main())

Awaiting the task lets it complete before cleanup

output
main waiting
finished
main returns
Common mistake: fire-and-forget at shutdown

A task you started with create_task and never awaited is cancelled the moment main() returns. Await your background work, or use a TaskGroup, before main exits.

Already in a loop, or running more than once

asyncio.run always wants to create a fresh loop, so it refuses to start when a loop is already running in the current thread. This happens in Jupyter, where the notebook has its own loop, and also if you call it from inside another coroutine. Either way it raises RuntimeError with the message asyncio.run() cannot be called from a running event loop. In those places the loop already exists, so you simply write await main() instead.

How do I start my async code?

Sometimes a sync program really does need several separate entry points, for example a setup step followed by the main work, and both must live on the same loop. Python 3.11 added asyncio.Runner(), a context manager that creates one loop and lets you call runner.run(coro) as many times as you like. The loop is closed, with the same cleanup as before, when the with block exits. The example below records the loop seen by each call and compares them.

python
import asyncio

loops = []

async def step(name):
    loops.append(asyncio.get_running_loop())
    return name

with asyncio.Runner() as runner:
    print(runner.run(step('setup')))
    print(runner.run(step('main')))
print('runner same loop:', loops[0] is loops[1])

loops.clear()
asyncio.run(step('a'))
asyncio.run(step('b'))
print('run twice same loop:', loops[0] is loops[1])

Runner reuses one loop; two run() calls make two loops

output
setup
main
runner same loop: True
run twice same loop: False
SituationUse
Ordinary script or program entryasyncio.run(main()), once
Jupyter, or inside a coroutineawait main()
Several calls that must share one loop (3.11+)asyncio.Runner() and runner.run(coro)

Mistakes to avoid

Most asyncio.run problems come from treating it like an ordinary function call that is cheap to repeat. Every call builds a loop, runs the full cleanup sequence and closes the loop again, so it only belongs at the outer edge of your program.

Common mistake: asyncio.run per request or inside a loop

Calling asyncio.run for every request, or inside a for loop, creates and tears down a whole event loop each time. That is slow, and it breaks anything bound to a loop. Run once with a long-lived loop, or use asyncio.Runner() when you truly need several calls.

WrongRight
run() per requestOne run() and a long-lived loop
run() in Jupyter or inside a coroutineawait main()
Many run() calls that share stateasyncio.Runner() (3.11+)
Un-awaited background taskAwait it before main() returns

The second mistake follows from the first. Objects such as a Lock, a Queue or a client session belong to the loop they were used on, and they cannot be moved to a different loop. If a second asyncio.run call finds an object that an earlier call tied to its own loop, the object fails. The example below uses a module-level lock that really gets contended in each run.

python
import asyncio

lock = asyncio.Lock()

async def use():
    async def holder():
        async with lock:
            await asyncio.sleep(0.05)
    t = asyncio.create_task(holder())
    await asyncio.sleep(0)
    async with lock:      # has to wait, so it binds to this loop
        pass
    await t

asyncio.run(use())
print('first run ok')
try:
    asyncio.run(use())
except RuntimeError as e:
    print('second run failed:', 'different event loop' in str(e))

The lock stays tied to the first run's loop

output
first run ok
second run failed: True
Common mistake: sharing loop-bound objects across loops

A Lock, Queue or client session created and used on one loop cannot be used from another. Create these objects inside the running loop, and keep a single loop for as long as they live.

Remember

asyncio.run(main()) creates a loop, runs main() to its result or exception, then cancels leftovers, shuts down async generators and the default executor, and closes the loop. Call it once at the top level, use await main() when a loop already runs, and reach for asyncio.Runner() when one loop must serve several runs.

Part 6 · Awaitables: Coroutines, Tasks, Futures

What you can await

The await keyword does not accept just anything. It accepts an awaitable: an object the event loop knows how to wait on. There are three built-in kinds, and a fourth escape hatch for your own classes.

Coroutine

What calling an async def returns

Runs only when awaited

Task

Wraps a coroutine

Scheduled on the loop right away

Future

A result slot filled later

Usually made by library code

await

Any object with this method

It must return an iterator

A coroutine is the plainest awaitable, and it is lazy. Calling step() only builds a coroutine object. Its body starts when something awaits it, and it then runs sequentially inside the caller: the caller waits until the coroutine finishes before moving to its next line. Awaiting a coroutine does not create any concurrency by itself.

The program below awaits two coroutines one after the other. Each one finishes completely before the next one starts, even though both are async def functions.

python
import asyncio

async def step(name, delay):
    print(name, "start")
    await asyncio.sleep(delay)
    print(name, "done")
    return name

async def main():
    a = await step("a", 0.1)
    b = await step("b", 0.1)
    print(a, b)

asyncio.run(main())
output
a start
a done
b start
b done
a b

The fourth kind is any object whose __await__ method returns an iterator. Libraries use this to make their own types awaitable. The class below delegates to asyncio.sleep(0) so that awaiting it yields to the loop once, then hands back a stored value.

python
import asyncio

class Ready:
    def __init__(self, value):
        self.value = value

    def __await__(self):
        yield from asyncio.sleep(0).__await__()
        return self.value

async def main():
    print(await Ready(42))

asyncio.run(main())
output
42
Remember

An awaitable is a coroutine, a Task, a Future, or an object with an __await__ method that returns an iterator. A bare coroutine adds no concurrency; it just runs in its caller.

Futures: a result that arrives later

A Future is a low-level placeholder for a result that does not exist yet. Code that needs the value awaits the Future. Other code, such as a timer, a socket callback or a library internal, fills the slot later. Application code rarely creates one; you meet Futures mostly inside libraries.

A Future is always in one of three states: pending (no outcome yet), done (a result or an exception was set) or cancelled. You move it out of pending with set_result, set_exception or cancel, and you register add_done_callback to be told when that happens.

Statedone()result()
pendingFalseraises InvalidStateError
done with a valueTruereturns the value
done with an exceptionTruere-raises the exception
cancelledTrueraises CancelledError

The example creates a Future by hand with loop.create_future(), attaches a callback, and schedules a timer that fills the slot after a tenth of a second. The await suspends main until then. Done callbacks run through the loop, not inline, which is why the callback prints before the awaiting line resumes.

python
import asyncio

async def main():
    loop = asyncio.get_running_loop()
    fut = loop.create_future()
    fut.add_done_callback(lambda f: print("callback saw", f.result()))
    print("done?", fut.done())
    loop.call_later(0.1, fut.set_result, 42)
    print("awaited", await fut)
    print("again", await fut)
    print("done?", fut.done())

asyncio.run(main())
output
done? False
callback saw 42
awaited 42
again 42
done? True

The second await fut returned instantly with the same value. Awaiting a finished Future is cheap and can be repeated, and many tasks can wait on the same Future at once. Each of them is woken when the result arrives. A bare coroutine cannot do this: it can be awaited only once, and a second await raises RuntimeError.

python
import asyncio

async def waiter(name, fut):
    print(name, "got", await fut)

async def compute():
    return 7

async def main():
    loop = asyncio.get_running_loop()
    fut = loop.create_future()
    waiters = [asyncio.create_task(waiter(n, fut)) for n in "abc"]
    await asyncio.sleep(0)
    fut.set_result("go")
    await asyncio.gather(*waiters)

    coro = compute()
    print("first await:", await coro)
    try:
        await coro
    except RuntimeError as exc:
        print("second await ->", type(exc).__name__)

asyncio.run(main())
output
a got go
b got go
c got go
first await: 7
second await -> RuntimeError
Setting a result only once

A Future accepts one outcome. Calling set_result on one that is already done raises InvalidStateError, so only one party should be responsible for filling a given slot.

Tasks: the unit of concurrency

A Task is a Future subclass that wraps a coroutine and schedules it on the event loop immediately. Because it is a Future, you can await it, ask whether it is done, and collect its result. Because it wraps a coroutine, the loop drives the coroutine for you. This makes the Task the unit of concurrency in asyncio.

You make one with asyncio.create_task(coro, name=...). The coroutine starts running concurrently at the next point where the current code yields to the loop. Later, await task retrieves the result. In the example, users-fetch makes progress while main awaits a different coroutine.

python
import asyncio

async def fetch(name, delay):
    await asyncio.sleep(delay)
    return f"{name} data"

async def main():
    t = asyncio.create_task(fetch("users", 0.2), name="users-fetch")
    print("created:", t.get_name(), t.done())
    other = await fetch("orders", 0.1)
    print("meanwhile:", other)
    print("task done yet?", t.done())
    data = await t
    print("result:", data, t.done(), t.exception())

asyncio.run(main())
output
created: users-fetch False
meanwhile: orders data
task done yet? False
result: users data True None
Life of a Task
  1. 1create_task(coro)Task is scheduled
  2. 2Runs at the next yieldalongside other tasks
  3. 3Suspends at each awaitloop runs others
  4. 4Done or cancelledoutcome is stored

Once a Task exists you can inspect it. task.done() is true once it has finished or been cancelled. task.result() returns the value, or re-raises the exception the coroutine ended with. task.exception() returns that exception, or None if there was none. Calling task.cancel() injects CancelledError at the point where the task is currently awaiting.

python
import asyncio

async def boom():
    raise ValueError("bad")

async def slow():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print("slow saw CancelledError")
        raise

async def main():
    t = asyncio.create_task(boom())
    print("before:", t.done())
    await asyncio.sleep(0)
    print("after:", t.done(), repr(t.exception()))
    try:
        t.result()
    except ValueError as exc:
        print("result() re-raised:", exc)

    s = asyncio.create_task(slow())
    await asyncio.sleep(0)
    s.cancel()
    try:
        await s
    except asyncio.CancelledError:
        print("cancelled:", s.cancelled(), s.done())

asyncio.run(main())
output
before: False
after: True ValueError('bad')
result() re-raised: bad
slow saw CancelledError
cancelled: True True

Putting the three kinds side by side shows why each exists. Write coroutines, start them as Tasks when you want concurrency, and leave raw Futures to library code.

CoroutineTaskFuture
StartsLazy: only when awaitedEager: scheduled at create_taskSet by whoever calls set_result
RunsSequentially in its callerConcurrently with other tasksHolds no code of its own
CancelNo cancel methodtask.cancel()future.cancel()
Made byCalling an async defasyncio.create_taskLibrary code via loop.create_future
Await twiceRuntimeErrorAllowedAllowed
Which awaitable do I need?

Two mistakes that lose tasks and errors

Tasks are easy to start and easy to lose track of. Two failures come up again and again, and both are silent.

Common mistake: nobody keeps the Task alive

The event loop holds only weak references to tasks. If you call create_task and discard the return value, nothing else may be pointing at the Task, so it can be garbage-collected in the middle of its run and simply vanish.

The fix is to hold a strong reference for as long as the task runs. The usual pattern keeps tasks in a set and removes each one when it finishes. A TaskGroup does the same bookkeeping for you, since it keeps strong references to every task created through it.

python
import asyncio

background = set()

async def job(n):
    await asyncio.sleep(0.1)
    print("job", n, "finished")

async def main():
    for n in range(2):
        t = asyncio.create_task(job(n))
        background.add(t)
        t.add_done_callback(background.discard)
    await asyncio.sleep(0.2)
    print("left:", len(background))

asyncio.run(main())
output
job 0 finished
job 1 finished
left: 0
Common mistake: an error in a task nobody awaits

If a task raises and no one ever awaits it or calls result() or exception(), the error is not raised anywhere. It is only logged as Task exception was never retrieved, and only when the task is garbage-collected. That can be long after the failure, or never during a short run.

Retrieve every task's outcome. Either await the task, or attach a done callback that looks at the exception. The callback below first skips cancelled tasks, because calling exception() on a cancelled task raises CancelledError.

python
import asyncio

async def boom():
    raise ValueError("bad input")

def report(task):
    if task.cancelled():
        return
    exc = task.exception()
    if exc:
        print("task", task.get_name(), "failed:", repr(exc))

async def main():
    t = asyncio.create_task(boom(), name="boom")
    t.add_done_callback(report)
    await asyncio.sleep(0.1)

asyncio.run(main())
output
task boom failed: ValueError('bad input')
Rule of thumb

Store every task you start, in a set or a TaskGroup, and make sure its outcome is retrieved. Then it cannot disappear mid-run and its errors cannot hide.

Part 7 · How await Yields Control

Down the chain, up to the Task

An await does not always pause anything. Whether control returns to the event loop depends on what sits at the very bottom of the chain of awaits. To see why, follow one Task as it drives a stack of nested coroutines.

The Task starts the chain by calling send(None) on its top coroutine. Each await on a coroutine simply runs that coroutine's body, so the chain goes down through main(), then fetch(), then read(). None of these steps suspends anything. The descent stops only when it reaches a Future that is still pending, meaning its result has not arrived yet.

One Task, three nested awaits
  1. 1Task drives the chaincalls coro.send(None)
  2. 2main() awaits fetch()running down, no pause yet
  3. 3fetch() awaits read()still running down
  4. 4read() awaits a pending Futurenowhere left to go
  5. 5Future yields itselfthe yield goes back up

The key detail is what Future.__await__ does at that point. It does not return, and it does not block. It yields the Future object itself. A yield inside a coroutine chain passes straight up through every await above it, so the Future travels back through read(), fetch() and main() until it comes out of the send() call in the Task. As a result, the Task always learns exactly which Future the whole stack is waiting on.

You can watch this happen without a Task. The example below drives a two-level chain by hand. The first send(None) returns the very same Future object that the innermost await was waiting on. After the Future gets a result, a second send(None) resumes the chain right after the await and finishes it.

python
import asyncio

async def read(fut):
    return await fut

async def fetch(fut):
    return await read(fut)

async def main():
    loop = asyncio.get_running_loop()
    fut = loop.create_future()
    coro = fetch(fut)
    yielded = coro.send(None)
    print(yielded is fut)
    print(fut.done())
    fut.set_result(42)
    try:
        coro.send(None)
    except StopIteration as e:
        print(e.value)

asyncio.run(main())

Playing the Task's role by hand

output
True
False
42
What reaches the Task

Nested awaits cost nothing until a pending Future is reached. That Future is yielded up through every await in the chain to the Task that drives it.

Suspend, wake up, or run straight through

Once the Task holds the pending Future, it calls add_done_callback(self.__wakeup) on it and then returns to the event loop. Returning is the moment the suspension happens. The Python frames of main(), fetch() and read() stay frozen in memory, and the loop is free to run any other ready task.

From suspension to resumption
  1. 1Task registers the callbackfut.add_done_callback(self.__wakeup)
  2. 2Task returns to the loopthe whole coroutine stack is suspended
  3. 3Other tasks runthe loop keeps working
  4. 4I/O or a timer calls fut.set_result(x)the Future is now done
  5. 5Callback schedules Task.__stepit goes onto the ready queue
  6. 6send() resumes the coroutineright after the await that yielded

The Future does not call the Task directly. When set_result runs, the done callback is queued with the loop, and the loop runs Task.__step on its next pass. The step calls send(), the innermost await returns the result x, and the code continues from exactly where it stopped.

The reverse case matters just as much. If you await a coroutine whose body never reaches a pending Future, nothing is ever yielded. The await behaves like an ordinary function call, running the other coroutine to completion before the caller moves on. No other task gets a turn in the meantime.

The next example shows this. main creates a background task and then awaits inner twice. Those awaits never suspend, so the background task has to wait until main reaches a real yield, await asyncio.sleep(0).

python
import asyncio

async def inner(label):
    print('inner', label)
    return label

async def other():
    print('other ran')

async def main():
    t = asyncio.create_task(other())
    await inner('one')
    await inner('two')
    print('main done awaiting')
    await asyncio.sleep(0)
    print('after sleep(0)')

asyncio.run(main())
output
inner one
inner two
main done awaiting
other ran
after sleep(0)

Each time you reach an await, you can decide whether it suspends by asking one question about what the chain ends in.

Does this await switch tasks?
You awaitSuspends?Why
A pending FutureYesIt yields itself up to the Task
asyncio.sleep(0)YesIt does a bare yield with no Future
A coroutine that reaches no pending FutureNoIt runs like a normal function call
An already-done FutureNoIt returns its result at once

sleep(0) and interleaving

Sometimes you want to give up control even though you are not waiting on anything. await asyncio.sleep(0) is the standard idiom for this. Internally it is a bare yield: it hands control back to the Task without any Future attached, so the Task goes straight back onto the ready queue. Every other task that is already ready gets a turn first, and then this one continues.

Here two workers each print three lines and call await asyncio.sleep(0) after each one. Because every await suspends, the two tasks alternate.

python
import asyncio

async def worker(name):
    for i in range(3):
        print(name, i)
        await asyncio.sleep(0)

async def main():
    await asyncio.gather(worker('A'), worker('B'))

asyncio.run(main())
output
A 0
B 0
A 1
B 1
A 2
B 2

Now remove the await from the loop. The coroutine is still async def, but nothing in it ever suspends. Task A runs to the end without being interrupted, and only then does B get its first turn.

python
async def greedy(name):
    for i in range(3):
        print(name, i)

async def main():
    await asyncio.gather(greedy('A'), greedy('B'))

asyncio.run(main())
output
A 0
A 1
A 2
B 0
B 1
B 2
VersionOutput orderWhere tasks switch
With await asyncio.sleep(0)A0 B0 A1 B1 A2 B2At every await
No await in the loopA0 A1 A2 B0 B1 B2Only when a task ends
The rule

Tasks switch only at points that actually suspend. The word async on a function does not create a switch point, and neither does an await that never reaches a pending Future or a bare yield.

Starving the loop and cancelling

The same rule has a serious consequence. The loop can run only one thing at a time, so a coroutine that holds the thread without suspending blocks every other task. This happens in two ways: a tight loop with no await, or a loop whose awaits all run straight through without suspending.

Common mistake: starving the loop

A long loop with no suspension point freezes everything else on the loop. Timeouts cannot fire, heartbeats stop, and every other client waits until the loop finishes. Awaiting coroutines that never reach a pending Future does not help, because those awaits never yield either.

To find the culprit, run with asyncio.run(main(), debug=True). Debug mode logs any callback that holds the loop for more than 100 ms. The usual fix is to add await asyncio.sleep(0) every so many iterations. For heavy work, move it off the loop, as the later sections on to_thread and process pools show.

Cancellation depends on suspension points in the same way. task.cancel() does not interrupt running code. It arranges for CancelledError to be thrown into the coroutine at the await where it is currently suspended. A coroutine that suspends often is cancelled promptly, as in the example below.

python
import asyncio

async def spinner():
    n = 0
    while True:
        n += 1
        await asyncio.sleep(0)

async def main():
    t = asyncio.create_task(spinner())
    await asyncio.sleep(0)
    t.cancel()
    try:
        await t
    except asyncio.CancelledError:
        print('cancelled at its await')

asyncio.run(main())
output
cancelled at its await

Now suppose the loop body were only n += 1 with no await. The task would never reach a suspension point, so the loop would never get a chance to deliver the cancellation. The task would keep running, and the whole program would hang.

Common mistake: expecting cancel to interrupt

A coroutine that never yields cannot be cancelled, and it also blocks timeout() and wait_for, because they work by cancelling. Cancellation and timeouts both need a real suspension point inside the work.

Part 8 · Running Things Concurrently

Sequential versus concurrent

Putting async on a function does not make anything overlap. A coroutine runs inside whoever awaits it, so await a() followed by await b() finishes a completely before b even starts. The total is time(a) + time(b), exactly as if the code were synchronous.

To overlap the waits you hand several awaitables to the loop at once. asyncio.gather(a(), b()) wraps each coroutine in a Task, lets them run side by side, and gives you one list back. The total drops to about max(time(a), time(b)), because the loop runs the other task while one is waiting. The list holds the results in argument order, not in the order the tasks finished.

python
import asyncio, time

async def a():
    await asyncio.sleep(0.2)
    return 'A'

async def b():
    await asyncio.sleep(0.4)
    return 'B'

async def sequential():
    start = time.perf_counter()
    ra = await a()
    rb = await b()
    print('sequential', [ra, rb], f'{time.perf_counter() - start:.1f}s')

async def concurrent():
    start = time.perf_counter()
    results = await asyncio.gather(b(), a())
    print('gather    ', results, f'{time.perf_counter() - start:.1f}s')

asyncio.run(sequential())
asyncio.run(concurrent())

a finishes first, but gather(b(), a()) still returns b's result first

output
sequential ['A', 'B'] 0.6s
gather     ['B', 'A'] 0.4s
Sequential awaitsgather
Total timetime(a) + time(b)about max(time(a), time(b))
Result orderthe order you awaitedthe order of the arguments
Overlapnonewaits overlap on one thread

When one of them fails

By default, if one awaitable raises, gather re-raises that first exception to you straight away. The other awaitables are not cancelled; they keep running in the background, and you no longer see their results. Passing return_exceptions=True changes this: every exception is placed in the result list as an ordinary value, and gather itself never raises for them. You then sort the failures from the successes yourself.

python
import asyncio

async def ok():
    return 'ok'

async def bad():
    raise ValueError('boom')

async def main():
    try:
        await asyncio.gather(ok(), bad())
    except ValueError as e:
        print('raised', e)

    results = await asyncio.gather(ok(), bad(), return_exceptions=True)
    print(results)
    errors = [r for r in results if isinstance(r, Exception)]
    print(len(errors), 'failed')

asyncio.run(main())
output
raised boom
['ok', ValueError('boom')]
1 failed

TaskGroup and structured concurrency

Python 3.11 added asyncio.TaskGroup, which ties the lifetime of your tasks to a block of code. You open it with async with, start work through tg.create_task(...), and the block does not finish until every task inside it is done. Nothing can outlive the block, which is why this style is called structured concurrency.

Each create_task call returns a Task. After the block exits you read its outcome with t.result(). This is how you get values out, since a TaskGroup does not build a result list for you.

python
import asyncio

async def a():
    await asyncio.sleep(0.1)
    return 'A'

async def b():
    await asyncio.sleep(0.2)
    return 'B'

async def show_results():
    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(a())
        t2 = tg.create_task(b())
    print(t1.result(), t2.result())

asyncio.run(show_results())
output
A B

The failure behaviour is where TaskGroup differs most. When one task raises, the group cancels all its sibling tasks, waits for them to finish cancelling, and then raises the collected errors together as an ExceptionGroup. You catch them with except*, which matches by exception type inside the group.

python
import asyncio

async def slow():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print('slow cancelled')
        raise

async def fail():
    await asyncio.sleep(0.1)
    raise ValueError('boom')

async def with_failure():
    try:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(slow())
            tg.create_task(fail())
    except* ValueError as eg:
        print('caught', [str(e) for e in eg.exceptions])

asyncio.run(with_failure())

fail() raises after 0.1 s, so the group cancels slow() instead of waiting 10 s

output
slow cancelled
caught ['boom']
What a TaskGroup does when a task fails
  1. 1A task raisesinside the async with block
  2. 2Siblings are cancelledeach gets CancelledError at its await
  3. 3Group waits for all taskscleanup runs to the end
  4. 4ExceptionGroup is raisedcatch it with except*

gather or TaskGroup?

gatherTaskGroup
Stylesimple, one callstructured, scoped to a block
Result orderlist in argument orderread each task with t.result()
On failureother tasks keep running by defaultsiblings are cancelled and awaited
Cleanupyou handle itsafe and automatic
Python versionany asyncio3.11+
Best forsmall lists where order mattersnew code, preferred
Rule of thumb

Reach for gather when you have a simple list and want results in order. Prefer a TaskGroup in new 3.11+ code, because a failure can never leave stray tasks running behind you.

Finishing early, timeouts and limits

Sometimes you do not want to wait for everything. asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) returns as soon as one task finishes, handing back two sets: done and pending. It does not raise the tasks' errors; you inspect each task in done yourself, and any task still pending is yours to cancel or keep waiting on. asyncio.as_completed() instead gives you the awaitables in the order they finish, so you can act on the fastest result first.

python
import asyncio

async def job(name, delay):
    await asyncio.sleep(delay)
    return name

async def main():
    tasks = [
        asyncio.create_task(job('slow', 0.3)),
        asyncio.create_task(job('fast', 0.1)),
    ]
    done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
    print('done:', [t.result() for t in done])
    print('pending:', len(pending))
    for t in pending:
        t.cancel()

    coros = [job('slow', 0.3), job('fast', 0.1), job('mid', 0.2)]
    for fut in asyncio.as_completed(coros):
        print('finished', await fut)

asyncio.run(main())
output
done: ['fast']
pending: 1
finished fast
finished mid
finished slow
ToolGives youOrder
gathera list of resultsargument order
TaskGrouptasks, read with t.result()you choose
wait(done, pending) setssets, no order
as_completedan iterator of awaitablesfinish order

Timeouts

Two forms put a time limit on work. async with asyncio.timeout(5): (3.11+) limits a whole block of awaits, while await asyncio.wait_for(coro, 5) limits a single awaitable. When time runs out, both cancel the inner work and then raise TimeoutError to the caller. Catch that error outside the timeout block, because code inside the block only sees the cancellation.

python
import asyncio

async def slow():
    try:
        await asyncio.sleep(5)
    except asyncio.CancelledError:
        print('inner cancelled')
        raise

async def main():
    try:
        async with asyncio.timeout(0.1):
            await slow()
    except TimeoutError:
        print('timeout block')

    try:
        await asyncio.wait_for(slow(), 0.1)
    except TimeoutError:
        print('wait_for')

asyncio.run(main())
output
inner cancelled
timeout block
inner cancelled
wait_for

Limiting fan-out

Concurrency is only useful up to a point. If you build 10,000 coroutines and gather them, you may try to open 10,000 sockets at once. A Semaphore(n) is a counter that lets only n tasks into a guarded section at a time; the rest wait their turn. Wrap the expensive call in async with sem: and everything else stays the same.

python
import asyncio

active = 0
peak = 0
sem = asyncio.Semaphore(3)

async def fetch(i):
    global active, peak
    async with sem:
        active += 1
        peak = max(peak, active)
        await asyncio.sleep(0.05)
        active -= 1
        return i

async def main():
    results = await asyncio.gather(*(fetch(i) for i in range(10)))
    print(len(results), 'done, peak', peak)

asyncio.run(main())

ten tasks are started, but never more than three are inside the semaphore

output
10 done, peak 3

Common mistakes

Two mistakes account for most disappointing asyncio programs. The first silently throws away the concurrency you wanted. The second breaks the machinery that cancellations and timeouts depend on.

Awaiting inside a for loop

for u in urls: await fetch(u) is fully sequential, because each await finishes before the next iteration begins. Build the coroutines first, then pass them to gather or a TaskGroup.

python
import asyncio, time

async def fetch(u):
    await asyncio.sleep(0.2)
    return u.upper()

async def main():
    urls = ['a', 'b', 'c']

    start = time.perf_counter()
    for u in urls:
        await fetch(u)
    print(f'loop of awaits: {time.perf_counter() - start:.1f}s')

    start = time.perf_counter()
    coros = [fetch(u) for u in urls]
    await asyncio.gather(*coros)
    print(f'gather: {time.perf_counter() - start:.1f}s')

asyncio.run(main())
output
loop of awaits: 0.6s
gather: 0.2s
Swallowing CancelledError

Cancellation works by raising CancelledError at the task's current await. A bare except: catches it and breaks both task.cancel() and timeouts. except Exception is safe since 3.8, when CancelledError became a BaseException. If you catch it on purpose to clean up, always re-raise it.

python
import asyncio

async def worker():
    try:
        await asyncio.sleep(10)
    except asyncio.CancelledError:
        print('cleaning up')
        raise

async def main():
    t = asyncio.create_task(worker())
    await asyncio.sleep(0.1)
    t.cancel()
    try:
        await t
    except asyncio.CancelledError:
        print('cancelled', t.cancelled())

asyncio.run(main())

the re-raise is what lets the task finish as cancelled

output
cleaning up
cancelled True

Part 9 · Blocking Calls: to_thread and run_in_executor

One Blocking Call Stops Everything

The event loop runs on a single thread, and it can only switch between tasks when a task reaches an await that actually suspends. A blocking call never gives the thread back. time.sleep, requests.get, a synchronous database driver and a read of a very large file all hold the thread until they return. While one of them runs, no other task moves, no timer fires and no socket is serviced.

The example below makes this visible. A ticker task wants to print every 0.1 seconds. We let it start, then run a coroutine that calls time.sleep(0.35). The ticker is due again after 0.1 seconds, but the loop cannot run it until the sleep returns.

python
import asyncio
import time

async def ticker():
    for i in range(3):
        print('tick', i)
        await asyncio.sleep(0.1)

async def blocker():
    print('blocking start')
    time.sleep(0.35)  # holds the only thread
    print('blocking end')

async def main():
    t = asyncio.create_task(ticker())
    await asyncio.sleep(0)  # let the ticker start
    await blocker()
    await t

asyncio.run(main())

The ticker is frozen for the whole blocking call

output
tick 0
blocking start
blocking end
tick 1
tick 2

Notice that tick 1 only appears after blocking end. The ticker was ready to run long before that, but nothing could schedule it. In a real server the frozen ticker would be every other client, every heartbeat and every timeout.

The rule that follows is simple. Inside async code, either use a library that is built for asyncio, or move the blocking call off the loop thread so the loop stays free.

Blocking callAsync replacement
time.sleepasyncio.sleep
requestshttpx or aiohttp
psycopg2asyncpg
open() on big filesaiofiles
Which first?

Prefer a native async library when one exists. Reach for a thread only when there is no async version of the library you must use.

Moving the Call Off the Loop with to_thread

The simplest way to offload a blocking function is await asyncio.to_thread(fn, *args, **kwargs), available since Python 3.9. It runs fn in the loop's default thread pool and suspends your coroutine until the function finishes. While the worker thread is busy, the loop is free to run every other task. The await evaluates to the function's return value, or re-raises the exception it raised.

Here is the earlier example fixed. The blocking time.sleep now happens in a worker thread, and the ticker keeps its rhythm.

python
import asyncio
import time

async def ticker():
    for i in range(3):
        print('tick', i)
        await asyncio.sleep(0.1)

async def offloaded():
    print('offloaded start')
    await asyncio.to_thread(time.sleep, 0.35)
    print('offloaded end')

async def main():
    t = asyncio.create_task(ticker())
    await asyncio.sleep(0)
    await offloaded()
    await t

asyncio.run(main())

Same blocking sleep, but the loop stays responsive

output
tick 0
offloaded start
tick 1
tick 2
offloaded end

to_thread has two conveniences. Keyword arguments pass straight through to your function, so await asyncio.to_thread(requests.get, url, timeout=5) just works. It also copies the current contextvars into the worker thread, so things like a request id set before the call are still visible inside it.

The lower-level tool is the loop's own method. You get the running loop and call await loop.run_in_executor(None, fn, arg). Passing None as the first argument selects the loop's default ThreadPoolExecutor. This method accepts positional arguments only. If your function needs keywords, bind them first with functools.partial(fn, key=val). Passing keywords directly raises a TypeError.

The next example shows both calls on the same function. It also shows a difference that matters: to_thread carries the contextvar into the thread, while run_in_executor does not.

python
import asyncio
import contextvars
import functools

request_id = contextvars.ContextVar('request_id', default='none')

def read_config(path, encoding='utf-8'):
    return f'{path} ({encoding}) rid={request_id.get()}'

async def main():
    request_id.set('r-42')
    a = await asyncio.to_thread(read_config, 'app.cfg', encoding='latin-1')
    loop = asyncio.get_running_loop()
    job = functools.partial(read_config, 'app.cfg', encoding='latin-1')
    b = await loop.run_in_executor(None, job)
    print(a)
    print(b)

asyncio.run(main())

Keywords go through partial; contextvars are copied only by to_thread

output
app.cfg (latin-1) rid=r-42
app.cfg (latin-1) rid=none
Common mistake: keywords in run_in_executor

Writing loop.run_in_executor(None, fn, key=val) raises a TypeError, because the method takes positional arguments only. Wrap the call in functools.partial(fn, key=val) or use to_thread instead.

Custom Executors, Processes and Pool Limits

The first argument of run_in_executor is the reason to use it over to_thread. Instead of None, you can pass any executor you built. Passing a ProcessPoolExecutor runs the function in a separate Python process, so CPU-heavy work really does run in parallel on several cores. The price is that the function, its arguments and its result all travel between processes by pickling. That means the function must be defined at module top level, not as a lambda, and the entry point should sit behind if __name__ == '__main__':.

python
import asyncio
from concurrent.futures import ProcessPoolExecutor

def crunch(n):
    return sum(i * i for i in range(n))

async def main():
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor(max_workers=2) as pool:
        results = await asyncio.gather(
            loop.run_in_executor(pool, crunch, 1000),
            loop.run_in_executor(pool, crunch, 2000),
        )
    print(results)

if __name__ == '__main__':
    asyncio.run(main())

CPU work in two real processes; the loop only waits for the results

output
[332833500, 2664667000]

You may wonder how the loop can await something that runs in another thread or process. The executor's submit returns a concurrent.futures.Future, which is a thread-world object that the loop cannot await. run_in_executor wraps it with asyncio.wrap_future, which creates an asyncio Future tied to the loop. When the worker finishes, the result is handed back to the loop thread safely and your coroutine resumes.

What happens during await loop.run_in_executor(...)
  1. 1Coroutine calls run_in_executorfn and args are handed to the executor
  2. 2Executor returns a concurrent.futures.Futurea thread-world future
  3. 3asyncio.wrap_future wraps itnow the loop can await it
  4. 4Worker finishes fnloop is free the whole time
  5. 5Result delivered to the loopcoroutine resumes after the await

The default thread pool is not unlimited. Its size is min(32, os.cpu_count() + 4) workers. If you start more simultaneous blocking calls than that, the extra ones wait in the executor's queue until a worker is free. Their coroutines are simply suspended, so the loop stays healthy, but the work itself is delayed. For heavy fan-out, pass your own ThreadPoolExecutor(max_workers=...) to run_in_executor, or cap concurrency with a semaphore.

to_threadrun_in_executor
SyntaxConciseMore verbose
ExecutorDefault threads onlyThread or process, your choice
Keyword argsPassed directlyVia functools.partial
contextvarsCopied to the workerNot copied
How do I run this blocking work?

Cancelling a Thread Call Does Not Stop It

Cancelling a task normally stops its coroutine at the current await. A thread is different: Python offers no safe way to kill a running thread. If you cancel a task that is awaiting to_thread, only the waiting side is cancelled. The function keeps running in its worker thread until it returns on its own, and its result is thrown away.

python
import asyncio
import time

def slow():
    time.sleep(0.3)
    print('thread finished anyway')

async def main():
    task = asyncio.create_task(asyncio.to_thread(slow))
    await asyncio.sleep(0.05)
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        print('awaiting side cancelled')
    await asyncio.sleep(0.5)  # outlive the thread

asyncio.run(main())

The cancelled await returns at once, the thread does not

output
awaiting side cancelled
thread finished anyway
Common mistake: expecting cancel to stop the work

After task.cancel() or a timeout around to_thread, the blocking function still runs to completion and still has its side effects, such as writing a file or sending a request. Give long thread functions their own stop signal, for example a threading.Event they check, and do not start work in a thread that must not finish.

Common mistake: blocking inside async def

A plain time.sleep() or requests.get() inside async def freezes the entire loop, not just that coroutine. Swap in an async library, or wrap the call in await asyncio.to_thread(...).

Remember

Blocking call on the loop thread means everything stalls. Prefer async libraries; otherwise use to_thread for threads, or run_in_executor when you need a custom executor such as a process pool.

Part 10 · Why CPU-Bound Work Doesn't Speed Up

One thread, one runner

Everything in this chapter so far has made waiting cheap. A coroutine that waits on a socket is parked, and the loop runs something else. That only helps when there is something to wait for. asyncio runs every coroutine on one thread, so at any moment exactly one piece of Python code is executing. The loop can interleave tasks, but it can never run two of them at the same instant.

CPU-bound code (hashing, parsing, number crunching) never waits on anything. It has no await that actually suspends, so it never hands control back. Putting it inside async def changes nothing about how it runs. You only add the overhead of creating a coroutine and a task.

The next program makes four coroutines burn CPU and starts them together with gather. Each one prints a line when it starts and when it finishes. If they overlapped, you would see all four starts first. The order shows they do not.

python
import asyncio

def burn(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

async def crunch(name):
    print(name, 'start')
    burn(2_000_000)  # pure CPU, no await
    print(name, 'done')

async def main():
    await asyncio.gather(crunch('A'), crunch('B'), crunch('C'), crunch('D'))

asyncio.run(main())

Four async functions, all CPU work

output
A start
A done
B start
B done
C start
C done
D start
D done

Each coroutine ran from start to finish before the next one could begin, because nothing in burn ever gave the loop a chance to switch. Now scale the work so each call takes about 1 s of CPU. The gather of four takes about 4 s, not 1 s. For that whole time the loop is frozen: no timers fire, no other client is served, and no task can be cancelled.

TimeRunningLoop
0-1 scrunch Afrozen
1-2 scrunch Bfrozen
2-3 scrunch Cfrozen
3-4 scrunch Ddone at about 4 s
Common mistake

Wrapping a hashing or parsing loop in async def and expecting gather to run copies in parallel. It is still serial, and it freezes the loop while it runs.

Threads, the GIL and processes

The usual escape from a blocked loop is to_thread, which you met in the previous section. For blocking I/O it works well, because a thread waiting on the network lets go of the interpreter. For pure-Python CPU work it does not help. On standard CPython the GIL (global interpreter lock) lets only one thread execute Python bytecode at a time. Four threads doing arithmetic take turns, and the total time is about the same as running them one after another.

There are two exceptions. Some C extensions release the GIL while they work in native code: NumPy on large arrays, hashlib on large inputs, and zlib. Several to_thread calls into these can run on separate cores at once. The other exception is the free-threaded CPython build (3.13t and later), which has no GIL and allows real thread parallelism. It is opt-in and not yet the default, so you cannot assume it on a user's machine.

OptionCPU speedup?Why
async defNoOne thread
to_thread, pure PythonNoThe GIL
to_thread, C extensionYesThe extension releases the GIL
Free-threaded 3.13t+YesNo GIL, but opt-in
ProcessPoolExecutorYes, about x coresOne interpreter per core

The general fix is a ProcessPoolExecutor driven through loop.run_in_executor. Each worker process has its own interpreter and its own GIL, so the pool scales with the number of cores. The loop only awaits the futures, so it stays responsive while the workers crunch. The example below sends four jobs to four processes and prints a small checksum of each result.

python
import asyncio
from concurrent.futures import ProcessPoolExecutor

def burn(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

async def main():
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor(max_workers=4) as pool:
        futs = [loop.run_in_executor(pool, burn, 1_000_000 + i)
                for i in range(4)]
        results = await asyncio.gather(*futs)
    for r in results:
        print('checksum', r % 1000)

if __name__ == '__main__':
    asyncio.run(main())

Four CPU jobs in four processes

output
checksum 0
checksum 0
checksum 1
checksum 5

Two rules come with processes. The worker function must be a top-level def, not a lambda, so it can be pickled. And the entry point needs the if __name__ == '__main__': guard, because the child processes import your module.

Processes are not free. Each one costs startup time, and every argument and result is pickled and sent between processes. That overhead is fixed per task, so the pool only pays off when each task is chunky.

Task sizeProcess pool worth it?
About 1 msNo, pickling costs more than the work
About 10 msBorderline
100 ms or moreYes
Common mistake

Using to_thread for pure-Python math and expecting it to scale, or sending 1 ms tasks to a process pool. The first is stopped by the GIL, and in the second the pickling costs more than the work.

Chunking and choosing the right tool

Sometimes you cannot or do not want to use processes, but you still must not freeze the loop. The middle ground is to split a long CPU loop into slices and put await asyncio.sleep(0) between them. sleep(0) is a bare yield: it lets every other ready task run once and then resumes you. Heartbeats, timeouts and other clients get a turn between slices.

python
import asyncio

def burn(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

async def heartbeat():
    for i in range(3):
        print('heartbeat', i)
        await asyncio.sleep(0)

async def crunch_chunked(chunks):
    for i in range(chunks):
        burn(200_000)  # one CPU slice
        print('chunk', i)
        await asyncio.sleep(0)

async def main():
    await asyncio.gather(heartbeat(), crunch_chunked(3))

asyncio.run(main())

A heartbeat keeps running between CPU slices

output
heartbeat 0
chunk 0
heartbeat 1
chunk 1
heartbeat 2
chunk 2

The heartbeat now runs between the slices instead of waiting for all the crunching to end. The work is responsive, but not faster. The total CPU time is the same, or slightly more because of the extra switches. Chunking fixes the frozen loop, not the speed.

Common mistake

Expecting sleep(0) chunks to make the work finish sooner. They only let other tasks in. Also keep each slice short, because a single slice that runs for more than 100 ms still stalls the loop.

Choosing a tool comes down to what the work is actually doing. Ask what the code spends its time on, in this order.

Which tool?

Real programs often mix these. A server may accept thousands of connections, which is I/O, and also need to parse each payload, which is CPU. The answer is an asyncio front end with a process pool behind it. The loop handles the connections and hands each heavy job to the pool. Create the pool once and reuse it.

Summary

I/O-bound work goes to asyncio. A blocking library goes to to_thread. CPU-bound work goes to processes. Mixed work gets an asyncio front end with a process pool behind it.

Part 11 · asyncio Cheatsheet

Define, Run and Combine

This section is a quick reference for everything the chapter covered. Each entry has a short explanation and a snippet you can copy. We start with the basic moves: define a coroutine, run it, and decide whether things happen one after another or together.

You define work with async def. Calling that function does not run it. It only gives you a coroutine object. The bridge from ordinary code into the event loop is asyncio.run(main()). Call it once, at the top level of your program, and let everything else happen inside main().

Inside main() you choose how the work is arranged. Two await lines in a row run strictly one after the other, even when both functions are async. To overlap them, hand both coroutines to asyncio.gather. It starts them together and returns their results in the order you passed them, not the order they finished.

GoalUse
One after the otherawait a(); await b()
Run togetherawait asyncio.gather(a(), b())
Background jobasyncio.create_task(coro())
Structured group (3.11+)asyncio.TaskGroup()
python
import asyncio

async def a():
    await asyncio.sleep(0.2)
    print('a done')
    return 'A'

async def b():
    await asyncio.sleep(0.1)
    print('b done')
    return 'B'

async def main():
    first = [await a(), await b()]
    print('sequential:', first)
    second = await asyncio.gather(a(), b())
    print('together:', second)

asyncio.run(main())

Sequential finishes a then b. Gather lets the shorter b finish first, yet the results stay in argument order.

output
a done
b done
sequential: ['A', 'B']
b done
a done
together: ['A', 'B']
Common mistake: asyncio.run in the wrong place

Do not call asyncio.run once per request or inside a loop. Each call builds and tears down a whole new event loop. If a loop is already running, as in Jupyter or inside another coroutine, asyncio.run raises RuntimeError. Write await main() there instead. Use asyncio.Runner() (3.11+) when you really need one loop for several runs.

Background Tasks and TaskGroup

Sometimes you want work to start now and collect its result later. asyncio.create_task(coro()) wraps the coroutine in a Task and schedules it on the loop right away. You carry on with other awaits, and when you need the answer you await the task. The loop keeps only weak references to tasks, so keep your own reference to every task you create. A task you do not hold can be garbage-collected in the middle of its run.

On Python 3.11 and newer, asyncio.TaskGroup is the structured way to do this. Tasks created through the group are held for you. The async with block does not exit until every task has finished. If one task fails, its siblings are cancelled and the errors arrive together as an ExceptionGroup, which you catch with except*.

python
import asyncio

async def job(name, delay):
    await asyncio.sleep(delay)
    print(name, 'finished')
    return name.upper()

async def main():
    t = asyncio.create_task(job('report', 0.1))
    print('doing other work')
    result = await t
    print('got', result)

    async with asyncio.TaskGroup() as tg:
        t1 = tg.create_task(job('one', 0.2))
        t2 = tg.create_task(job('two', 0.1))
    print(t1.result(), t2.result())

asyncio.run(main())

The task starts at create_task, not at await. The group block waits for both tasks.

output
doing other work
report finished
got REPORT
two finished
one finished
ONE TWO
Call on a taskWhat it does
t.cancel()Raises CancelledError at the task's current await
t.done()True once it finished or was cancelled
t.result()Returns the value, or re-raises the task's error
t.exception()Returns the error, or None
Common mistake: the unreferenced task

asyncio.create_task(job()) with the result thrown away can vanish mid-run. Store tasks in a set and remove them when done with t.add_done_callback(bg.discard), or use a TaskGroup. Also await or collect every task you start. Otherwise an error inside it is only logged later as 'Task exception was never retrieved'.

Timeouts and Yielding

Network calls can hang, so give them a deadline. async with asyncio.timeout(s): limits a whole block of awaits. await asyncio.wait_for(coro, s) limits a single awaitable. Both cancel the inner work when time runs out and then raise TimeoutError. Since 3.11, asyncio.TimeoutError is simply the built-in TimeoutError.

timeout()wait_for()
WrapsA block of awaitsOne awaitable
On expiryTimeoutErrorTimeoutError
Inner workCancelledCancelled
python
import asyncio

async def slow():
    await asyncio.sleep(1)
    return 'slow result'

async def main():
    try:
        async with asyncio.timeout(0.1):
            await slow()
    except TimeoutError:
        print('block timed out')
    try:
        await asyncio.wait_for(slow(), 0.1)
    except TimeoutError:
        print('wait_for timed out')
    await asyncio.sleep(0)
    print('still running')

asyncio.run(main())

Catch TimeoutError outside the timeout block. Code inside the block only ever sees CancelledError.

output
block timed out
wait_for timed out
still running

The last line of that example used await asyncio.sleep(0). This is the way to yield to the loop on purpose. It suspends the current task for one turn so other ready tasks can run, then resumes it. Use it every so often inside a long loop that has no other await. The opposite of this is time.sleep(). It blocks the only thread and freezes every task, so it never belongs in async code. Use await asyncio.sleep(s) instead.

CallEffect on the loop
await asyncio.sleep(0)✓ yields now, others get a turn
await asyncio.sleep(1)✓ others run while this waits
time.sleep(1)✗ loop frozen
CPU loop with no await✗ starves the loop
Timeouts need a suspension point

A timeout can only fire when the code awaits something that actually suspends. A busy CPU loop inside timeout() runs to the end before the timeout can act.

Offloading, Loop Access and Primitives

When you must call blocking synchronous code, move it off the loop thread. await asyncio.to_thread(fn, *args) runs fn in the default thread pool, passes keyword arguments straight through, and gives you the return value or re-raises the error. For CPU-heavy work, threads do not help in standard CPython, so use a process pool: await loop.run_in_executor(ProcessPoolExecutor(), fn, x). The function and its arguments must be picklable, so use a top-level def, not a lambda. run_in_executor takes positional arguments only, so wrap keywords with functools.partial(fn, k=v).

Which tool?
python
import asyncio
import time

def blocking(n):
    time.sleep(0.1)
    return n * 2

async def ticker():
    for _ in range(3):
        print('tick')
        await asyncio.sleep(0.03)

async def main():
    result, _ = await asyncio.gather(asyncio.to_thread(blocking, 21), ticker())
    print(result)

asyncio.run(main())

The blocking call runs in a worker thread, so the ticker keeps going.

output
tick
tick
tick
42

Inside a coroutine, get the running loop with asyncio.get_running_loop(). The loop is not thread-safe, so a plain worker thread must not touch it directly. From another thread, hand a coroutine over with asyncio.run_coroutine_threadsafe(coro, loop). It returns a concurrent future, and calling .result(timeout) on it blocks that thread until the loop has produced the answer.

python
import asyncio

async def hello():
    return 'hello from the loop'

async def main():
    loop = asyncio.get_running_loop()

    def worker():
        fut = asyncio.run_coroutine_threadsafe(hello(), loop)
        print(fut.result(timeout=1))

    await asyncio.to_thread(worker)

asyncio.run(main())
output
hello from the loop

Three small primitives handle most coordination. A Semaphore caps how many tasks do something at once. A Lock protects state that is read, then awaited on, then written, because another task can slip in at the await. A Queue passes items from producers to consumers.

PrimitiveUse for
Semaphore(n)Limit concurrency to n at a time
Lock()State shared across awaits
Queue()Producer / consumer
python
import asyncio

sem = asyncio.Semaphore(2)
lock = asyncio.Lock()
active = 0
peak = 0
balance = 100

async def fetch(i):
    global active, peak
    async with sem:
        active += 1
        peak = max(peak, active)
        await asyncio.sleep(0.05)
        active -= 1

async def withdraw(n):
    global balance
    async with lock:
        current = balance
        await asyncio.sleep(0.01)
        balance = current - n

async def main():
    await asyncio.gather(*(fetch(i) for i in range(6)))
    print('peak', peak)
    await asyncio.gather(*(withdraw(10) for _ in range(3)))
    print('balance', balance)

asyncio.run(main())

Six fetches run, never more than two at once. Without the lock the balance would end at 90.

output
peak 2
balance 70
python
import asyncio

async def producer(q):
    for i in range(3):
        await q.put(i)
    await q.put(None)

async def consumer(q):
    while (item := await q.get()) is not None:
        print('consumed', item)

async def main():
    q = asyncio.Queue()
    await asyncio.gather(producer(q), consumer(q))

asyncio.run(main())

None is used here as a stop signal.

output
consumed 0
consumed 1
consumed 2
Create primitives inside the loop

Semaphores, locks and queues are bound to one loop. Create them inside the running loop, not once per process, and never share them between two asyncio.run calls.

Red Flags

Most asyncio bugs fall into a few familiar patterns. Learn to spot these five on sight. You can also run asyncio.run(main(), debug=True). Debug mode logs coroutines that were never awaited and any callback that runs longer than 100 ms.

SymptomLikely causeFix
'coroutine was never awaited'Called an async function without awaitAdd await, or wrap it in create_task
Everything stalls togetherBlocking call inside async defUse an async library or to_thread
Timeouts and cancels do nothingCancelledError swallowedClean up, then re-raise it
Task vanishes or its error is never seenNo reference keptStore it, or use a TaskGroup
RuntimeError from asyncio.runLoop already runningawait main() instead
Never awaited

The warning 'coroutine was never awaited' means you called an async function and dropped the coroutine object. The body never ran. Put await in front of the call, or turn it into a task.

Blocking call in async def

requests.get, time.sleep and synchronous database drivers hold the only thread, so every task freezes. Swap in an async library, or push the call off the loop with asyncio.to_thread.

Swallowed CancelledError

A bare except: or an except CancelledError without a re-raise breaks cancellation and timeouts. Catch Exception instead, since CancelledError is a BaseException. If you must handle cancellation, do your cleanup and then raise.

Unreferenced tasks

A create_task() whose result you throw away can be garbage-collected before it finishes, and its errors are only logged late. Keep a reference, or let a TaskGroup own the tasks.

asyncio.run inside a running loop

Calling asyncio.run from code that is already running on a loop raises RuntimeError. Just await the coroutine instead.

One-line rule

I/O-bound work goes to asyncio. Blocking sync calls go to to_thread. CPU-bound work goes to processes.

Part 12 · Check yourself

Quiz

Work out each answer on paper first, then open the answer. These questions test what the code does, not what the chapter said.

This program is meant to print a heartbeat every 0.1 s while a slow step runs. What does it print, and what is wrong with it?
  • It prints tick, then crunched, then tick, then tick. The heartbeat is meant to tick during the slow step, but the other two ticks come only after crunched.
  • heartbeat() prints the first tick and suspends at await asyncio.sleep(0.1). Then crunch() runs time.sleep(0.35), which blocks the only thread, so the loop cannot wake the heartbeat until it returns.
  • The fix is to move the blocking call off the loop: replace time.sleep(0.35) with await asyncio.to_thread(time.sleep, 0.35). The output then becomes tick, tick, tick, crunched.
import asyncio, time

async def heartbeat():
    for _ in range(3):
        print('tick')
        await asyncio.sleep(0.1)

async def crunch():
    time.sleep(0.35)
    print('crunched')

async def main():
    await asyncio.gather(heartbeat(), crunch())

asyncio.run(main())
What happens when this program runs, and why?
  • load() returns a coroutine object because nothing awaited it, so value is a coroutine, not 42.
  • value + 1 raises TypeError (unsupported operand types: 'coroutine' and 'int'). You also get a RuntimeWarning: coroutine 'load' was never awaited.
  • The fix is value = await load().
import asyncio

async def load():
    return 42

async def main():
    value = load()
    print(value + 1)

asyncio.run(main())
Roughly how long does main take in total, and what does it print?
  • It prints [1, 1] after about 3 seconds in total.
  • The two awaited calls run one after the other, so they cost 1 s + 1 s = 2 s. Both being async does not make them overlap.
  • gather runs its two coroutines together, so that line costs about max(1, 1) = 1 s. The results come back in argument order.
import asyncio

async def job(n):
    await asyncio.sleep(n)
    return n

async def main():
    await job(1)
    await job(1)
    print(await asyncio.gather(job(1), job(1)))

asyncio.run(main())
In which order do the two lines print?
  • It prints main first, then task.
  • create_task only schedules say on the loop. It does not run it. The task gets its first turn when main suspends at await t.
  • If you want the task to run before print('main'), main would have to suspend first, for example with await asyncio.sleep(0).
import asyncio

async def say(text):
    print(text)

async def main():
    t = asyncio.create_task(say('task'))
    print('main')
    await t

asyncio.run(main())

Summary

  • asyncio gives you concurrency on one thread, never parallelism, and switches tasks only at an await that actually suspends.
  • Calling an async def function returns a coroutine object. It runs only when awaited or wrapped in a Task.
  • asyncio.run(main()) creates the loop, runs main, cleans up and closes. Call it once at the top level, never inside a running loop.
  • A coroutine is lazy and sequential, a Task is scheduled at once and runs concurrently, and a Future is a raw result slot. Keep a reference to every Task.
  • gather or a TaskGroup runs awaitables together. Awaiting them one by one adds their times.
  • A blocking call or a CPU loop freezes every task, so use to_thread for blocking libraries and a process pool for CPU-bound work.