Blocking Calls in Async Code
A blocking call on an asyncio event-loop thread prevents other tasks on that loop from running until the call returns. Use non-blocking APIs or offload unavoidable synchronous work to preserve responsiveness and throughput.
An asyncio event loop runs callbacks and coroutines on its thread. When a coroutine reaches an `await` that is not ready, it can suspend, letting the loop schedule other work. A synchronous blocking call does not suspend the coroutine in this way: it keeps the thread occupied until it finishes.
This matters when a blocking operation runs on the event-loop thread. During that time, other tasks on the same loop cannot run, including tasks that might otherwise handle requests, process timers, or complete network operations. The result can be increased latency and reduced throughput even if the blocked task is waiting on I/O rather than using much CPU.
Prefer non-blocking libraries for network and file operations when available. For a synchronous I/O function that must be used, offload it with `asyncio.to_thread` or an executor. For CPU-heavy work, use a process pool or another suitable compute strategy; threads do not generally provide parallel execution of Python bytecode under the conventional CPython GIL.
An `async def` declaration does not make every operation inside it non-blocking. The key question is whether each potentially slow operation yields control to the event loop or runs synchronously on its thread.
Key points
- Blocking synchronous calls on the event-loop thread stop other tasks on that loop from progressing until the call returns.
- `async def` does not guarantee that every operation in the function is non-blocking.
- Use async APIs for I/O where possible; offload unavoidable synchronous I/O to a thread or executor.
- For CPU-heavy work, consider process-based execution and account for its overhead.
Interview questions
What does it mean for a call to block an asyncio event loop?
A blocking call runs to completion on the current thread and does not hand control back to the event loop while it waits. As a result, other tasks scheduled on that loop cannot progress during the call. A non-blocking awaitable operation can suspend its coroutine, allowing the loop to run other work. Blocking is especially damaging when the call executes on the event-loop thread.
Why can an `async def` function still block?
`async def` defines a coroutine function, but its body can still call ordinary synchronous functions. When such a function blocks on the event-loop thread, the loop cannot schedule other tasks until it returns. Only operations that actually yield control, such as awaiting a pending asynchronous operation, allow the loop to run other work. Therefore, inspect the operations inside the coroutine, not just its declaration.
How do you handle a synchronous I/O call from an async application?
Use an asynchronous client or API when one is available, because it can wait without occupying the event-loop thread. If a synchronous I/O function is unavoidable, run it in a worker thread using `asyncio.to_thread` or an executor. This keeps the loop available while the worker waits. Be mindful of thread-safety, cancellation behavior, and limits on how many worker operations can run concurrently.
Would you use a thread or process pool for CPU-intensive work?
For CPU-heavy work, a thread can free the event-loop thread, but under the conventional CPython GIL it usually does not provide parallel execution of Python bytecode across cores. A process pool can provide CPU parallelism at the cost of process startup, data transfer, and serialization overhead. The right choice depends on whether the work is I/O-bound or CPU-bound, plus workload size and operational constraints. Measure the real workload before selecting an approach.
How would you diagnose and prevent event-loop blocking in a production service?
I would first identify event-loop stalls using latency metrics, tracing, or asyncio diagnostics such as slow-callback logging. Then I would locate synchronous calls on request paths and replace them with asynchronous APIs or bounded offloading. I would also consider backpressure and worker capacity: offloading every call to an unbounded source of work can merely move the bottleneck. Finally, I would test under realistic concurrency and verify tail latency, not just average throughput.