Lesson 15 of 34 · 0 of 1 exercises solved

Async & Concurrency

Many programs spend most of their time waiting on the network, the disk or a . asyncio lets one thread run many of those waits at the same time, so ten one-second take about one second instead of ten.

import asyncio, time

async def fetch(name, seconds):
    print(f"start {name}")
    await asyncio.sleep(seconds)     # yields control while waiting
    print(f"done {name}")
    return f"{name}-result"

async def sequential():
    a = await fetch("a", 1)
    b = await fetch("b", 1)
    return [a, b]                    # takes ~2 seconds

async def concurrent():
    return await asyncio.gather(
        fetch("a", 1),
        fetch("b", 1),
    )                                # takes ~1 second

start = time.perf_counter()
print(asyncio.run(concurrent()))
print(f"{time.perf_counter() - start:.2f}s")
 
Python (loads on first run)Ctrl+Enter runs
import asyncio

# Tasks run in the background as soon as they are created
async def worker(n):
    await asyncio.sleep(n * 0.1)
    return n * n

async def main():
    # TaskGroup (3.11+) — the modern way; cancels siblings if one fails
    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(worker(i)) for i in range(5)]
    print([t.result() for t in tasks])   # [0, 1, 4, 9, 16]

    # Timeouts
    try:
        await asyncio.wait_for(asyncio.sleep(10), timeout=0.5)
    except asyncio.TimeoutError:
        print("gave up waiting")

    # Bounding concurrency so you do not open 5000 sockets at once
    sem = asyncio.Semaphore(10)
    async def limited(i):
        async with sem:
            return await worker(i)

    results = await asyncio.gather(*(limited(i) for i in range(100)))
    print(len(results))

asyncio.run(main())
 
Python (loads on first run)Ctrl+Enter runs
import asyncio
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# Three kinds of concurrency, three right answers:
#
#   asyncio      -> many I/O waits, one thread     (network, sockets, DB)
#   threads      -> blocking I/O in libraries that are not async-aware
#   processes    -> CPU-bound work (bypasses the GIL)

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

def blocking_io():
    import time; time.sleep(1); return "io done"

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

    # Push blocking work off the event loop so it stays responsive
    with ThreadPoolExecutor() as pool:
        print(await loop.run_in_executor(pool, blocking_io))

    with ProcessPoolExecutor() as pool:
        results = await asyncio.gather(*(
            loop.run_in_executor(pool, cpu_heavy, 1_000_000)
            for _ in range(4)
        ))
        print(results[0])

asyncio.run(main())
 
Python (loads on first run)Ctrl+Enter runs

Common question

Does asyncio make my program run in parallel?

Concurrency is dealing with many things at once; parallelism is doing many things at once. asyncio gives you the first, on a single thread, through an event that runs one task until it awaits and then switches to another task that is ready. So the program does useful work while I/O is in flight, which for network-heavy programs is most of the time.

Write coroutines with async , pause them only with await on things that can be awaited, and start many at once with gather or TaskGroup. Never call blocking functions inside the loop; wrap them with asyncio.to_thread if you have to. For CPU-heavy work, use multiprocessing or a native extension, because awaiting never makes arithmetic faster.

Source: The asyncio documentation: "asyncio is a to write concurrent code using the async/await syntax." Concurrent, not parallel.

Common mix-ups

Each of these sounds reasonable. Think about why it is wrong, then open the explanation.

  • asyncio.gather runs several coroutines at once, so it must be using several CPU cores.

  • Adding async to a function makes it faster.

  • time.sleep inside a coroutine pauses only that coroutine.

Practice

Write an async function fetch(name, delay) that sleeps for delay seconds with asyncio.sleep and returns name. In main(), run three fetches concurrently with asyncio.gather so the whole program finishes in about 0.3 seconds, not 0.6, and store the returned names in results.

Exercise

Run tasks concurrently

4 checks
import asyncio
import time


async def fetch(name, delay):
    ...


async def main():
    global results
    start = time.perf_counter()
    # run fetch("A", 0.3), fetch("B", 0.2), fetch("C", 0.1) concurrently
    results = []
    print(results, f"{time.perf_counter() - start:.2f}s")


asyncio.run(main())
 
Python (loads on first run)Ctrl+Enter runs
Output
Press Run to try your code, then Check answer to grade it.

Check yourself

Quick check

What does await do inside an async function?

Finished reading?1 exercise still open. You can come back to it later.

Tip: use the ← and → keys to move between lessons.