When async actually helps

RoadmapsPython

Scenario

You rewrite a number-crunching script with `async`/`await` everywhere, expecting it to fly. It runs at exactly the same speed — you just added `await` noise to code that has no I/O. Async isn't 'fast mode'; used on the wrong workload it buys you nothing.

Mental model

Async is one hyper-efficient waiter who never stands idle at a table — the instant a table is 'waiting on the kitchen', they go take another order. Brilliant when the night is mostly waiting (I/O). But if every table asks the waiter to personally solve a math problem (CPU work), one waiter is still one waiter — no amount of cleverness makes them compute two answers at once.

Async concurrency overlaps *waiting*, not *computing*. It runs many coroutines on a single thread, switching at `await` points while I/O is in flight — so it shines for high-volume I/O-bound work (thousands of network/DB calls) with far less memory than one-thread-per-task. It does nothing for CPU-bound work: there are no `await` points to yield at, and one thread still can't use multiple cores. The decision tree: CPU-bound → multiprocessing; I/O-bound at modest scale → threads (simplest); I/O-bound at high scale → asyncio.

Deep dive

Async's superpower is scale of I/O concurrency at low cost. A coroutine parked at `await` is just a small object on one thread — no OS thread stack. So asyncio can keep tens of thousands of connections in flight where one-thread-per-connection would exhaust memory. This is why async web servers and high-fan-out API clients use it.

asyncio overlaps many I/O waits cheaply — it scales I/O concurrency far past what threads can.

Async does nothing for CPU-bound work. There's no I/O to `await`, so a heavy computation just runs start-to-finish on the single event-loop thread — and worse, it *blocks the loop*, freezing every other coroutine until it finishes. CPU parallelism needs multiple cores, which means multiprocessing (or offloading the CPU chunk to a process/thread pool via `run_in_executor`).

No I/O means no yield points; CPU work just blocks the single loop — use processes instead.

Async vs threads for I/O: both overlap waits, so at modest scale threads are usually the pragmatic choice — no need to rewrite call sites as `async`, and blocking libraries just work. Async wins when concurrency is very high (its per-task overhead is tiny) or when you're already in an async framework (FastAPI, aiohttp) where the whole call chain must be awaitable. The catch: async is all-or-nothing — one blocking call in a coroutine stalls the entire loop, so you need async-native libraries throughout.

Threads: simplest for modest I/O. Async: high-scale I/O or an already-async stack — but needs async-native libs end to end.

Code examples

The decision, in one function

# Pseudocode decision guide — pick the model from the workload.

def choose(workload):
    if workload == "cpu_bound":            # hashing, parsing, math, image work
        return "multiprocessing (own GIL per process -> real cores)"
    if workload == "io_bound":            # network, disk, DB, subprocess
        if concurrency == "very_high":     # tens of thousands in flight
            return "asyncio (cheapest per-task, but needs async libs)"
        return "threading (simplest; blocking libs just work)"
    return "plain serial code"

# Mixed job? Keep async for the I/O and hand the CPU chunk to a pool:
#   result = await loop.run_in_executor(process_pool, heavy_cpu_fn, data)

The axis that matters is CPU-bound vs I/O-bound, then scale. Async is one branch of the I/O side — not a universal speed switch. Mixed workloads combine models: async for the waiting, a process pool for the computing.

Async gives nothing here — and even blocks

import asyncio, time

def heavy():                 # CPU-bound: no I/O to await
    return sum(i*i for i in range(30_000_000))

async def worker(name):
    print(f"{name} start")
    heavy()                  # blocks the loop the whole time
    print(f"{name} done")

async def main():
    t = time.perf_counter()
    await asyncio.gather(worker("A"), worker("B"))
    print(f"{time.perf_counter()-t:.2f}s")   # ~sum, not overlapped

asyncio.run(main())
# A start / A done / B start / B done  <- no overlap; B waits for A

Because `heavy()` has no `await`, the loop can't switch to B while A computes — A runs to completion first. Async didn't parallelize anything and actually serialized the two CPU tasks on one thread. This work belongs in a process pool.

Common mistakes

Recall questions

Questions & answers

Walk me through how you'd choose between threading, multiprocessing, and asyncio for a given task.

First classify the bottleneck. CPU-bound (math, parsing, compression) → multiprocessing, because only separate processes escape the GIL and use multiple cores. I/O-bound (network, disk, DB) → threading for simplicity at modest scale, or asyncio when concurrency is very high or the framework is already async, since coroutines overlap thousands of waits with tiny per-task overhead. For mixed workloads, combine them: async/threads for the I/O and a process pool (via `run_in_executor`) for the CPU chunk. The one-liner: async and threads overlap waiting; multiprocessing overlaps computing.

Approach: Lead with CPU-bound vs I/O-bound, map each to a model, then add the scale nuance (threads vs async) and the mixed-workload combination.

A FastAPI endpoint that's `async def` becomes unresponsive whenever it runs a heavy in-memory computation. Explain why and how you'd fix it.

The heavy computation is CPU-bound and has no `await`, so it runs on the single event-loop thread and blocks it — while it runs, the loop can't service any other request, so the whole app stalls. Fixes: offload the CPU work off the loop with `await loop.run_in_executor(process_pool, fn, data)` (a ProcessPoolExecutor for real parallelism), move it to a background worker/queue, or make the endpoint a plain `def` so FastAPI runs it in a threadpool (helps availability but not CPU parallelism). Async was the wrong tool for the compute itself.

Approach: Explain that CPU work with no await point blocks the single loop, then offload to a process pool / background worker rather than expecting async to parallelize compute.

Continue learning

Previous: asyncio and the event loop

Previous: Threading vs Multiprocessing

Return to Python Roadmap