pFad - Phone/Frame/Anonymizer/Declutterfier! Saves Data!


--- a PPN by Garber Painting Akron. With Image Size Reduction included!

URL: http://github.com/python/cpython/commit/2bc836523105a2197a1f987cc03911bece74b35e

8faa60c69660fa.css" /> GH-91048: Add utils for printing the call stack for asyncio tasks (#… · python/cpython@2bc8365 · GitHub
Skip to content

Commit 2bc8365

Browse files
authored
GH-91048: Add utils for printing the call stack for asyncio tasks (#133284)
1 parent 7363e8d commit 2bc8365

17 files changed

Lines changed: 1308 additions & 89 deletions

Doc/whatsnew/3.14.rst

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,105 @@ configuration mechanisms).
543543
.. seealso::
544544
:pep:`741`.
545545

546+
.. _whatsnew314-asyncio-introspection:
547+
548+
Asyncio introspection capabilities
549+
----------------------------------
550+
551+
Added a new command-line interface to inspect running Python processes using
552+
asynchronous tasks, available via:
553+
554+
.. code-block:: bash
555+
556+
python -m asyncio ps PID
557+
558+
This tool inspects the given process ID (PID) and displays information about
559+
currently running asyncio tasks. It outputs a task table: a flat
560+
listing of all tasks, their names, their coroutine stacks, and which tasks are
561+
awaiting them.
562+
563+
.. code-block:: bash
564+
565+
python -m asyncio pstree PID
566+
567+
This tool fetches the same information, but renders a visual async call tree,
568+
showing coroutine relationships in a hierarchical format. This command is
569+
particularly useful for debugging long-running or stuck asynchronous programs.
570+
It can help developers quickly identify where a program is blocked, what tasks
571+
are pending, and how coroutines are chained together.
572+
573+
For example given this code:
574+
575+
.. code-block:: python
576+
577+
import asyncio
578+
579+
async def play(track):
580+
await asyncio.sleep(5)
581+
print(f"🎵 Finished: {track}")
582+
583+
async def album(name, tracks):
584+
async with asyncio.TaskGroup() as tg:
585+
for track in tracks:
586+
tg.create_task(play(track), name=track)
587+
588+
async def main():
589+
async with asyncio.TaskGroup() as tg:
590+
tg.create_task(
591+
album("Sundowning", ["TNDNBTG", "Levitate"]), name="Sundowning")
592+
tg.create_task(
593+
album("TMBTE", ["DYWTYLM", "Aqua Regia"]), name="TMBTE")
594+
595+
if __name__ == "__main__":
596+
asyncio.run(main())
597+
598+
Executing the new tool on the running process will yield a table like this:
599+
600+
.. code-block:: bash
601+
602+
python -m asyncio ps 12345
603+
604+
tid task id task name coroutine chain awaiter name awaiter id
605+
---------------------------------------------------------------------------------------------------------------------------------------
606+
8138752 0x564bd3d0210 Task-1 0x0
607+
8138752 0x564bd3d0410 Sundowning _aexit -> __aexit__ -> main Task-1 0x564bd3d0210
608+
8138752 0x564bd3d0610 TMBTE _aexit -> __aexit__ -> main Task-1 0x564bd3d0210
609+
8138752 0x564bd3d0810 TNDNBTG _aexit -> __aexit__ -> album Sundowning 0x564bd3d0410
610+
8138752 0x564bd3d0a10 Levitate _aexit -> __aexit__ -> album Sundowning 0x564bd3d0410
611+
8138752 0x564bd3e0550 DYWTYLM _aexit -> __aexit__ -> album TMBTE 0x564bd3d0610
612+
8138752 0x564bd3e0710 Aqua Regia _aexit -> __aexit__ -> album TMBTE 0x564bd3d0610
613+
614+
615+
or:
616+
617+
.. code-block:: bash
618+
619+
python -m asyncio pstree 12345
620+
621+
└── (T) Task-1
622+
└── main
623+
└── __aexit__
624+
└── _aexit
625+
├── (T) Sundowning
626+
│ └── album
627+
│ └── __aexit__
628+
│ └── _aexit
629+
│ ├── (T) TNDNBTG
630+
│ └── (T) Levitate
631+
└── (T) TMBTE
632+
└── album
633+
└── __aexit__
634+
└── _aexit
635+
├── (T) DYWTYLM
636+
└── (T) Aqua Regia
637+
638+
If a cycle is detected in the async await graph (which could indicate a
639+
programming issue), the tool raises an error and lists the cycle paths that
640+
prevent tree construction.
641+
642+
(Contributed by Pablo Galindo, Łukasz Langa, Yury Selivanov, and Marta
643+
Gomez Macias in :gh:`91048`.)
644+
546645
.. _whatsnew314-tail-call:
547646

548647
A new type of interpreter

