๐ŸŒ‰ Pattern 4: Offload Blocking Code

Never block the event loop. Run blocking/CPU code in thread or process pools.

The Problem: #1 Async Mistake in Production

โŒ WRONG: Blocking call inside async code
async def handler():
    time.sleep(1)  # โ† FREEZES the event loop
    result = requests.get(url)  # โ† also blocks the loop
    heavy_json = json.loads(big_data)  # โ† CPU-heavy
    return result

# While one request sleeps for 1s, ALL OTHER REQUESTS stall
# One user's slow request = entire server frozen
Why this is bad: The event loop runs on a single thread. If you call a blocking function (one that doesn't `await`), that thread freezes. Every other coroutine stalls, waiting for the loop to be free again.

Visual: How Blocking Freezes the Loop

โŒ Blocking call โ€” the whole loop freezes

sequenceDiagram participant A as Req A participant EL as Event Loop participant BC as Req B and C A->>EL: call time.sleep(1) directly Note over EL: ๐Ÿ”ด loop FROZEN for 1s Note over BC: Req B waiting...
Req C waiting...
nothing can run EL-->>A: returns after 1s EL->>BC: only now can B and C run

โœ… Offloaded โ€” the loop stays free

sequenceDiagram participant A as Req A participant EL as Event Loop participant Pool as Thread Pool participant BC as Req B and C A->>Pool: await to_thread(blocking_io) Note over EL: ๐ŸŸข loop FREE EL->>BC: Req B runs immediately EL->>BC: Req C runs immediately Pool-->>EL: blocking work done (later) EL-->>A: Req A resumes

The Solution: Two Approaches

1. Blocking I/O โ†’ Thread Pool (asyncio.to_thread)

Use when: Calling a legacy sync library (requests, sqlite, slow JSON parsing)
import asyncio

def blocking_io():
    # Simulate legacy sync code: file read, DB query, etc.
    time.sleep(1)
    return "done"

async def handler():
    # asyncio.to_thread runs blocking_io in a thread pool
    # Loop is free to run other coroutines
    result = await asyncio.to_thread(blocking_io)
    return result

async def main():
    # All 3 handlers run concurrently (requests don't block each other)
    results = await asyncio.gather(
        handler(), handler(), handler()
    )

2. CPU-Bound Work โ†’ Process Pool (ProcessPoolExecutor)

Use when: Heavy math, encoding, parsing. Needs true parallelism (multiple cores). Avoids Python's GIL.
from concurrent.futures import ProcessPoolExecutor
import asyncio

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

async def handler():
    loop = asyncio.get_running_loop()
    with ProcessPoolExecutor() as pool:
        # Offload to separate process
        result = await loop.run_in_executor(pool, cpu_heavy, 5_000_000)
    return result

async def main():
    results = await asyncio.gather(
        handler(), handler(), handler()
    )

When to Use What

Type of Work Tool Why
Sync I/O (file, DB, legacy lib) asyncio.to_thread() Threads can block safely; same process
Heavy CPU, math, encoding ProcessPoolExecutor Multiple processes bypass GIL; real parallelism
Network I/O (API calls) async-compatible lib (httpx, aiohttp) Don't block the loop; designed for async

Key Takeaways

The Rule

Never call a blocking function directly in async code. If you must, offload it.

The Fix

await asyncio.to_thread(fn) or loop.run_in_executor(pool, fn)