Part 5 of 8 · 2 min read
Blocking inside async
Why one slow call can freeze everything, with a measured example, and how to fix it.
Remember the rule: the event loop works only if every job hands control back at its await. A call that blocks never hands control back, so nothing else can run.
One blocking call freezes everything
Calling time.sleep(5), requests.get(...), a big json.loads, or a heavy loop inside async def stops the event loop. No other task runs, no timeout can fire, and the whole server stops answering. The same happens if you use a synchronous library (the requests package, an old database driver) in an async program.
The example project measures this with a "heartbeat": a small task that records a tick every 10 milliseconds. While 0.1 seconds of work runs, the heartbeat should tick about ten times if the loop is free:
| Work inside the async function | Heartbeat ticks |
|---|---|
time.sleep(0.1) (blocking) |
0 |
await asyncio.to_thread(time.sleep, 0.1) |
10 |
With blocking work, the heartbeat never got to run at all. The loop was frozen for the whole 0.1 seconds. With the work moved into a thread, the loop stayed free and ticked as normal.
Fixes, in order of preference:
- Use the library's async version:
httpx.AsyncClient,asyncio.sleep, async database drivers. - Run the blocking function in a thread:
await asyncio.to_thread(func, arg). - For heavy computation, use a process pool or a separate worker service.
Why not make everything async?
Async makes code harder to read and to debug. Use it where you wait on many things at once (fan-out, servers handling many users). A simple script that makes one model call at a time does not need it.
Testing async code
You can test async code with plain asyncio.run(...) inside an ordinary test, as the example project does. Later you may add the pytest-asyncio plugin to write async def test_... directly. Fake the slow part with a short asyncio.sleep or a fake function, as fake_model does.
For the curious Why to_thread helps, and why Python threads do not speed up computing
A thread is a second line of execution inside the same program. to_thread hands the blocking call to a worker thread, so the event loop's own thread stays free. This works well for waiting (like time.sleep or a blocking network call), because a waiting thread uses no CPU.
It does not make computing faster. Standard CPython has a global interpreter lock (GIL): only one thread runs Python code at a time. So two threads doing arithmetic take as long as one. For heavy computing, use separate processes, which each have their own interpreter. Recent Python versions also offer an optional free-threaded build without the GIL, but it is not the default, so plan as if the GIL is there.
Check yourself
- What does the heartbeat measure?
- You must call a library that only has a blocking version. What do you do?
- Why do threads not speed up a heavy calculation in standard Python?