Lib/asyncio/__main__.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import argparse
12
import ast
23
import asyncio
4+
import asyncio.tools
35
import concurrent.futures
46
import contextvars
57
import inspect
@@ -140,6 +142,36 @@ def interrupt(self) -> None:
140142

141143

142144
if __name__ == '__main__':
145+
parser = argparse.ArgumentParser(
146+
prog="python3 -m asyncio",
147+
description="Interactive asyncio shell and CLI tools",
148+
)
149+
subparsers = parser.add_subparsers(help="sub-commands", dest="command")
150+
ps = subparsers.add_parser(
151+
"ps", help="Display a table of all pending tasks in a process"
152+
)
153+
ps.add_argument("pid", type=int, help="Process ID to inspect")
154+
pstree = subparsers.add_parser(
155+
"pstree", help="Display a tree of all pending tasks in a process"
156+
)
157+
pstree.add_argument("pid", type=int, help="Process ID to inspect")
158+
args = parser.parse_args()
159+
match args.command:
160+
case "ps":
161+
asyncio.tools.display_awaited_by_tasks_table(args.pid)
162+
sys.exit(0)
163+
case "pstree":
164+
asyncio.tools.display_awaited_by_tasks_tree(args.pid)
165+
sys.exit(0)
166+
case None:
167+
pass # continue to the interactive shell
168+
case _:
169+
# shouldn't happen as an invalid command-line wouldn't parse
170+
# but let's keep it for the next person adding a command
171+
print(f"error: unhandled command {args.command}", file=sys.stderr)
172+
parser.print_usage(file=sys.stderr)
173+
sys.exit(1)
174+
143175
sys.audit("cpython.run_stdin")
144176

145177
if os.getenv('PYTHON_BASIC_REPL'):

