Debugging Async Code with AsyncIO Inspector (Python 3.14)
Learn how to keep—and improve—your debugging workflow when migrating from Eventlet to AsyncIO using Python 3.14's new external inspector tools.
Overview of the AsyncIO Inspector 🔗
This guide is about how to keep—and improve—your debugging workflow once you migrated. Python 3.14 introduces a pair of command-line tools that let you inspect a live asyncio
program from outside the process, no telnet consoles or custom logging required.
New tool | What it does | One-liner |
---|---|---|
python -m asyncio ps <PID> |
Dump a flat table of every running task plus its coroutine stack. | python -m asyncio ps 12345 |
python -m asyncio pstree <PID> |
Show a hierarchical await tree (who awaits whom). | python -m asyncio pstree 12345 |
These cover the same needs Eventlet users traditionally solved with eventlet.debug
, hub_blocking_detection()
, and the backdoor telnet console—only now they're:
- Zero-intrusion (no code changes, works on prod PIDs).
- Read-only & safe (uses the debugger API; won't freeze your loop).
- Structured (TaskGroup names, await relationships, cycle detection).
Audience & Prerequisites 🔗
- You operate services that still use Eventlet in places and have begun migrating parts to asyncio.
- You're already running Python 3.14 (beta or newer) in staging or prod.
- You want to identify deadlocks, long-running coroutines, or await cycles quickly.
No prior knowledge of pdb
or low-level CPython internals required.
Quick Demo — 60 Seconds to First Stack Trace 🔗
# 1. Start any asyncio program (PID will vary)
python my_server.py & # assume PID=12345
# 2. Flat view
python -m asyncio ps 12345
# 3. Hierarchical view
python -m asyncio pstree 12345
Sample ps
output (first 3 rows):
python -m asyncio ps 12345
tid task_id task_name coroutine_chain awaiter_name runtime
--------- ----------------- ------------- ------------------------------------- ------------- -------
1407356 0x7f8b1b5d9e90 tg_workers crawl_site -> parse_links main 12.4s
1407356 0x7f8b1b5da250 heartbeat heartbeat -> asyncio.sleep main 0.5s
1407356 0x7f8b1b5db610 crawl_site parse_links -> fetch_url tg_workers 3.2s
Sample pstree
snippet (abridged):
python -m asyncio pstree 12345
└── (T) tg_workers [12 tasks]
├── crawl_site('https://example.com')
│ └── parse_links()
│ ├── fetch_url('https://example.com/a') [3.2 s]
│ └── fetch_url('https://example.com/b') [3.1 s]
├── crawl_site('https://example.org')
│ └── parse_links()
│ ├── fetch_url('https://example.org/a') [3.0 s]
│ └── fetch_url('https://example.org/b') [3.0 s]
└── heartbeat() [sleeping 0.5 s]
Red flags:
- Leaf tasks stuck > N seconds.
- A branch that keeps growing (possible memory leak).
Eventlet versus AsyncIO Introspection—Cheat Sheet 🔗
Concern | Eventlet tooling | AsyncIO 3.14 equivalent | Pros of new tool |
---|---|---|---|
Flat list of green threads / tasks | eventlet.debug.format_threads_info() |
python -m asyncio ps |
No in-process call needed; includes call stacks. |
Call hierarchy | N/A (manual walk in backdoor) | python -m asyncio pstree |
Readable tree; highlights cycles automatically. |
Blocking detector | hub_blocking_detection(timeout) |
Implicit: long leaf tasks in pstree |
Non-intrusive; no thread-interrupt side-effects. |
Live console | eventlet.backdoor.backdoor_server (telnet) |
Use external CLI + await asyncio.to_thread(pdb.set_trace) if needed |
No open port; safer in prod. |
Understanding the Output 🔗
PS Columns
Column | Meaning |
---|---|
tid | Native thread ID (useful if you run multiple loops). |
task id | Memory address of the asyncio.Task object. |
task name | Comes from task.set_name() or TaskGroup child naming. |
coroutine chain | Top-of-stack coroutine → … → root. |
awaiter | Which task (if any) is currently awaiting this task. |
PSTREE Glyphs
(T)
— a task node (leaf or internal).- Indentation shows await nesting.
- Cycle detection: if the await graph forms a loop, the tool aborts and prints the exact path twice (start ↔︎ end).
Best Practices During Migration 🔗
- Name your tasks —
asyncio.create_task(coro, name="crawler_page")
or insideTaskGroup
usecreate_task(coro, name=...)
. These names appear in both inspectors. - Keep Eventlet debug hooks until removed — they still help with the legacy side; use the asyncio CLI for the new side.
- Script it — add a CI health check:
python -m asyncio ps $PID | grep -q "TaskGroup.*blocked" && exit 1
Fail fast if a task exceeds your SLA.
- Combine with profiling — long-running leaves can be profiled via
asyncio.run(await aiomonitor.start_server())
inside staging only.
Troubleshooting the Inspector Itself 🔗
Symptom | Cause | Remedy |
---|---|---|
PermissionError when attaching |
Different user or container namespace | Run as same UID / enter the pid-ns via nsenter . |
Empty output | Wrong PID or Python interpreter < 3.14 | Verify with ps aux and python -V . |
Unicode gibberish in stacks | Terminal's locale mismatch | export LC_ALL=en_US.UTF-8 . |
Quick Reference Commands 🔗
# Flat view, filter by task name
python -m asyncio ps <PID> | grep loader
# Limit output lines (rough approximation of depth)
python -m asyncio pstree <PID> | head -n 100
Further Reading 🔗
One-Pager Takeaway
Python 3.14's ps
and pstree
put x-ray goggles on your event loop.
They replace telnet backdoors and ad-hoc logging with an outside-looking-in view that's safer, richer, and ready for production.