> ## Content Index
> Fetch the complete content index at: https://debugly.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Debugging asyncio Deadlocks and the Blocking Call Checklist
- URL: https://debugly.dev/python-asyncio-deadlock-blocking-calls/
- Published: 2026-06-02T08:50:00.000Z
- Updated: 2026-10-17T05:58:40.000Z
- Description: Your async service handles ten requests a second and pegs one core. Somewhere a synchronous call is blocking the event loop.
- Author: Rohit Bhadani
- Tags: Python, Debugging, Performance, Error Autopsy

## The quick version

An async application that is slow, unresponsive, or hung almost always has **synchronous code blocking the event loop**. One blocking call stops every coroutine on that loop, not just the one that made it.

Find it:

```python
import asyncio
loop = asyncio.get_running_loop()
loop.set_debug(True)
loop.slow_callback_duration = 0.1   # warn on anything over 100ms

```

```
Executing <Task ... coro=<handler() at app.py:42>> took 2.417 seconds

```

That log line names the coroutine and the file. It is usually the whole diagnosis.

Tested on Python 3.13.2.

## Why one blocking call stops everything

An event loop is a single thread running a loop: take the next ready callback, run it to completion, repeat.

"To completion" is the important part. There is no preemption. A coroutine yields control only at an `await` that actually suspends. If your code calls something synchronous that takes two seconds, the loop runs that for two seconds and every other task waits, including the ones that were ready.

So the symptom is not a slow endpoint. It is **every** endpoint being slow whenever any request hits the blocking path, which makes it look like a general capacity problem rather than a specific bug.

The tell in metrics: CPU pinned at roughly one core regardless of load, throughput that does not improve with concurrency, and latency that rises for requests that should be fast.

## The blocking call checklist

Things that look fine in an async function and are not.

**`requests` and `urllib`.** Synchronous HTTP. Use `httpx` or `aiohttp`.

```python
# blocks
r = requests.get(url)

# does not
async with httpx.AsyncClient() as client:
    r = await client.get(url)

```

**`time.sleep`.** Use `await asyncio.sleep`. This one is usually obvious and occasionally hides in a retry helper somebody imported.

**File I/O.** `open()`, `read()`, `write()` are all blocking. For small config files at startup nobody cares. For per request file access it matters. Use `aiofiles`, or `asyncio.to_thread`.

**Database drivers.** `psycopg2` is synchronous. `psycopg` version 3 has async support, `asyncpg` is async native. SQLAlchemy needs the async engine and an async driver, and using the sync engine inside async code is a common mistake because it works right up until load.

**`subprocess.run`.** Use `asyncio.create_subprocess_exec`.

**CPU bound work.** JSON parsing a 50MB document, image resizing, cryptography, compression, regex over a large string. None of these have an async version because the problem is not I/O. They need a process pool.

**Logging to a slow handler.** A logging handler writing to a network destination synchronously blocks the loop on every log line. Use a `QueueHandler`.

**DNS resolution in some paths.** `socket.getaddrinfo` is blocking. asyncio's default resolver runs it in a thread pool, which is fine, but a library calling it directly is not.

## Finding the blocker

### Debug mode first

```python
asyncio.run(main(), debug=True)

```

Or `PYTHONASYNCIODEBUG=1`. It logs any callback taking longer than `slow_callback_duration`, defaulting to 100ms, and also warns about coroutines that were never awaited.

Do not run this in production, since it adds meaningful overhead. It is the fastest path to an answer in development.

### Dump the tasks when it hangs

For a hang rather than slowness, you want to know what every task is doing.

```python
import asyncio, signal, sys, traceback

def dump_tasks(*_):
    for task in asyncio.all_tasks():
        print(f"--- {task.get_name()} done={task.done()}", file=sys.stderr)
        task.print_stack(file=sys.stderr)

# in an async context
loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGUSR1, dump_tasks)

```

