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.
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.
| Concurrency | Parallelism | |
|---|---|---|
| Meaning | Progress overlaps in time | Work happens at the same instant |
| Hardware needed | One core is enough | Several cores |
| Does asyncio give it? | Yes | No |
| Wins for | Work that spends its time waiting on I/O | Work 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.
- 1Task runsit owns the only thread
- 2Reaches an awaitthe result is not ready yet
- 3Task steps asideit is parked until its result arrives
- 4Another task runsany task that is ready
- 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.
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.
| Threads | asyncio | |
|---|---|---|
| Who switches | The OS scheduler | The task itself |
| When | Any bytecode | Only at an await |
| Visible in the code? | No | Yes |
| Style | Preemptive | Cooperative |
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.
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())
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.
| Unit | Memory each | 10,000 at once |
|---|---|---|
| OS thread | About 1-8 MB of stack | About 10-80 GB reserved |
| Coroutine | About 1-2 KB | About 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.
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())
10000 connections finishedRaces, 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.
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())
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.
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.
| Model | Switching | Strength | Catch |
|---|---|---|---|
| Threads | Preemptive | Cheap to write in a blocking style | GIL-bound for Python code, and you need locks everywhere |
| Processes | True parallelism | Real speedup for CPU work | Heavy to start, and every argument must be pickled across IPC |
| asyncio | Explicit, at each await | Lightest cost per task | The 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.
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.
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
<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.
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 is | Outcome |
|---|---|
Inside an async def | Works |
Top level of the python -m asyncio REPL | Works |
Inside a plain def | SyntaxError |
| Top level of a normal script or module | SyntaxError |
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.
import asyncio async def fetch(): await asyncio.sleep(1) return 'data' async def main(): result = await fetch() print(result) asyncio.run(main())
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.
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
3This 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.
| Statement | Methods it calls | Typical 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 generator | async def containing yield | Produce 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.
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())
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
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.
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.
import asyncio def load(): return 5 async def main(): try: await load() except TypeError as e: print(e) asyncio.run(main())
object int can't be used in 'await' expression
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.
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
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.
| Check | Called on fetch (the function) | Called on fetch() (the coroutine object) |
|---|---|---|
inspect.iscoroutinefunction | True | False |
inspect.iscoroutine | False | True |
asyncio.iscoroutine | False | True |
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.
| Collection | What it holds | Example entry |
|---|---|---|
| Ready queue | Callbacks that can run right now, in first-in first-out order | A task's next step after its await finished |
| Timer heap | Callbacks due at a future time, ordered so the nearest one is on top | A wake-up for asyncio.sleep(2) |
| Watched file descriptors | Sockets and pipes the loop is waiting on for I/O | A 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.
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
outside: no running event loop
inside: TrueThe 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.
- 1Run ready callbacksevery callback in the ready queue, one after another
- 2Compute a timeouttime until the nearest timer on the heap
- 3Wait in the selectorblock until I/O arrives or the timeout expires
- 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.
| Platform | OS multiplexer | Loop class |
|---|---|---|
| Linux | epoll | Selector-based loop |
| macOS and BSD | kqueue | Selector-based loop |
| Windows | IOCP (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.
| Method | When the callback runs | Called from |
|---|---|---|
loop.call_soon(cb) | On the next iteration, after callbacks already queued | The loop thread |
loop.call_later(delay, cb) | After delay seconds | The loop thread |
loop.call_at(when, cb) | When the loop's clock reaches when | The loop thread |
loop.call_soon_threadsafe(cb) | On the next iteration | Any 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.
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
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.
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())
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.
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())
from the worker threadWhen 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.
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())
[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.
PYTHONASYNCIODEBUG=1 python app.pySame effect as passing debug=True to asyncio.run
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.
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.
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?
mainawaitsreport(), and awaiting a coroutine just runs it inside the caller. The loop gets no turn untilreport()suspends.report()contains only CPU work and never reaches an await that suspends, so theheartbeattask sits in the ready queue and cannot run.- Fix: put
await asyncio.sleep(0)inside the for loop ofreport(). Each pass then hands control back to the loop, soheartbeatgets its turn between slices. - Running with
debug=Truewould log the long step ofreport()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())
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.
- 11. Createnew event loop, set as current for this thread
- 22. Rundrive main() to completion, return its result or raise its exception
- 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.
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
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 step | Purpose |
|---|---|
| Cancel leftover tasks | Stop 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.
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
running: True
caught boomThe 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.
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'
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.
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
main waiting finished main returns
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.
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.
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
setup main runner same loop: True run twice same loop: False
| Situation | Use |
|---|---|
| Ordinary script or program entry | asyncio.run(main()), once |
| Jupyter, or inside a coroutine | await 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.
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.
| Wrong | Right |
|---|---|
run() per request | One run() and a long-lived loop |
run() in Jupyter or inside a coroutine | await main() |
Many run() calls that share state | asyncio.Runner() (3.11+) |
| Un-awaited background task | Await 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.
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
first run ok
second run failed: TrueA 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.
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.
What calling an async def returns
Runs only when awaited
Wraps a coroutine
Scheduled on the loop right away
A result slot filled later
Usually made by library code
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.
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())
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.
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())
42An 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.
| State | done() | result() |
|---|---|---|
| pending | False | raises InvalidStateError |
| done with a value | True | returns the value |
| done with an exception | True | re-raises the exception |
| cancelled | True | raises 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.
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())
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.
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())
a got go b got go c got go first await: 7 second await -> RuntimeError
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.
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())
created: users-fetch False meanwhile: orders data task done yet? False result: users data True None
- 1create_task(coro)Task is scheduled
- 2Runs at the next yieldalongside other tasks
- 3Suspends at each awaitloop runs others
- 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.
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())
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.
| Coroutine | Task | Future | |
|---|---|---|---|
| Starts | Lazy: only when awaited | Eager: scheduled at create_task | Set by whoever calls set_result |
| Runs | Sequentially in its caller | Concurrently with other tasks | Holds no code of its own |
| Cancel | No cancel method | task.cancel() | future.cancel() |
| Made by | Calling an async def | asyncio.create_task | Library code via loop.create_future |
| Await twice | RuntimeError | Allowed | Allowed |
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.
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.
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())
job 0 finished job 1 finished left: 0
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.
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())
task boom failed: ValueError('bad input')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.
- 1Task drives the chaincalls coro.send(None)
- 2main() awaits fetch()running down, no pause yet
- 3fetch() awaits read()still running down
- 4read() awaits a pending Futurenowhere left to go
- 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.
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
True False 42
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.
- 1Task registers the callbackfut.add_done_callback(self.__wakeup)
- 2Task returns to the loopthe whole coroutine stack is suspended
- 3Other tasks runthe loop keeps working
- 4I/O or a timer calls fut.set_result(x)the Future is now done
- 5Callback schedules Task.__stepit goes onto the ready queue
- 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).
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())
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.
| You await | Suspends? | Why |
|---|---|---|
| A pending Future | Yes | It yields itself up to the Task |
asyncio.sleep(0) | Yes | It does a bare yield with no Future |
| A coroutine that reaches no pending Future | No | It runs like a normal function call |
| An already-done Future | No | It 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.
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())
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.
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())
A 0 A 1 A 2 B 0 B 1 B 2
| Version | Output order | Where tasks switch |
|---|---|---|
With await asyncio.sleep(0) | A0 B0 A1 B1 A2 B2 | At every await |
| No await in the loop | A0 A1 A2 B0 B1 B2 | Only when a task ends |
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.
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.
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())
cancelled at its awaitNow 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.
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.
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
sequential ['A', 'B'] 0.6s gather ['B', 'A'] 0.4s
| Sequential awaits | gather | |
|---|---|---|
| Total time | time(a) + time(b) | about max(time(a), time(b)) |
| Result order | the order you awaited | the order of the arguments |
| Overlap | none | waits 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.
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())
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.
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())
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.
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
slow cancelled
caught ['boom']- 1A task raisesinside the async with block
- 2Siblings are cancelledeach gets CancelledError at its await
- 3Group waits for all taskscleanup runs to the end
- 4ExceptionGroup is raisedcatch it with except*
gather or TaskGroup?
| gather | TaskGroup | |
|---|---|---|
| Style | simple, one call | structured, scoped to a block |
| Result order | list in argument order | read each task with t.result() |
| On failure | other tasks keep running by default | siblings are cancelled and awaited |
| Cleanup | you handle it | safe and automatic |
| Python version | any asyncio | 3.11+ |
| Best for | small lists where order matters | new code, preferred |
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.
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())
done: ['fast'] pending: 1 finished fast finished mid finished slow
| Tool | Gives you | Order |
|---|---|---|
| gather | a list of results | argument order |
| TaskGroup | tasks, read with t.result() | you choose |
| wait | (done, pending) sets | sets, no order |
| as_completed | an iterator of awaitables | finish 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.
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())
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.
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
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.
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.
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())
loop of awaits: 0.6s gather: 0.2s
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.
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
cleaning up
cancelled TruePart 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.
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
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 call | Async replacement |
|---|---|
time.sleep | asyncio.sleep |
requests | httpx or aiohttp |
psycopg2 | asyncpg |
open() on big files | aiofiles |
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.
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
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.
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
app.cfg (latin-1) rid=r-42 app.cfg (latin-1) rid=none
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__':.
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
[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.
- 1Coroutine calls run_in_executorfn and args are handed to the executor
- 2Executor returns a concurrent.futures.Futurea thread-world future
- 3asyncio.wrap_future wraps itnow the loop can await it
- 4Worker finishes fnloop is free the whole time
- 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_thread | run_in_executor | |
|---|---|---|
| Syntax | Concise | More verbose |
| Executor | Default threads only | Thread or process, your choice |
| Keyword args | Passed directly | Via functools.partial |
| contextvars | Copied to the worker | Not copied |
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.
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
awaiting side cancelled thread finished anyway
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.
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(...).
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.
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
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.
| Time | Running | Loop |
|---|---|---|
| 0-1 s | crunch A | frozen |
| 1-2 s | crunch B | frozen |
| 2-3 s | crunch C | frozen |
| 3-4 s | crunch D | done at about 4 s |
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.
| Option | CPU speedup? | Why |
|---|---|---|
| async def | No | One thread |
| to_thread, pure Python | No | The GIL |
| to_thread, C extension | Yes | The extension releases the GIL |
| Free-threaded 3.13t+ | Yes | No GIL, but opt-in |
| ProcessPoolExecutor | Yes, about x cores | One 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.
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
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 size | Process pool worth it? |
|---|---|
| About 1 ms | No, pickling costs more than the work |
| About 10 ms | Borderline |
| 100 ms or more | Yes |
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.
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
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.
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.
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.
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.
| Goal | Use |
|---|---|
| One after the other | await a(); await b() |
| Run together | await asyncio.gather(a(), b()) |
| Background job | asyncio.create_task(coro()) |
| Structured group (3.11+) | asyncio.TaskGroup() |
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.
a done b done sequential: ['A', 'B'] b done a done together: ['A', 'B']
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*.
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.
doing other work report finished got REPORT two finished one finished ONE TWO
| Call on a task | What 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 |
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() | |
|---|---|---|
| Wraps | A block of awaits | One awaitable |
| On expiry | TimeoutError | TimeoutError |
| Inner work | Cancelled | Cancelled |
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.
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.
| Call | Effect 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 |
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).
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.
tick
tick
tick
42Inside 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.
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())
hello from the loopThree 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.
| Primitive | Use for |
|---|---|
Semaphore(n) | Limit concurrency to n at a time |
Lock() | State shared across awaits |
Queue() | Producer / consumer |
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.
peak 2 balance 70
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.
consumed 0 consumed 1 consumed 2
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.
| Symptom | Likely cause | Fix |
|---|---|---|
| 'coroutine was never awaited' | Called an async function without await | Add await, or wrap it in create_task |
| Everything stalls together | Blocking call inside async def | Use an async library or to_thread |
| Timeouts and cancels do nothing | CancelledError swallowed | Clean up, then re-raise it |
| Task vanishes or its error is never seen | No reference kept | Store it, or use a TaskGroup |
RuntimeError from asyncio.run | Loop already running | await main() instead |
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.
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.
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.
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.
Calling asyncio.run from code that is already running on a loop raises RuntimeError. Just await the coroutine instead.
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, thencrunched, thentick, thentick. The heartbeat is meant to tick during the slow step, but the other two ticks come only aftercrunched. heartbeat()prints the first tick and suspends atawait asyncio.sleep(0.1). Thencrunch()runstime.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)withawait asyncio.to_thread(time.sleep, 0.35). The output then becomestick,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, sovalueis a coroutine, not 42.value + 1raisesTypeError(unsupported operand types: 'coroutine' and 'int'). You also get aRuntimeWarning: 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.
gatherruns 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
mainfirst, thentask. create_taskonly schedulessayon the loop. It does not run it. The task gets its first turn whenmainsuspends atawait t.- If you want the task to run before
print('main'),mainwould have to suspend first, for example withawait 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 deffunction returns a coroutine object. It runs only when awaited or wrapped in a Task. asyncio.run(main())creates the loop, runsmain, 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.
gatheror aTaskGroupruns awaitables together. Awaiting them one by one adds their times.- A blocking call or a CPU loop freezes every task, so use
to_threadfor blocking libraries and a process pool for CPU-bound work.