When async isn't the right tool
Async solves waiting, not computing
Every problem this lesson has solved so far has the same shape:
something waiting on the outside world — a network response, a timer —
while the CPU sits idle and could be doing something else in the
meantime. That’s I/O-bound work,
as named back in Concept 1.
async/await is specifically a solution for overlapping waiting, not
for making the CPU itself compute anything faster.
CPU-bound work is the opposite case: a tight loop doing heavy
computation, with no waiting involved at all — hashing a large amount of
data, running a numeric simulation, some parsing job that’s genuinely
compute-intensive rather than mostly idle. Wrapping that kind of code in
async def doesn’t speed it up:
import asyncio
import time
async def compute_something_heavy(n: int) -> int:
total = 0
for i in range(n):
total += i * i
return total
async def main():
return await asyncio.gather(
compute_something_heavy(5_000_000),
compute_something_heavy(5_000_000),
)
start = time.perf_counter()
asyncio.run(main())
elapsed = time.perf_counter() - start
print(f"total time: {elapsed:.2f}s")(illustrating expected behavior — actual numbers depend heavily on the machine running this, not shown as a specific value here)
Even wrapped in gather(), both calls to compute_something_heavy
still take roughly as long combined as they would running one after
another — there’s no await anywhere inside the loop, so there’s no
point where either coroutine ever actually pauses to let the other one
run. async/await only creates opportunities for other code to run at
points where something explicitly says “I’m waiting, go ahead” — a tight
computational loop with no await inside it never says that, so from the
event loop’s perspective, it behaves exactly like
the blocking time.sleep() case from earlier in this lesson:
nothing else gets a chance to run until it’s done.
Why: the GIL
The underlying reason Python doesn’t get free CPU parallelism from
async, or even from regular threads, is the GIL — the Global
Interpreter Lock. CPython (the standard Python implementation this
entire course has been using) only ever executes one thread’s Python
bytecode at a time, no matter how many threads exist. This is a
deliberate, long-standing design choice in CPython specifically, not a
property of the Python language itself — it exists to keep CPython’s
internal memory management simple and safe, at the cost of true
CPU-level parallelism for pure Python code within a single process.
This is exactly why asyncio works the way it does: it doesn’t need
multiple OS threads running Python code truly simultaneously — it gets
concurrency by cooperatively pausing and resuming coroutines within a
single thread, at await points, which is a completely different
mechanism than actually running multiple pieces of Python code on
different CPU cores at once.
What actually exists for CPU-bound work
This course doesn’t teach either of these in depth — they’re genuinely separate topics — but it’s worth knowing they exist, and roughly why:
threading— Python’s standard threading module. Because of the GIL, threads don’t help with pure CPU-bound Python code the way you might expect from other languages; two threads doing heavy computation still take turns on the same single core. Threading remains useful for I/O-bound work in codebases that predateasyncioor need to call blocking libraries, but for new I/O-bound code,asynciois generally the more modern, more efficient choice.multiprocessing— sidesteps the GIL entirely by running separate processes, each with its own Python interpreter and its own GIL. This gives genuine parallel execution across CPU cores for CPU-bound work, at the cost of more overhead (starting a process is heavier than starting a thread or a coroutine) and no automatically shared memory between processes the way threads share memory within one process.
The decision rule
Before reaching for async/await, the question worth asking is simply:
is this code mostly waiting, or mostly computing? Waiting on a
network call, a file, another service — asyncio is the right tool,
exactly as the rest of this lesson has shown. Genuinely heavy computation
with no waiting involved — hashing, numeric processing, parsing a huge
amount of data — needs multiprocessing (or, often just as reasonably,
no concurrency at all, if the computation is a one-off and fast enough
that the added complexity isn’t worth it). Reaching for async/await
on CPU-bound code doesn’t just fail to help — it adds real complexity
(coroutines, await, an event loop) for exactly zero benefit.
What kind of problem does async/await actually solve?