Asyncio in Python
What is asyncio?
Section titled “What is asyncio?”asyncio is Python’s built-in library for asynchronous I/O.
It’s designed for workloads that spend lots of time waiting:
- network requests
- database calls
- file I/O (through async libraries)
Async vs threading vs multiprocessing
Section titled “Async vs threading vs multiprocessing”- asyncio: single-threaded concurrency using an event loop (great for many I/O tasks)
- threading: multiple threads (good for I/O, simpler when using blocking libs)
- multiprocessing: multiple processes (good for CPU-bound)
Mental model
Section titled “Mental model”async defdefines a coroutine.awaitpauses the coroutine so other tasks can run.- The event loop schedules coroutines and resumes them when they’re ready.
Your first coroutine
Section titled “Your first coroutine”import asyncio
async def main():
print("Hello")
await asyncio.sleep(1)
print("World")
asyncio.run(main())When asyncio helps
Section titled “When asyncio helps”If you do 100 HTTP requests sequentially, you wait 100 times.
With asyncio you can:
- start many requests
- efficiently wait for them concurrently
When asyncio won’t help
Section titled “When asyncio won’t help”For CPU-heavy loops, asyncio won’t speed things up.
Use:
- multiprocessing
- numpy/vectorization
- compiled extensions
await is not where concurrency comes from
Section titled “await is not where concurrency comes from”This is the single most common misreading of asyncio. await means wait here until
this finishes. Written in a loop, it is exactly as sequential as ordinary code:
flowchart TD A["await one after another"] --> A1["task 1 ... 0.2s"] A1 --> A2["task 2 ... 0.2s"] A2 --> A3["task 3 ... 0.2s"] A3 --> AR["total 1.005s"] B["asyncio.gather"] --> B1["task 1, 2, 3 all handed
to the loop together"] B1 --> B2["loop switches whenever
one of them waits"] B2 --> BR["total 0.215s"]
Measured, five coroutines each sleeping 0.2 s:
| time | speedup | |
|---|---|---|
for i in range(5): await work(i) | 1.005 s | 1.0× |
await asyncio.gather(*[work(i) for i in range(5)]) | 0.215 s | 4.7× |
Concurrency comes from handing the loop several things at once — gather,
create_task, or a TaskGroup. An await on its own just yields control until that
one operation is done.
Calling a coroutine function does nothing
Section titled “Calling a coroutine function does nothing”import asyncio, inspect
async def work(name):
await asyncio.sleep(0.1)
return name
c = work("a") # no code has run yet
print(type(c).__name__) # coroutine
print(inspect.iscoroutine(c)) # True
print(asyncio.run(c)) # 'a' <- now it runsA coroutine object is a description of work. Forgetting to await one is why
RuntimeWarning: coroutine ... was never awaited exists — the call looked like it did
something and did not.
create_task schedules, a bare coroutine waits to be asked
Section titled “create_task schedules, a bare coroutine waits to be asked”log = []
async def note(n):
log.append(f"{n} started")
await asyncio.sleep(0.05)
t1 = asyncio.create_task(note("task")) # handed to the loop right away
co = note("coro") # just an object; nothing scheduled
await asyncio.sleep(0.01)
print(log) # ['task started'] <- only one of them beganSee it move
Section titled “See it move”One thread, one loop. A coroutine runs until it hits an await, hands control back,
and the loop picks whoever is ready. Nothing here is parallel — it is interleaved,
and that is enough when the work is waiting.
One blocking call freezes everything
Section titled “One blocking call freezes everything”Because there is only one thread, any function that blocks without awaiting stops the whole loop — every other coroutine included:
async def good():
await asyncio.sleep(0.3) # yields to the loop
async def blocker():
time.sleep(0.3) # WRONG inside async: nothing yields| two coroutines running together | measured |
|---|---|
both await asyncio.sleep(0.3) | 0.310 s — overlapped |
both time.sleep(0.3) | 0.602 s — serialized |
gather returns in argument order
Section titled “gather returns in argument order”res = await asyncio.gather(timed("slow", 0.3), timed("fast", 0.05), timed("mid", 0.15))
print(res) # ['slow', 'fast', 'mid'] <- the order you passed them
# actual completion order was ['fast', 'mid', 'slow']Results line up with your arguments, which makes zip(urls, results) safe. If you want
results as they arrive, use asyncio.as_completed.
When one of them fails
Section titled “When one of them fails”await asyncio.gather(work("ok"), bad())
# ValueError: boom <- the first exception propagates immediately
await asyncio.gather(work("ok"), bad(), return_exceptions=True)
# ['ok', ValueError('boom')] <- collected instead of raisedWith return_exceptions=True you always get one entry per input, and it is your job to
check which entries are exceptions.
Check yourself
Section titled “Check yourself”-
Five coroutines each sleep 0.2 s. Awaiting them one per loop iteration took 1.005 s; gather took 0.215 s. What does that show?
A loop of awaits is sequential by definition. gather submits all five, so their waiting overlaps. Everything still runs on one thread.
pch.quizShowAnswer
B — await means 'wait here', so concurrency comes from handing several coroutines to the loop at once — A loop of awaits is sequential by definition. gather submits all five, so their waiting overlaps. Everything still runs on one thread.
-
You write c = work('a') where work is an async def, and never await c. What has happened?
Calling an async function builds a coroutine object describing the work. inspect.iscoroutine(c) is True and no body has executed until it is awaited or scheduled.
pch.quizShowAnswer
C — nothing ran; c is a coroutine object and Python warns it was never awaited — Calling an async function builds a coroutine object describing the work. inspect.iscoroutine(c) is True and no body has executed until it is awaited or scheduled.
-
Two coroutines that both call time.sleep(0.3) took 0.602 s, while two that await asyncio.sleep(0.3) took 0.310 s. Why?
asyncio is single-threaded. A blocking call never yields control, so the loop cannot switch to the other coroutine. Use asyncio.to_thread for work you cannot make awaitable.
pch.quizShowAnswer
B — time.sleep blocks the single thread the loop runs on, so nothing else can proceed — asyncio is single-threaded. A blocking call never yields control, so the loop cannot switch to the other coroutine. Use asyncio.to_thread for work you cannot make awaitable.
-
gather is given slow (0.3 s), fast (0.05 s) and mid (0.15 s), in that order. What does it return?
gather preserves argument order regardless of when each finished, which is what makes zip(inputs, results) correct. Use asyncio.as_completed if you want them as they arrive.
pch.quizShowAnswer
B — results in argument order: slow, fast, mid — gather preserves argument order regardless of when each finished, which is what makes zip(inputs, results) correct. Use asyncio.as_completed if you want them as they arrive.
🧪 Try It Yourself
Section titled “🧪 Try It Yourself”Exercise 1 – Your First Coroutine
Section titled “Exercise 1 – Your First Coroutine”Exercise 2 – await asyncio.sleep
Section titled “Exercise 2 – await asyncio.sleep”Exercise 3 – Gather Two Coroutines
Section titled “Exercise 3 – Gather Two Coroutines”pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading