Skip to content
Educora
Advanced22 min21 / 42

Asynchronous Python: async and await

Run waiting-heavy work concurrently: coroutines, `await`, the event loop, `asyncio.run`, `gather`, `create_task`, `TaskGroup`, timeouts, `async for` and the dangers of blocking the loop.

Check yourself
In this lesson you will learn
  • Explain coroutines, await and the event loop
  • Run coroutines concurrently with gather, create_task and TaskGroup
  • Set timeouts with wait_for and use async 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

Definition
Coroutine

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.

Python
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
In the browser this code runs inside an event loop that is already running, so 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())**:

Python
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())
Expected output
['rates (0.3 s)', 'news (0.1 s)']
The structure of a normal script: run it on your computer with 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:

Python
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:

Python
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')
Expect about 0.60 s and 0.30 s; the exact numbers differ slightly on every run.

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:

Python
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):

Python
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:

Python
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

ApproachGood forExample
asynciomany simultaneous waits in one thread (network, sockets)a chat server, a web crawler, FastAPI
threads (threading)blocking libraries that cannot be awaitedseveral downloads with requests
processes (multiprocessing)heavy calculations on all CPU coresimage processing, simulations
Exercise

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.

Exercise · Python
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']
Exercise

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.

Exercise · Python
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 def defines a coroutine; calling it returns a coroutine object, and await runs it.
  • At each await the 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 you await directly.
  • gather, create_task and TaskGroup run coroutines concurrently; total time ≈ the longest wait.
  • Never block the loop with time.sleep or heavy computation; asyncio is for waiting (I/O-bound) tasks.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
async def load(): ... is defined. What does the call load() return?