Your async service answers most requests in 20 milliseconds, but now and then one takes two seconds. Some requests hang forever. In async code, a slow or stuck request has one of two causes, and each needs its own tool:
- The loop is blocked. Some code runs for a long time without reaching
an
await, so every other task waits. Every request slows down at once. - A task is waiting. The task sits at an
awaitfor something slow, or for something stuck, such as a lock its holder forgot to release. Other requests carry on.
Find blocking code with debug mode
In debug mode, asyncio logs every task step that holds the loop for longer than 100 milliseconds:
import asyncio
import time
async def handler() -> None:
time.sleep(0.3) # a blocking call inside async code
asyncio.run(handler(), debug=True)
Executing <Task finished name='Task-1' coro=<handler() done, defined at app.py:5> ...> took 0.305 seconds
A task step is the code a task runs between two points where it gives
control back to the loop. A slow step means a blocking call or heavy
computing somewhere in that stretch. The message names the task, not the
line. So give tasks names, as in asyncio.create_task(coro, name="refresh-cache"),
and read that task's code between its awaits. Move the blocking call to
asyncio.to_thread.
You can also turn on debug mode with the environment variable
PYTHONASYNCIODEBUG=1 or with python -X dev. When you forget to await a
coroutine, debug mode adds the line that created it to the warning. Debug
mode slows the loop down, so turn it on to investigate, and measure the cost
before you leave it on in production.
The warning prints only when the step ends. A step that never ends, such as an infinite loop, prints nothing. The last section covers that case.
See what every task is waiting for
When requests hang but the loop still runs, list the tasks and where each one is suspended:
import asyncio
import sys
async def refresh_cache(release: asyncio.Event) -> None:
await release.wait() # stands in for a slow fetch
def dump_tasks() -> None:
for task in sorted(asyncio.all_tasks(), key=lambda t: t.get_name()):
print(f"task: {task.get_name()}")
task.print_stack(limit=3, file=sys.stdout)
async def main() -> None:
release = asyncio.Event()
worker = asyncio.create_task(refresh_cache(release), name="cache-refresh")
await asyncio.sleep(0) # let the worker start and suspend
dump_tasks()
release.set()
await worker
asyncio.run(main())
The entry for the worker shows the line it is suspended on:
task: cache-refresh
Stack for <Task pending name='cache-refresh' coro=<refresh_cache() running at app.py:6> ...>
File "app.py", line 6, in refresh_cache
await release.wait() # stands in for a slow fetch
In a real service, call dump_tasks from an admin endpoint. It runs on the
event loop, so it needs the loop to be free. Keep the output short, and keep
secrets out of task names.
On Python 3.14, print who is waiting for whom
A stack shows where one task is suspended. To see which task is waiting for
it, use asyncio.print_call_graph(task), new in Python 3.14. It prints the
task's stack and then the tasks that await it:
* Task(name='fetch-user', id=0x106163df0)
+ Call stack:
| File 'app.py', line 5, in async fetch_user()
+ Awaited by:
* Task(name='request-42', id=0x106420d70)
Python 3.14 can also inspect another running Python 3.14 process from the
command line. Replace PID with its process ID:
python3.14 -m asyncio ps PID
python3.14 -m asyncio pstree PID
ps lists the pending tasks as a table, and pstree draws them as a tree of
who awaits whom. Both read the other process's memory, so you may need extra
permissions, such as sudo on macOS. Try them on your own machine before
you need them in an incident.
When a step never ends, look from outside
If a step never ends, every task waits, your task dump included. Use a tool
that reads the process from outside.
py-spy is a profiler you install with
pip install py-spy. py-spy dump --pid PID prints the
current stack of every Python thread, so the event loop's thread shows the
exact line it is stuck on.
Check your understanding
Debug mode logs Executing <Task ... name='report' ...> took 1.200 seconds. What does that tell you?
Show answer
Hide answer
The loop ran the report task for 1.2 seconds without switching to anything
else. Somewhere between two of its awaits, it made a blocking call or did
heavy computing, and every other task waited. Read that stretch of code, or
profile it, and move the blocking call to asyncio.to_thread.
Check your understanding A request hangs forever, but debug mode logs nothing and other requests are fine. What do you check first? Show answer Hide answer
A task dump. Other requests are fine, so the loop is running, and the stuck
request is waiting at an await. The dump shows which one. On Python 3.14,
print_call_graph also shows which task is waiting for it.
Going further
loop.slow_callback_duration sets the threshold for the slow-step warning,
0.1 seconds by default. Debug mode also checks for some asyncio calls made
from the wrong thread.
Read more in Developing with asyncio, call graph introspection and the asyncio command-line tools.