Then `kill -USR1 <pid>` and read the stacks. Same technique as [triggering a heap snapshot on a signal in Node](https://debugly.dev/nodejs-memory-leak-heap-snapshot/), and equally useful: no restart, no port exposed, works in production.

A task stuck in `_run_once` or showing a stack inside synchronous library code is your blocker.

### py-spy for the running process

```bash
py-spy dump --pid 1234
py-spy top --pid 1234

```

`py-spy` attaches without modifying the process and shows you where time is being spent. For an async application, `py-spy dump` shows the current stack of every thread, and if the main thread is inside `psycopg2` or `requests`, you have found it in about five seconds.

In a container you need `SYS_PTRACE`, the same as [strace](https://debugly.dev/strace-for-web-developers/).

## Fixing it

### Offload to a thread

For blocking I/O you cannot replace:

```python
result = await asyncio.to_thread(blocking_io_function, arg)

```

`asyncio.to_thread` runs it in the default executor and awaits the result. The loop stays free.

This works because the GIL is released during I/O. It does **not** help CPU bound work, where the thread holds the GIL and blocks the loop anyway.

### Use a process pool for CPU work

```python
from concurrent.futures import ProcessPoolExecutor

pool = ProcessPoolExecutor(max_workers=4)

async def handle():
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(pool, cpu_heavy_function, data)

```

Separate processes, separate GILs. The cost is pickling arguments and results, so this is only worth it when the work is substantially larger than the serialisation.

Create the pool once at startup, not per request. Creating a process pool is expensive and doing it in a handler is a bug I have seen more than once.

### Yield inside long loops

If you have a genuinely long running pure Python loop that you cannot move:

```python
for i, item in enumerate(large_list):
    process(item)
    if i % 1000 == 0:
        await asyncio.sleep(0)   # yield to the loop

```

`await asyncio.sleep(0)` yields without delay. It is a pressure valve rather than a fix, and it keeps the service responsive while the work proceeds.

## Actual deadlocks

Distinct from blocking, and less common.

**Awaiting your own task.**

```python
async def process():
    task = asyncio.current_task()
    await task          # waits for itself, forever

```

**Lock ordering.** Two coroutines acquiring two `asyncio.Lock` objects in opposite orders deadlock exactly like threads do. The fix is the same as [ordering database locks](https://debugly.dev/postgres-deadlock-detected-fix/): acquire in a consistent order everywhere.

**An unbounded queue with no consumer**, or a bounded queue where the consumer is waiting on something the producer holds.

**Calling `asyncio.run` inside a running loop.** Raises rather than deadlocking in modern Python, and in older code you find `loop.run_until_complete` nested inside a coroutine, which does hang.

For all of these, the task dump is the tool. A task blocked on `Lock.acquire` with another task holding it tells you the story immediately.

## Prevention

**Ban the sync libraries with a lint rule.** A `flake8` or `ruff` rule forbidding `import requests` in your async package catches the most common cause at review time. Same for `time.sleep`.

**Set `slow_callback_duration` in development** and treat the warnings as failures. Most blocking calls are introduced by someone who did not realise the library was synchronous, and a warning at the moment they add it is far cheaper than finding it in production.

**Load test with concurrency.** A blocking call is invisible at one request at a time. Ten concurrent requests reveals it immediately, because throughput does not scale.

**Watch event loop lag as a metric.** Schedule a callback every 100ms and measure how late it actually runs. Lag above a few tens of milliseconds means something is blocking, and this is the single best production signal for this class of problem:

```python
async def monitor_lag():
    while True:
        start = time.monotonic()
        await asyncio.sleep(0.1)
        lag = (time.monotonic() - start - 0.1) * 1000
        if lag > 50:
            logger.warning("event loop lag %.0fms", lag)

```

That is ten lines and it is the difference between knowing and guessing. It belongs in every async service, in the same category as [the other metrics worth having by default](https://debugly.dev/three-metrics-that-catch-incidents/).

---

Published on [debugly.dev](https://debugly.dev/) by [Rohit Bhadani](https://debugly.dev/about/) | Founder of [Krova Cloud](https://krova.cloud/?ref=debugly.dev)