- Explain coroutines,
awaitand the event loop - Run coroutines concurrently with
gather,create_taskandTaskGroup - Set timeouts with
wait_forand useasync for - Know when asyncio helps and what blocks the event loop
A program has to download 100 pages from the internet. Each request spends about 0.3 s just waiting for the network — the processor does nothing during that time. One after another, that is 30 seconds of mostly idle waiting. Think of a waiter in a teahouse: he does not stand at the kitchen door until one order is ready — he takes the next table's order in the meantime. Asynchronous programming lets a single thread work like that waiter: while one operation waits, the others make progress.
Coroutines, await and the event loop
A function defined with async def is a coroutine function. Calling it does not run the body — just like a generator function, it returns a coroutine object, which has to be awaited.
await runs a coroutine and waits for its result. At every await on an operation that has not finished yet, the coroutine pauses and hands control back to the event loop — the scheduler that decides which paused coroutine continues next. Nothing runs in parallel: there is one thread, and the coroutines take turns voluntarily. This is called cooperative multitasking.
import asyncio
async def greet(name, delay):
await asyncio.sleep(delay)
print(f'Hello, {name}!')
return name.upper()
coro = greet('Aysel', 0.1)
print(type(coro).__name__)
result = await coro
print(result)▸ Expected output
coroutine Hello, Aysel! AYSEL
await may be written directly at the top level.In an ordinary .py file, await outside a function is a syntax error. There the program has one entry coroutine, usually called main(), and the event loop is started once, at the very end of the script, with **asyncio.run(main())**:
import asyncio
async def fetch(name, seconds):
await asyncio.sleep(seconds)
return f'{name} ({seconds} s)'
async def main():
results = await asyncio.gather(fetch('rates', 0.3), fetch('news', 0.1))
print(results)
if __name__ == '__main__':
asyncio.run(main())['rates (0.3 s)', 'news (0.1 s)']
python app.py.Running concurrently with gather
asyncio.gather(*coroutines) starts several coroutines at once and waits until all of them have finished. Look at the order of the lines: all three start immediately and finish in the order of their waiting times, not in the order they were written. The results, however, come back in the order of the arguments:
import asyncio
async def fetch(name, seconds):
print(f'start {name}')
await asyncio.sleep(seconds)
print(f'done {name}')
return f'{name} ({seconds} s)'
results = await asyncio.gather(
fetch('rates', 0.3),
fetch('weather', 0.2),
fetch('news', 0.1),
)
print(results)▸ Expected output
start rates start weather start news done news done weather done rates ['rates (0.3 s)', 'weather (0.2 s)', 'news (0.1 s)']
Now let's measure. When the coroutines are awaited one after another, the waits add up; with gather they overlap, so the total time equals the longest wait:
import asyncio
import time
async def fetch(seconds):
await asyncio.sleep(seconds)
start = time.perf_counter()
for s in (0.3, 0.2, 0.1):
await fetch(s)
print(f'one after another: {time.perf_counter() - start:.2f} s')
start = time.perf_counter()
await asyncio.gather(fetch(0.3), fetch(0.2), fetch(0.1))
print(f'with gather: {time.perf_counter() - start:.2f} s')Tasks, TaskGroup and timeouts
asyncio.create_task(coro) schedules a coroutine to run in the background right away and returns a task. Meanwhile you can do other work and later await the task to get its result. Since Python 3.11, **asyncio.TaskGroup** offers structured concurrency: the async with block does not end until all its tasks have finished, and if one of them fails, the others are cancelled automatically:
import asyncio
async def worker(name, seconds):
await asyncio.sleep(seconds)
print(f'{name} finished')
return seconds
async def main():
task = asyncio.create_task(worker('backup', 0.2))
print('task created, doing other work...')
await asyncio.sleep(0.1)
print('other work done, task done?', task.done())
print('result:', await task)
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(worker('A', 0.2))
t2 = tg.create_task(worker('B', 0.1))
print('group results:', t1.result(), t2.result())
await main()▸ Expected output
task created, doing other work... other work done, task done? False backup finished result: 0.2 B finished A finished group results: 0.2 0.1
Network services sometimes never answer. asyncio.wait_for(coro, timeout) cancels the operation after the given number of seconds and raises TimeoutError (since Python 3.11, asyncio.TimeoutError is the same class):
import asyncio
async def slow_api():
await asyncio.sleep(1)
return 'data'
try:
result = await asyncio.wait_for(slow_api(), timeout=0.2)
except TimeoutError:
print('the API did not answer in 0.2 s')▸ Expected output
the API did not answer in 0.2 s
Other constructs of the language have asynchronous versions too: async with for context managers whose setup or cleanup has to wait (a network connection, for example), and async for for asynchronous iterators. An asynchronous generator is an async def function that contains yield:
import asyncio
async def ticker(n, delay):
for i in range(1, n + 1):
await asyncio.sleep(delay)
yield i
async for value in ticker(3, 0.05):
print('tick', value)
squares = [v * v async for v in ticker(4, 0.01)]
print(squares)▸ Expected output
tick 1 tick 2 tick 3 [1, 4, 9, 16]
Pitfalls and when to choose asyncio
| Approach | Good for | Example |
|---|---|---|
asyncio | many simultaneous waits in one thread (network, sockets) | a chat server, a web crawler, FastAPI |
threads (threading) | blocking libraries that cannot be awaited | several downloads with requests |
processes (multiprocessing) | heavy calculations on all CPU cores | image processing, simulations |
Cook the three dishes at the same time with asyncio.gather and print the list of results. Note: the “ready” lines appear in order of cooking time, while the list keeps the menu order.
import asyncio
async def cook(dish, seconds):
await asyncio.sleep(seconds)
print('ready:', dish)
return dish
menu = [('plov', 0.3), ('salad', 0.1), ('soup', 0.2)]
# cook all dishes at the same time with asyncio.gather and print the results▸ Expected output
ready: salad ready: soup ready: plov ['plov', 'salad', 'soup']
Write the coroutine fetch_with_limit: it runs fetch with a time limit of limit seconds and returns its result, or the string '<name>: timeout' when the time runs out.
import asyncio
async def fetch(name, seconds):
await asyncio.sleep(seconds)
return f'{name}: ok'
async def fetch_with_limit(name, seconds, limit):
# return fetch's result, or f'{name}: timeout' if it takes longer than limit
...
results = await asyncio.gather(
fetch_with_limit('fast', 0.05, 0.2),
fetch_with_limit('slow', 0.5, 0.2),
)
print(results)▸ Expected output
['fast: ok', 'slow: timeout']
Key points
async defdefines a coroutine; calling it returns a coroutine object, andawaitruns it.- At each
awaitthe coroutine pauses and the event loop runs another one — one thread, cooperative multitasking. - A script starts with
asyncio.run(main()); in the browser and in Jupyter youawaitdirectly. gather,create_taskandTaskGrouprun coroutines concurrently; total time ≈ the longest wait.- Never block the loop with
time.sleepor heavy computation; asyncio is for waiting (I/O-bound) tasks.
Check yourself
10 questions. Every correct answer earns XP.
async def load(): ... is defined. What does the call load() return?