Eventlet Removal Logo
Eventlet Removal

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 🔗

  1. Name your tasksasyncio.create_task(coro, name="crawler_page") or inside TaskGroup use create_task(coro, name=...). These names appear in both inspectors.
  2. Keep Eventlet debug hooks until removed — they still help with the legacy side; use the asyncio CLI for the new side.
  3. 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.

  4. 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.