Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit2bc8365

Browse files
authored
GH-91048: Add utils for printing the call stack for asyncio tasks (#133284)
1 parent7363e8d commit2bc8365

17 files changed

+1308
-89
lines changed

‎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+
asyncdefplay(track):
580+
await asyncio.sleep(5)
581+
print(f"🎵 Finished:{track}")
582+
583+
asyncdefalbum(name,tracks):
584+
asyncwith asyncio.TaskGroup()as tg:
585+
for trackin tracks:
586+
tg.create_task(play(track),name=track)
587+
588+
asyncdefmain():
589+
asyncwith 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+
importargparse
12
importast
23
importasyncio
4+
importasyncio.tools
35
importconcurrent.futures
46
importcontextvars
57
importinspect
@@ -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+
matchargs.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+
caseNone:
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
ifos.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+
fromdataclassesimportdataclass
4+
fromcollectionsimportdefaultdict
5+
fromitertoolsimportcount
6+
fromenumimportEnum
7+
importsys
8+
from_remotedebuggingimportget_all_awaited_by
9+
10+
11+
classNodeType(Enum):
12+
COROUTINE=1
13+
TASK=2
14+
15+
16+
@dataclass(frozen=True)
17+
classCycleFoundException(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,tasksinresult:
27+
fortid,tname,awaitedintasks:
28+
id2name[tid]=tname
29+
forstack,parent_idinawaited:
30+
awaits.append((parent_id,stack,tid))
31+
returnid2name,awaits
32+
33+
34+
def_build_tree(id2name,awaits):
35+
id2label= {(NodeType.TASK,tid):namefortid,nameinid2name.items()}
36+
children=defaultdict(list)
37+
cor_names=defaultdict(dict)# (parent) -> {frame: node}
38+
cor_id_seq=count(1)
39+
40+
def_cor_node(parent_key,frame_name):
41+
"""Return an existing or new (NodeType.COROUTINE, …) node under *parent_key*."""
42+
bucket=cor_names[parent_key]
43+
ifframe_nameinbucket:
44+
returnbucket[frame_name]
45+
node_key= (NodeType.COROUTINE,f"c{next(cor_id_seq)}")
46+
id2label[node_key]=frame_name
47+
children[parent_key].append(node_key)
48+
bucket[frame_name]=node_key
49+
returnnode_key
50+
51+
# lay down parent ➜ …frames… ➜ child paths
52+
forparent_id,stack,child_idinawaits:
53+
cur= (NodeType.TASK,parent_id)
54+
forframeinreversed(stack):# outer-most → inner-most
55+
cur=_cor_node(cur,frame)
56+
child_key= (NodeType.TASK,child_id)
57+
ifchild_keynotinchildren[cur]:
58+
children[cur].append(child_key)
59+
60+
returnid2label,children
61+
62+
63+
def_roots(id2label,children):
64+
all_children= {cforkidsinchildren.values()forcinkids}
65+
return [nforninid2labelifnnotinall_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+
forparent_id,_stack,child_idinawaits:
72+
g[parent_id].add(child_id)
73+
returng
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+
defdfs(v):
88+
color[v]=GREY
89+
path.append(v)
90+
forwingraph.get(v, ()):
91+
ifcolor[w]==WHITE:
92+
dfs(w)
93+
elifcolor[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+
forvinlist(graph):
100+
ifcolor[v]==WHITE:
101+
dfs(v)
102+
returncycles
103+
104+
105+
# ─── PRINT TREE FUNCTION ───────────────────────────────────────
106+
defbuild_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 frames with `cor_emoji`.
112+
"""
113+
id2name,awaits=_index(result)
114+
g=_task_graph(awaits)
115+
cycles=_find_cycles(g)
116+
ifcycles:
117+
raiseCycleFoundException(cycles,id2name)
118+
labels,children=_build_tree(id2name,awaits)
119+
120+
defpretty(node):
121+
flag=task_emojiifnode[0]==NodeType.TASKelsecor_emoji
122+
returnf"{flag}{labels[node]}"
123+
124+
defrender(node,prefix="",last=True,buf=None):
125+
ifbufisNone:
126+
buf= []
127+
buf.append(f"{prefix}{'└── 'iflastelse'├── '}{pretty(node)}")
128+
new_pref=prefix+ (" "iflastelse"│ ")
129+
kids=children.get(node, [])
130+
fori,kidinenumerate(kids):
131+
render(kid,new_pref,i==len(kids)-1,buf)
132+
returnbuf
133+
134+
return [render(root)forrootin_roots(labels,children)]
135+
136+
137+
defbuild_task_table(result):
138+
id2name,awaits=_index(result)
139+
table= []
140+
fortid,tasksinresult:
141+
fortask_id,task_name,awaitedintasks:
142+
ifnotawaited:
143+
table.append(
144+
[
145+
tid,
146+
hex(task_id),
147+
task_name,
148+
"",
149+
"",
150+
"0x0"
151+
]
152+
)
153+
forstack,awaiter_idinawaited:
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+
returntable
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+
forcinexception.cycles:
173+
inames=" → ".join(exception.id2name.get(tid,hex(tid))fortidinc)
174+
print(f"cycle:{inames}",file=sys.stderr)
175+
176+
177+
def_get_awaited_by_tasks(pid:int)->list:
178+
try:
179+
returnget_all_awaited_by(pid)
180+
exceptRuntimeErrorase:
181+
whilee.__context__isnotNone:
182+
e=e.__context__
183+
print(f"Error retrieving tasks:{e}")
184+
sys.exit(1)
185+
186+
187+
defdisplay_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+
forrowintable:
198+
print(f"{row[0]:<10}{row[1]:<20}{row[2]:<20}{row[3]:<50}{row[4]:<20}{row[5]:<15}")
199+
200+
201+
defdisplay_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+
exceptCycleFoundExceptionase:
208+
_print_cycle_exception(e)
209+
sys.exit(1)
210+
211+
fortreeinresult:
212+
print("\n".join(tree))

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp