Inspecting Tasks with python -m asyncio ps and pstree¶
Before Python 3.14, seeing what an asyncio service's tasks were doing meant code inside the process — a signal handler, an admin endpoint — written before the incident. 3.14 adds two commands that read the task graph from outside, like a debugger: python -m asyncio ps <pid> prints a table of every task, its coroutine stack and what it is awaiting, and python -m asyncio pstree <pid> prints the same information as a tree of who awaits whom. Run against a service with three request tasks stuck in a slow query and a consumer waiting on an event, ps listed all five tasks with sleep -> db_query -> handle as the request stacks, and pstree showed the four children hanging off the TaskGroup in main. The first attempt failed with Failed to find the PyRuntime section in process … on Linux platform — which turned out to mean "not allowed to read that process", not anything about the build. This guide covers both commands and the permission they actually need.
Prerequisites¶
- Python 3.14 for both the target and the inspecting interpreter, same version.
- Linux or macOS with permission to read the target's memory (details in step 3).
- Named tasks, from naming and tracking tasks for observability — the output is far more readable with names.
1. Run ps against a live process¶
A small service: three request handlers stuck in a slow query and a consumer waiting on an event, all under one TaskGroup.
import asyncio
async def db_query(i):
await asyncio.sleep(3600)
async def handle(i):
await db_query(i)
async def consumer():
await asyncio.Event().wait()
async def main():
async with asyncio.TaskGroup() as tg:
for i in range(3):
tg.create_task(handle(i), name=f"request-{i}")
tg.create_task(consumer(), name="orders-consumer")
asyncio.run(main())
python3.14 -m asyncio ps 3699861
tid task id task name coroutine stack awaiter chain awaiter name awaiter id
-------------------------------------------------------------------------------------------------------------------------------------------------
3699861 0x78d43d345130 Task-1 TaskGroup._aexit -> TaskGroup.__aexit__ -> main 0x0
3699861 0x78d43d345310 request-0 sleep -> db_query -> handle TaskGroup._aexit -> TaskGroup.__aexit__ -> main Task-1 0x78d43d345130
3699861 0x78d43c9403f0 request-1 sleep -> db_query -> handle TaskGroup._aexit -> TaskGroup.__aexit__ -> main Task-1 0x78d43d345130
3699861 0x78d43c940790 request-2 sleep -> db_query -> handle TaskGroup._aexit -> TaskGroup.__aexit__ -> main Task-1 0x78d43d345130
3699861 0x78d43d496c10 orders-consumer Event.wait -> consumer TaskGroup._aexit -> TaskGroup.__aexit__ -> main Task-1 0x78d43d345130
Each row is one task: its name, its coroutine stack innermost-first, and the task awaiting it with that task's own stack. Three requests parked in sleep -> db_query is the kind of pattern you are looking for — in a real service, that column would show acquire -> Pool.acquire -> fetch or similar, naming the resource everyone is queued on.
Verify: every task you expect appears, with the names you gave it.
2. Read the tree with pstree¶
pstree prints the same data shaped as the await graph, which is easier to read when tasks are nested:
└── (T) Task-1
└── main svc.py:7
└── TaskGroup.__aexit__ asyncio/taskgroups.py:72
└── TaskGroup._aexit asyncio/taskgroups.py:121
├── (T) request-0
│ └── handle svc.py:4
│ └── db_query svc.py:3
│ └── sleep asyncio/tasks.py:702
├── (T) request-1
│ └── …
└── (T) orders-consumer
└── consumer svc.py:5
└── Event.wait asyncio/locks.py:213
(T) marks a task; the lines beneath are its coroutine frames with file and line. The tree makes structured concurrency visible: everything hangs off the TaskGroup in main, so cancelling Task-1 would cancel the lot. Tasks created with bare create_task and never awaited appear as separate roots — a quick way to spot fire-and-forget work, the subject of handling exceptions in fire-and-forget tasks.
Because the tool reads memory rather than running code in the target, it works even when the target's loop is blocked in a synchronous call — the case where an in-process signal handler cannot run.
Verify: the tree's roots are the tasks you expect to be top-level; unexpected roots are untracked tasks.
3. Grant the ptrace permission it needs¶
Both commands read the target with the same mechanism a debugger uses. On most Linux distributions the Yama security module restricts that: with /proc/sys/kernel/yama/ptrace_scope set to 1 (the common default), a process may only read its own descendants. Run from another shell, the command fails — and the error is misleading:
Error retrieving tasks: Failed to find the PyRuntime section in process 3700302 on Linux platform
The interpreter build was fine — the section exists in the binary — the inspector simply could not read the target's memory. Options, from most to least contained:
# 1. The target opts in to being read by any process of the same user (Linux prctl)
import ctypes
PR_SET_PTRACER, PR_SET_PTRACER_ANY = 0x59616D61, ctypes.c_ulong(-1)
ctypes.CDLL(None).prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY, 0, 0, 0)
# 2. Run the inspector with CAP_SYS_PTRACE (root, or a debug container with the capability)
sudo python3.14 -m asyncio ps 3700302
# 3. Relax Yama host-wide (affects every process; usually not acceptable in production)
sudo sysctl kernel.yama.ptrace_scope=0
Verified: with option 1 in the target, the same unprivileged ps command that had failed printed the full table. Opting in only from staging or behind a flag keeps production hosts at the stricter default.
Verify: cat /proc/sys/kernel/yama/ptrace_scope on your hosts, then test the command against a staging process before an incident.
4. Make it work in containers¶
In Kubernetes and Docker, the inspector usually cannot see the target's PID namespace at all. Two arrangements work:
# Kubernetes: an ephemeral debug container sharing the pod's process namespace
kubectl debug -it pod/api-7d9f --image=python:3.14 --target=app \
--profile=general -- python -m asyncio pstree 1
# Docker: run the inspector in the target's PID namespace with the capability
docker run --rm -it --pid=container:api --cap-add=SYS_PTRACE python:3.14 \
python -m asyncio ps 1
The inspector image must have the same Python minor version as the target. PID 1 inside the namespace is usually the app process, but check — a shell entrypoint or an init process may be PID 1, and pointing the tool at the wrapper produces the same misleading error, because a shell has no Python runtime to find. That exact mistake happened while preparing this guide: the first PID tried belonged to a bash wrapper.
Verify: run the command from a debug container in staging and confirm the output names your tasks.
5. Keep an in-process fallback¶
External inspection is the best tool when it is available and the worst one to discover is unavailable mid-incident. Keep a cheap in-process path that uses the same data, on 3.14's print_call_graph:
import asyncio
import signal
import sys
def dump_tasks() -> None:
for task in asyncio.all_tasks():
asyncio.print_call_graph(task, file=sys.stderr)
async def main() -> None:
asyncio.get_running_loop().add_signal_handler(signal.SIGUSR2, dump_tasks)
await serve()
The in-process version needs the loop to be responsive, while ps does not; ps needs ptrace rights, while the signal handler does not. Between them every case is covered. The full set of dumping techniques, including thread dumps for a blocked loop, is in dumping stacks of a hung asyncio program.
Verify: both paths produce matching task lists for the same process.
Verification¶
Task inspection is ready when:
psandpstreerun successfully against a staging process, with the permission model you will use in production.- Tasks are named, so the output identifies handlers, consumers and background jobs.
- Containers have a documented debug path — an ephemeral container or a sidecar with
SYS_PTRACE. - An in-process dump exists for environments where ptrace is unavailable.
Diagnostic Hook: during an incident, pipe ps output through awk to count tasks by their coroutine stack column; the most common stack is usually the bottleneck. Capture the output to the incident record — it is a snapshot of every task's position at that moment, which is exactly what is lost when the process is restarted.
Pitfalls & edge cases¶
- The misleading PyRuntime error. Check ptrace permission and the PID's executable before suspecting the build.
- Mismatched inspector version. Use the same 3.14.x as the target; internal layouts can change between releases.
- Very large task counts. Output for tens of thousands of tasks is large; aggregate rather than read.
- Assuming the snapshot is atomic. Tasks may change while being read; treat rare inconsistencies as noise.
Frequently Asked Questions¶
What does python -m asyncio ps do?
Added in Python 3.14, it reads another running Python process's memory and prints one row per asyncio task: task name and id, its coroutine stack, and the task awaiting it. pstree shows the same data as a tree of awaiting relationships.
Why does asyncio ps say 'Failed to find the PyRuntime section'?
Usually because the inspector is not allowed to read the target's memory, most often Linux's Yama ptrace_scope=1 blocking a non-parent process. Pointing it at a shell wrapper instead of the Python process gives the same error. Grant ptrace rights or use the right PID.
Can I use asyncio ps inside Kubernetes?
Yes, from a container that shares the pod's process namespace and has ptrace rights, such as an ephemeral debug container created with kubectl debug --target. The inspector needs the same Python version as the application.
Does asyncio ps work if the event loop is blocked?
Yes. It reads memory from outside and runs no code in the target, so it works even when the loop is stuck in a synchronous call.
Related¶
- Asyncio Across Python Versions — up to the topic overview.
- Reading await chains with task.get_stack — the in-process API that exposes the same graph.
- Asyncio Fundamentals & Event Loop Architecture — the section overview.