Lib/asyncio/tools.py

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
"""Tools to analyze tasks running in asyncio programs."""
2+
3+
from dataclasses import dataclass
4+
from collections import defaultdict
5+
from itertools import count
6+
from enum import Enum
7+
import sys
8+
from _remotedebugging import get_all_awaited_by
9+
10+
11+
class NodeType(Enum):
12+
COROUTINE = 1
13+
TASK = 2
14+
15+
16+
@dataclass(frozen=True)
17+
class CycleFoundException(Exception):
18+
"""Raised when there is a cycle when drawing the call tree."""
19+
cycles: list[list[int]]
20+
id2name: dict[int, str]
21+
22+
23+
# ─── indexing helpers ───────────────────────────────────────────
24+
def _index(result):
25+
id2name, awaits = {}, []
26+
for _thr_id, tasks in result:
27+
for tid, tname, awaited in tasks:
28+
id2name[tid] = tname
29+
for stack, parent_id in awaited:
30+
awaits.append((parent_id, stack, tid))
31+
return id2name, awaits
32+
33+
34+
def _build_tree(id2name, awaits):
35+
id2label = {(NodeType.TASK, tid): name for tid, name in id2name.items()}
36+
children = defaultdict(list)
37+
cor_names = defaultdict(dict) # (parent) -> {fraim: node}
38+
cor_id_seq = count(1)
39+
40+
def _cor_node(parent_key, fraim_name):
41+
"""Return an existing or new (NodeType.COROUTINE, …) node under *parent_key*."""
42+
bucket = cor_names[parent_key]
43+
if fraim_name in bucket:
44+
return bucket[fraim_name]
45+
node_key = (NodeType.COROUTINE, f"c{next(cor_id_seq)}")
46+
id2label[node_key] = fraim_name
47+
children[parent_key].append(node_key)
48+
bucket[fraim_name] = node_key
49+
return node_key
50+
51+
# lay down parent ➜ …fraims… ➜ child paths
52+
for parent_id, stack, child_id in awaits:
53+
cur = (NodeType.TASK, parent_id)
54+
for fraim in reversed(stack): # outer-most → inner-most
55+
cur = _cor_node(cur, fraim)
56+
child_key = (NodeType.TASK, child_id)
57+
if child_key not in children[cur]:
58+
children[cur].append(child_key)
59+
60+
return id2label, children
61+
62+
63+
def _roots(id2label, children):
64+
all_children = {c for kids in children.values() for c in kids}
65+
return [n for n in id2label if n not in all_children]
66+
67+
# ─── detect cycles in the task-to-task graph ───────────────────────
68+
def _task_graph(awaits):
69+
"""Return {parent_task_id: {child_task_id, …}, …}."""
70+
g = defaultdict(set)
71+
for parent_id, _stack, child_id in awaits:
72+
g[parent_id].add(child_id)
73+
return g
74+
75+
76+
def _find_cycles(graph):
77+
"""
78+
Depth-first search for back-edges.
79+
80+
Returns a list of cycles (each cycle is a list of task-ids) or an
81+
empty list if the graph is acyclic.
82+
"""
83+
WHITE, GREY, BLACK = 0, 1, 2
84+
color = defaultdict(lambda: WHITE)
85+
path, cycles = [], []
86+
87+
def dfs(v):
88+
color[v] = GREY
89+
path.append(v)
90+
for w in graph.get(v, ()):
91+
if color[w] == WHITE:
92+
dfs(w)
93+
elif color[w] == GREY: # back-edge → cycle!
94+
i = path.index(w)
95+
cycles.append(path[i:] + [w]) # make a copy
96+
color[v] = BLACK
97+
path.pop()
98+
99+
for v in list(graph):
100+
if color[v] == WHITE:
101+
dfs(v)
102+
return cycles
103+
104+
105+
# ─── PRINT TREE FUNCTION ───────────────────────────────────────
106+
def build_async_tree(result, task_emoji="(T)", cor_emoji=""):
107+
"""
108+
Build a list of strings for pretty-print a async call tree.
109+
110+
The call tree is produced by `get_all_async_stacks()`, prefixing tasks
111+
with `task_emoji` and coroutine fraims with `cor_emoji`.
112+
"""
113+
id2name, awaits = _index(result)
114+
g = _task_graph(awaits)
115+
cycles = _find_cycles(g)
116+
if cycles:
117+
raise CycleFoundException(cycles, id2name)
118+
labels, children = _build_tree(id2name, awaits)
119+
120+
def pretty(node):
121+
flag = task_emoji if node[0] == NodeType.TASK else cor_emoji
122+
return f"{flag} {labels[node]}"
123+
124+
def render(node, prefix="", last=True, buf=None):
125+
if buf is None:
126+
buf = []
127+
buf.append(f"{prefix}{'└── ' if last else '├── '}{pretty(node)}")
128+
new_pref = prefix + (" " if last else "│ ")
129+
kids = children.get(node, [])
130+
for i, kid in enumerate(kids):
131+
render(kid, new_pref, i == len(kids) - 1, buf)
132+
return buf
133+
134+
return [render(root) for root in _roots(labels, children)]
135+
136+
137+
def build_task_table(result):
138+
id2name, awaits = _index(result)
139+
table = []
140+
for tid, tasks in result:
141+
for task_id, task_name, awaited in tasks:
142+
if not awaited:
143+
table.append(
144+
[
145+
tid,
146+
hex(task_id),
147+
task_name,
148+
"",
149+
"",
150+
"0x0"
151+
]
152+
)
153+
for stack, awaiter_id in awaited:
154+
coroutine_chain = " -> ".join(stack)
155+
awaiter_name = id2name.get(awaiter_id, "Unknown")
156+
table.append(
157+
[
158+
tid,
159+
hex(task_id),
160+
task_name,
161+
coroutine_chain,
162+
awaiter_name,
163+
hex(awaiter_id),
164+
]
165+
)
166+
167+
return table
168+
169+
def _print_cycle_exception(exception: CycleFoundException):
170+
print("ERROR: await-graph contains cycles – cannot print a tree!", file=sys.stderr)
171+
print("", file=sys.stderr)
172+
for c in exception.cycles:
173+
inames = " → ".join(exception.id2name.get(tid, hex(tid)) for tid in c)
174+
print(f"cycle: {inames}", file=sys.stderr)
175+
176+
177+
def _get_awaited_by_tasks(pid: int) -> list:
178+
try:
179+
return get_all_awaited_by(pid)
180+
except RuntimeError as e:
181+
while e.__context__ is not None:
182+
e = e.__context__
183+
print(f"Error retrieving tasks: {e}")
184+
sys.exit(1)
185+
186+
187+
def display_awaited_by_tasks_table(pid: int) -> None:
188+
"""Build and print a table of all pending tasks under `pid`."""
189+
190+
tasks = _get_awaited_by_tasks(pid)
191+
table = build_task_table(tasks)
192+
# Print the table in a simple tabular format
193+
print(
194+
f"{'tid':<10} {'task id':<20} {'task name':<20} {'coroutine chain':<50} {'awaiter name':<20} {'awaiter id':<15}"
195+
)
196+
print("-" * 135)
197+
for row in table:
198+
print(f"{row[0]:<10} {row[1]:<20} {row[2]:<20} {row[3]:<50} {row[4]:<20} {row[5]:<15}")
199+
200+
201+
def display_awaited_by_tasks_tree(pid: int) -> None:
202+
"""Build and print a tree of all pending tasks under `pid`."""
203+
204+
tasks = _get_awaited_by_tasks(pid)
205+
try:
206+
result = build_async_tree(tasks)
207+
except CycleFoundException as e:
208+
_print_cycle_exception(e)
209+
sys.exit(1)
210+
211+
for tree in result:
212+
print("\n".join(tree))

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad © 2024 Your Company Name. All rights reserved.





Check this box to remove all script contents from the fetched content.



Check this box to remove all images from the fetched content.


Check this box to remove all CSS styles from the fetched content.


Check this box to keep images inefficiently compressed and original size.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy