skip to content

What does python -m asyncio ps <pid> print, and when would you reach for it?

level: seniorimportance: nice to knowfreq 15%

answer

  1. Two subcommands, added in 3.14
  2. You give it a process id
  3. It needs nothing from the target
  4. One prints a table, one a tree
  5. A cycle in the graph stops the tree

basics

~20 s

New in Python 3.14, it attaches to a running Python process by PID and prints a table of its pending asyncio tasks — id, name, coroutine stack and awaiter — with no restart and no change to the program.

solid answer

~50 s

`python -m asyncio ps <pid>` prints one row per pending task in a *running* process: thread id, task id, task name, the coroutine stack, and the awaiter chain, name and id. `python -m asyncio pstree <pid>` renders the same await graph as a tree of who awaits whom, and refuses to draw it if the graph contains a cycle, reporting the cycle members instead — which is itself a finding. Both are new in 3.14 and use the external-process inspection support added by PEP 768: they read the target's memory rather than executing anything inside it, so the target needs no debug flag, no code change and no restart. You reach for it exactly when that matters — a stalled production process you cannot redeploy. It needs OS permission to attach, and it shows only asyncio tasks.

code

console · 2 lines
console
python3 -m asyncio ps 4242
python3 -m asyncio pstree 4242

go deeper

for a junior

Just know it exists on Python 3.14: given a process id it lists that process's pending asyncio tasks from outside, so a stalled service can be inspected before anyone restarts it.

for a middle

Be able to say what the two subcommands print — a task table versus an await tree — and why neither requires a debug flag, a restart or any change to the target program.

for a senior

Show incident judgement: inspect before restarting, read the await tree for the stuck subtree, and know the limits — asyncio tasks only, permissions needed, and a blocked loop looks like ordinary suspension.

for a principal

Decide whether remote inspection is permitted in your runtime at all: it is a privileged read of process memory, so the capability that makes debugging possible is the one a hardened image may deliberately remove.

## The gap it fills Every other way of listing a loop's tasks requires cooperation from inside the process: a dump endpoint you added, a signal handler you registered, debug mode you enabled at start-up. During an incident, the process that is stalled is the one that has none of those, and restarting it destroys the evidence you were about to collect. Python 3.14 closed that gap. The `asyncio` module, which had been runnable as an async REPL, gained subcommands: ```console python3 -m asyncio ps 4242 python3 -m asyncio pstree 4242 ``` Both take the PID of a running Python process and report its asyncio tasks from the outside. ## What each one prints `ps` prints a flat table, one row per pending task: the thread id it lives on, the task id, the task name, the coroutine stack rendered as a chain of function names, and then the awaiter chain, the awaiter's name and the awaiter's id. It is the machine-readable shape — easy to grep, easy to paste into a ticket, and it copes with hundreds of tasks. `pstree` takes the same data and draws the await graph as a tree: each task, then beneath it the coroutine frames, then beneath those the tasks *those* frames are awaiting. This is the view that answers "why is this whole subtree stuck" in one glance, because the structure of the stall is visible rather than reconstructed from ids. One behaviour is worth memorising: if the await graph contains a cycle, `pstree` does not draw a tree at all. It reports that the graph contains cycles and lists the tasks in each cycle. That is not a failure of the tool — a cycle in an await graph is a deadlock, and the tool just named the participants. ## How it works, and what that implies These subcommands are built on the external-process inspection support added in 3.14 (PEP 768), the same infrastructure behind `sys.remote_exec`. Crucially, `ps` and `pstree` *read* the target's memory and reconstruct its task graph; they do not inject or run code in the target. The consequences are all practical: - **Nothing is required of the target.** No debug flag, no environment variable, no library, no restart. A process that has been running for three weeks is inspectable as it stands. - **No steady-state cost.** Unlike debug mode, there is nothing to leave on; the price is paid only when you look. - **It needs OS permission to attach.** Reading another process's memory is a privileged operation; on a locked-down machine it refuses with an explicit permissions message pointing at the documentation. In containers this usually means the right capability, and on Linux a ptrace policy that permits it. - **Version coupling.** The tool reads interpreter data structures, so it belongs to the same CPython version as the target, and there is nothing to inspect in a 3.13 or earlier process. - **Hardened builds can switch it off.** Remote inspection can be disabled at build time or by configuration, so a hardened runtime may not offer it at all. ## What it will not tell you It is an *await* graph, not a thread dump. If your loop is blocked because synchronous code holds the thread, every task in that process shows as merely suspended and nothing points at the culprit — the tool shows what tasks are waiting for, not who is holding the thread. It does not show non-asyncio threads, work handed to an executor, or time spent inside a C call. And it is a snapshot: run it twice and diff, exactly as you would with an in-process dump, because a live loop is *supposed* to be full of suspended tasks. ## Where it sits in the toolkit Think of three tiers. A permanent, cheap in-process signal tells you the loop stalled. asyncio debug mode, enabled where you can afford it, names the blocking callback. And `ps`/`pstree` are for the case the first two do not cover: a live process, no instrumentation, no restart, and a need to know what its tasks are waiting for right now. Knowing it exists changes what you do in the middle of the night — you inspect before you restart, instead of restarting and losing the state that would have explained the incident.

  • What does pstree do when the target's tasks await each other in a cycle?
    It refuses to draw the tree and instead reports that the await graph contains cycles, listing the tasks in each one. That output is the diagnosis, not an error: a cycle of tasks awaiting each other is a deadlock, and the tool has just handed you its participants by name.
  • The loop in the target process is blocked by synchronous code. What will ps show you?
    A set of tasks that look ordinarily suspended, with nothing marking the culprit. It reconstructs the await graph, so it answers what tasks are waiting for, not which code holds the loop's thread. For that case you want a stall signal from inside the process — a drift measurement or asyncio debug mode's slow-callback warning — and the task graph only as context.
  • Why does the target need no debug flag or code change for this to work?
    Because the tool reads the target's memory from outside using the external-process inspection support added in 3.14, rather than running anything inside it. Nothing has to be enabled in advance and there is no steady-state cost. The price is that reading another process's memory is privileged, so it needs OS permission to attach and fails with an explicit permissions message when it does not have it.

saying these in an interview costs you the question

  • Assumes it works on any Python version
  • Expects it to show threads or blocking C calls
  • Believes it injects and runs code in the target
  • Thinks no OS permission is needed to attach
  • Confuses the subcommands with the asyncio REPL
  • Restarts the stalled process before inspecting it

context