Skip to content

Commit 8394c63

Browse files
scopreongodlygeek
andauthored
Add subinterpreter support
Add support for Python subinterpreters. When a process uses multiple interpreters (e.g. via Python 3.14's `concurrent.interpreters` module or `concurrent.futures.InterpreterPoolExecutor`), stacks for all interpreters are now reported instead of just the main one. This is supported both for Python stacks and for hybrid Python + native stacks. Signed-off-by: Saul Cooperman <saulcoops@gmail.com> Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net> Co-authored-by: Matt Wozniski <mwozniski@bloomberg.net>
1 parent 0e1b1ee commit 8394c63

36 files changed

Lines changed: 1530 additions & 164 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ PyStack has the following amazing features:
2828
- 🧵 Shows if each thread currently holds the Python GIL, is waiting to acquire it, or is currently
2929
dropping it.
3030
- 🗑️ Shows if a thread is running a garbage collection cycle.
31+
- 🪆 Reports on multiple interpreters in the same process.
3132
- 🐍 Optionally shows native function calls, as well as Python ones. In this mode, PyStack prints
3233
the native stack trace (C/C++/Rust function calls), except that the calls to Python callables are
3334
replaced with frames showing the Python code being executed, instead of showing the internal C

docs/multiple_interpreters.rst

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
.. _multiple-interpreters:
2+
3+
Multiple interpreters
4+
*********************
5+
6+
If your process uses multiple interpreters (for example, using the `concurrent.interpreters` module
7+
or a `concurrent.futures.InterpreterPoolExecutor`), PyStack will show which interpreter each stack
8+
is associated with, and will even show where a thread switches from one interpreter to another.
9+
10+
.. note::
11+
This feature only works for processes running Python 3.11 or newer.
12+
13+
Example program with multiple interpreters
14+
==========================================
15+
16+
For instance, given this Python 3.14 program::
17+
18+
import os
19+
import signal
20+
from concurrent.futures import InterpreterPoolExecutor
21+
22+
23+
def interpreter1_body(read_fd):
24+
print("Hello from interpreter 1!")
25+
open(read_fd, closefd=False).read()
26+
print("Goodbye from interpreter 1!")
27+
28+
29+
def interpreter2_body(read_fd):
30+
print("Greetings from interpreter 2!")
31+
open(read_fd, closefd=False).read()
32+
print("Farewell from interpreter 2!")
33+
34+
35+
read_fd, write_fd = os.pipe()
36+
37+
with InterpreterPoolExecutor(max_workers=2) as executor:
38+
executor.submit(interpreter1_body, read_fd)
39+
executor.submit(interpreter2_body, read_fd)
40+
41+
try:
42+
signal.pause()
43+
except KeyboardInterrupt:
44+
print("\nCtrl-C received, signaling workers to stop...")
45+
os.close(write_fd)
46+
os.close(read_fd)
47+
48+
If you run it until it pauses and then analyze it with PyStack, you will see output like this::
49+
50+
$ pystack remote 392462
51+
Traceback for thread 392464 (InterpreterPool) (most recent call last):
52+
In the main interpreter []
53+
(Python) File "/usr/lib/python3.14/threading.py", line 1044, in _bootstrap
54+
self._bootstrap_inner()
55+
(Python) File "/usr/lib/python3.14/threading.py", line 1082, in _bootstrap_inner
56+
self._context.run(self.run)
57+
(Python) File "/usr/lib/python3.14/threading.py", line 1024, in run
58+
self._target(*self._args, **self._kwargs)
59+
(Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 119, in _worker
60+
work_item.run(ctx)
61+
(Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 86, in run
62+
result = ctx.run(self.task)
63+
(Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 84, in run
64+
return self.interp.call(do_call, self.results, *task)
65+
(Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 238, in call
66+
return self._call(callable, args, kwargs)
67+
(Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 220, in _call
68+
res, excinfo = _interpreters.call(self._id, callable, args, kwargs, restrict=True)
69+
In interpreter 2 []
70+
(Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 11, in do_call
71+
return func(*args, **kwargs)
72+
(Python) File "/tmp/interpreter_demo.py", line 14, in interpreter2_body
73+
open(read_fd, closefd=False).read()
74+
75+
Traceback for thread 392463 (InterpreterPool) (most recent call last):
76+
In the main interpreter []
77+
(Python) File "/usr/lib/python3.14/threading.py", line 1044, in _bootstrap
78+
self._bootstrap_inner()
79+
(Python) File "/usr/lib/python3.14/threading.py", line 1082, in _bootstrap_inner
80+
self._context.run(self.run)
81+
(Python) File "/usr/lib/python3.14/threading.py", line 1024, in run
82+
self._target(*self._args, **self._kwargs)
83+
(Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 119, in _worker
84+
work_item.run(ctx)
85+
(Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 86, in run
86+
result = ctx.run(self.task)
87+
(Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 84, in run
88+
return self.interp.call(do_call, self.results, *task)
89+
(Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 238, in call
90+
return self._call(callable, args, kwargs)
91+
(Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 220, in _call
92+
res, excinfo = _interpreters.call(self._id, callable, args, kwargs, restrict=True)
93+
In interpreter 1 []
94+
(Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 11, in do_call
95+
return func(*args, **kwargs)
96+
(Python) File "/tmp/interpreter_demo.py", line 8, in interpreter1_body
97+
open(read_fd, closefd=False).read()
98+
99+
Traceback for thread 392462 (python3.14) (most recent call last):
100+
In the main interpreter []
101+
(Python) File "/tmp/interpreter_demo.py", line 25, in <module>
102+
signal.pause()
103+
104+
Note that the output now shows which interpreter each thread is running in, and also shows the
105+
transition from the main interpreter to the subinterpreters. The GIL status is shown separately for
106+
each interpreter, as a thread may hold the GIL for one interpreter but not another. Likewise, the GC
107+
status is shown separately, since a thread may be running a GC cycle in one interpreter but not
108+
another.

docs/overview.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ PyStack has the following amazing features:
1414
- 🧵 Shows if each thread currently holds the Python GIL, is waiting to acquire it, or is
1515
currently dropping it.
1616
- 🗑️ Shows if a thread is running a garbage collection cycle.
17+
- 🪆 Reports on multiple interpreters in the same process.
1718
- 🐍 Optionally shows native function calls, as well as Python ones. In this mode, PyStack prints
1819
the native stack trace (C/C++/Rust function calls), except that the calls to Python callables are
1920
replaced with frames showing the Python code being executed, instead of showing the internal C
@@ -42,6 +43,7 @@ Contents
4243

4344
process
4445
corefile
46+
multiple_interpreters
4547
customizing_the_reports
4648

4749
.. toctree::

news/279.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add :ref:`support for Python subinterpreters <multiple-interpreters>`. When a process uses multiple interpreters (e.g. via Python 3.14's `concurrent.interpreters` module), stacks for all interpreters are now reported instead of just the main one.

src/pystack/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from ._version import __version__
2-
from .traceback_formatter import print_thread
2+
from .traceback_formatter import print_threads
33

44
__all__ = [
55
"__version__",
6-
"print_thread",
6+
"print_threads",
77
]

src/pystack/__main__.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from pystack.process import is_gzip
2020

2121
from . import errors
22-
from . import print_thread
22+
from . import print_threads
2323
from .colors import colored
2424
from .engine import CoreFileAnalyzer
2525
from .engine import NativeReportingMode
@@ -287,14 +287,14 @@ def process_remote(parser: argparse.ArgumentParser, args: argparse.Namespace) ->
287287
if not args.block and args.native_mode != NativeReportingMode.OFF:
288288
parser.error("Native traces are only available in blocking mode")
289289

290-
for thread in get_process_threads(
290+
threads = get_process_threads(
291291
args.pid,
292292
stop_process=args.block,
293293
native_mode=args.native_mode,
294294
locals=args.locals,
295295
method=StackMethod.ALL if args.exhaustive else StackMethod.AUTO,
296-
):
297-
print_thread(thread, args.native_mode)
296+
)
297+
print_threads(threads, args.native_mode)
298298

299299

300300
def format_psinfo_information(psinfo: Dict[str, Any]) -> str:
@@ -414,15 +414,15 @@ def process_core(parser: argparse.ArgumentParser, args: argparse.Namespace) -> N
414414
elf_id if elf_id else "<MISSING>",
415415
)
416416

417-
for thread in get_process_threads_for_core(
417+
threads = get_process_threads_for_core(
418418
corefile,
419419
executable,
420420
library_search_path=lib_search_path,
421421
native_mode=args.native_mode,
422422
locals=args.locals,
423423
method=StackMethod.ALL if args.exhaustive else StackMethod.AUTO,
424-
):
425-
print_thread(thread, args.native_mode)
424+
)
425+
print_threads(threads, args.native_mode)
426426

427427

428428
if __name__ == "__main__": # pragma: no cover

src/pystack/_pystack.pyi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ def get_process_threads_for_core(
8484
def get_bss_info(binary: Union[str, pathlib.Path]) -> Optional[Dict[str, Any]]: ...
8585
def copy_memory_from_address(pid: int, address: int, size: int) -> bytes: ...
8686
def _check_interpreter_shutdown(manager: ProcessManager) -> None: ...
87+
def is_eval_frame(symbol: str, python_version: Tuple[int, int]) -> bool: ...
88+
def _normalize_threads_for_testing(
89+
thread_descs: List[Dict[str, Any]],
90+
native_mode: NativeReportingMode,
91+
python_version: Tuple[int, int],
92+
) -> List[PyThread]: ...
8793

8894
F = TypeVar("F", bound=Callable[..., Any])
8995

src/pystack/_pystack/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ set(PYSTACK_SOURCES
1111
logging.cpp
1212
maps_parser.cpp
1313
mem.cpp
14+
native_frame.cpp
1415
process.cpp
1516
pycode.cpp
1617
pyframe.cpp
@@ -21,6 +22,7 @@ set(PYSTACK_SOURCES
2122
version.cpp
2223
version_detector.cpp
2324
bindings.cpp
25+
interpreter.cpp
2426
)
2527

2628
# Create the nanobind module

0 commit comments

Comments
 (0)