๐ŸŒ€ Async at Scale

Tens of thousands of I/O tasks on one core โ€” if you never block the loop. One sync call stalls every task at once.

The Cardinal Rule: Never Block the Event Loop

The whole model runs on one thread. The event loop makes progress by hopping between tasks at every await. If a coroutine calls something synchronous โ€” a time.sleep, a requests.get, a heavy CPU loop, or a huge json.loads โ€” the loop can't hop. Every other task freezes until that one call returns. One blocking line takes down the entire service's concurrency.
# โŒ The bug: a synchronous call inside a coroutine freezes ALL tasks
import asyncio, time

async def handle(n):
    time.sleep(1)          # SYNC sleep โ€” blocks the loop, not just this task
    return n

async def main():
    # you'd hope for ~1s total; you get ~5s because the loop can't interleave
    await asyncio.gather(*(handle(i) for i in range(5)))

asyncio.run(main())
flowchart TB subgraph GOOD["โœ… Healthy loop"] L["Event loop"] --> A["await task A"] L --> B["await task B"] L --> C["await task C"] A -->|yields| L B -->|yields| L C -->|yields| L end subgraph BAD["โŒ One blocking call"] L2["Event loop"] --> X["task X: time.sleep(1)
NEVER yields"] X --> FROZEN["tasks A, B, C
๐ŸงŠ all frozen"] end

The Fixes: Await, Async Libs, or Offload

Three ways out, in order of preference:

import asyncio

# โœ… 1. Use the async equivalent โ€” yields control back to the loop
async def handle(n):
    await asyncio.sleep(1)     # ASYNC sleep โ€” 5 of these finish in ~1s total
    return n

async def main():
    await asyncio.gather(*(handle(i) for i in range(5)))   # ~1s, not ~5s

asyncio.run(main())
# โœ… 2. Use an async I/O library instead of a blocking one
import httpx     # not `requests`

async def fetch(client, url):
    r = await client.get(url)      # non-blocking; loop serves others while waiting
    return r.json()

async def main(urls):
    async with httpx.AsyncClient() as client:      # reuse one client + pool
        return await asyncio.gather(*(fetch(client, u) for u in urls))
# โœ… 3. Can't avoid a blocking call? Offload it to a thread โ€” the loop stays free
import asyncio, requests

def blocking_get(url):
    return requests.get(url).json()      # legacy sync lib you can't replace

async def fetch(url):
    # asyncio.to_thread (3.9+) runs it in the default ThreadPoolExecutor
    return await asyncio.to_thread(blocking_get, url)

# older / explicit form: loop.run_in_executor(None, blocking_get, url)
Offloading rule: blocking I/O โ†’ a thread pool (to_thread / run_in_executor(None, ...)). Blocking CPU โ†’ a process pool, because threads can't dodge the GIL (see CPU-Bound Scaling).
# โœ… CPU-bound work from async: offload to PROCESSES, not threads
import asyncio
from concurrent.futures import ProcessPoolExecutor

def crunch(n):
    return sum(i * i for i in range(n))     # heavy CPU โ€” would stall the loop

async def main():
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        # runs in a separate interpreter/core; the loop keeps serving I/O
        return await loop.run_in_executor(pool, crunch, 50_000_000)

asyncio.run(main())

Bounding Fan-Out: Don't gather a Million Coroutines

asyncio.gather(*(work(x) for x in millions)) instantiates every coroutine and task object up front. A million tasks is a million task objects, a million buffers โ€” you OOM before any real work finishes, and you hammer the downstream with unbounded concurrency.

Cap concurrency with a Semaphore (see concurrency limits), or run a fixed pool of workers off an asyncio.Queue.

# โœ… Semaphore: still create the coroutines, but only N run at once
import asyncio

sem = asyncio.Semaphore(100)          # at most 100 in flight

async def fetch(client, url):
    async with sem:                   # acquire โ†’ work โ†’ release
        r = await client.get(url)
        return r.json()

async def main(client, urls):
    return await asyncio.gather(*(fetch(client, u) for u in urls))
# โœ… Queue + worker pool: bounded memory even for a truly huge stream of jobs
import asyncio

async def worker(queue, results):
    while True:
        item = await queue.get()
        try:
            results.append(await do_work(item))
        finally:
            queue.task_done()

async def main(items):
    queue = asyncio.Queue(maxsize=1000)        # backpressure: producer waits when full
    results = []
    workers = [asyncio.create_task(worker(queue, results)) for _ in range(50)]
    for item in items:                          # feed lazily โ€” never materialize all
        await queue.put(item)
    await queue.join()                          # wait until every item is processed
    for w in workers:
        w.cancel()
    return results

gather vs as_completed vs TaskGroup

ToolUse whenOn error
gatherYou need all results, in input orderOthers keep running unless return_exceptions/cancel
as_completedYou want results as soon as each finishesYou handle each exception as it arrives
TaskGroup (3.11+)Structured concurrency: all-or-nothing with clean cancellationFirst error cancels siblings, raises an ExceptionGroup
# gather โ€” collect everything, order preserved
import asyncio
results = await asyncio.gather(fetch(a), fetch(b), fetch(c))   # [a, b, c] order
# as_completed โ€” process the fastest responses first (streaming results)
import asyncio
for coro in asyncio.as_completed([fetch(u) for u in urls]):
    result = await coro          # yields in COMPLETION order, not input order
    handle(result)
# TaskGroup (3.11+) โ€” the modern default: structured, auto-cancels on failure
import asyncio

async def main(urls):
    results = []
    async with asyncio.TaskGroup() as tg:        # exits only when all tasks done
        for u in urls:
            tg.create_task(fetch_into(u, results))
    # if any task raised, siblings were cancelled and an ExceptionGroup propagates here
    return results
Reach for TaskGroup first on 3.11+. Unlike bare gather, a failing task cancels its siblings and nothing is silently left running โ€” the #1 source of leaked tasks and zombie work under load.

Deadlines with asyncio.timeout() (3.11+)

Under load, a slow downstream must not tie up a task forever. Wrap any await in a deadline; on expiry it raises TimeoutError and cancels the inner work cleanly.

import asyncio

async def fetch_with_deadline(client, url):
    try:
        async with asyncio.timeout(2.0):        # 2s budget for everything inside
            r = await client.get(url)
            return r.json()
    except TimeoutError:
        return None                             # shed the slow one, keep serving the rest

Faster Loop: uvloop

uvloop is a drop-in event loop built on libuv. Install it and switch with one line โ€” no code changes โ€” for roughly 2โ€“4ร— higher throughput on network-heavy workloads.

# pip install uvloop   (Linux / macOS)
import asyncio
import uvloop

# Python 3.11+: pass the loop factory to asyncio.run
asyncio.run(main(), loop_factory=uvloop.new_event_loop)

# Older / global install (still widely used):
#   uvloop.install()
#   asyncio.run(main())
When it helps: loops dominated by many small socket reads/writes (proxies, API gateways, high-QPS clients). It does not fix blocking calls โ€” a time.sleep stalls uvloop exactly as hard. Fix blocking first, then swap the loop for free headroom.

Profiling the Loop

The loop can't tell you it's stalled โ€” you have to ask. Two cheap built-in probes catch coroutines that hog the thread:

import asyncio

async def main():
    loop = asyncio.get_running_loop()
    loop.slow_callback_duration = 0.1     # log any callback that runs > 100ms
    loop.set_debug(True)                  # warns on slow callbacks + un-awaited coros
    # ... run your app ...

# Or from the shell, no code change:
#   PYTHONASYNCIODEBUG=1 python app.py
Debug mode surfaces the exact bug from the top of this page: any callback exceeding slow_callback_duration is logged with its source line โ€” that's your blocking offender, named and located.

Blocking Offender โ†’ Async Fix

Blocking offenderAsync fix
time.sleep(n)await asyncio.sleep(n)
requests.get(...)await httpx / aiohttp client
Blocking DB driverasyncpg / async SQLAlchemy, or to_thread
Legacy sync I/O you can't replaceasyncio.to_thread(fn, ...) / run_in_executor(None, ...)
Heavy CPU (parse, encode, crunch)run_in_executor(ProcessPoolExecutor(), ...)
Unbounded gather of huge fan-outSemaphore or asyncio.Queue worker pool
No deadline on a slow dependencyasync with asyncio.timeout(s):
Checklist: โ‘  never call sync I/O or heavy CPU in a coroutine โ‘ก offload the unavoidable (threads for I/O, processes for CPU) โ‘ข bound fan-out with a semaphore or queue โ‘ฃ prefer TaskGroup for structured cancellation โ‘ค put asyncio.timeout() on every downstream โ‘ฅ run uvloop and watch slow_callback_duration.