Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ PyStack has the following amazing features:
- 🧵 Shows if each thread currently holds the Python GIL, is waiting to acquire it, or is currently
dropping it.
- 🗑️ Shows if a thread is running a garbage collection cycle.
- 🪆 Reports on multiple interpreters in the same process.
- 🐍 Optionally shows native function calls, as well as Python ones. In this mode, PyStack prints
the native stack trace (C/C++/Rust function calls), except that the calls to Python callables are
replaced with frames showing the Python code being executed, instead of showing the internal C
Expand Down
108 changes: 108 additions & 0 deletions docs/multiple_interpreters.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
.. _multiple-interpreters:

Multiple interpreters
*********************

If your process uses multiple interpreters (for example, using the `concurrent.interpreters` module
or a `concurrent.futures.InterpreterPoolExecutor`), PyStack will show which interpreter each stack
is associated with, and will even show where a thread switches from one interpreter to another.

.. note::
This feature only works for processes running Python 3.11 or newer.

Example program with multiple interpreters
==========================================

For instance, given this Python 3.14 program::

import os
import signal
from concurrent.futures import InterpreterPoolExecutor


def interpreter1_body(read_fd):
print("Hello from interpreter 1!")
open(read_fd, closefd=False).read()
print("Goodbye from interpreter 1!")


def interpreter2_body(read_fd):
print("Greetings from interpreter 2!")
open(read_fd, closefd=False).read()
print("Farewell from interpreter 2!")


read_fd, write_fd = os.pipe()

with InterpreterPoolExecutor(max_workers=2) as executor:
executor.submit(interpreter1_body, read_fd)
executor.submit(interpreter2_body, read_fd)

try:
signal.pause()
except KeyboardInterrupt:
print("\nCtrl-C received, signaling workers to stop...")
os.close(write_fd)
os.close(read_fd)

If you run it until it pauses and then analyze it with PyStack, you will see output like this::

$ pystack remote 392462
Traceback for thread 392464 (InterpreterPool) (most recent call last):
In the main interpreter []
(Python) File "/usr/lib/python3.14/threading.py", line 1044, in _bootstrap
self._bootstrap_inner()
(Python) File "/usr/lib/python3.14/threading.py", line 1082, in _bootstrap_inner
self._context.run(self.run)
(Python) File "/usr/lib/python3.14/threading.py", line 1024, in run
self._target(*self._args, **self._kwargs)
(Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 119, in _worker
work_item.run(ctx)
(Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 86, in run
result = ctx.run(self.task)
(Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 84, in run
return self.interp.call(do_call, self.results, *task)
(Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 238, in call
return self._call(callable, args, kwargs)
(Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 220, in _call
res, excinfo = _interpreters.call(self._id, callable, args, kwargs, restrict=True)
In interpreter 2 []
(Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 11, in do_call
return func(*args, **kwargs)
(Python) File "/tmp/interpreter_demo.py", line 14, in interpreter2_body
open(read_fd, closefd=False).read()

Traceback for thread 392463 (InterpreterPool) (most recent call last):
In the main interpreter []
(Python) File "/usr/lib/python3.14/threading.py", line 1044, in _bootstrap
self._bootstrap_inner()
(Python) File "/usr/lib/python3.14/threading.py", line 1082, in _bootstrap_inner
self._context.run(self.run)
(Python) File "/usr/lib/python3.14/threading.py", line 1024, in run
self._target(*self._args, **self._kwargs)
(Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 119, in _worker
work_item.run(ctx)
(Python) File "/usr/lib/python3.14/concurrent/futures/thread.py", line 86, in run
result = ctx.run(self.task)
(Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 84, in run
return self.interp.call(do_call, self.results, *task)
(Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 238, in call
return self._call(callable, args, kwargs)
(Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 220, in _call
res, excinfo = _interpreters.call(self._id, callable, args, kwargs, restrict=True)
In interpreter 1 []
(Python) File "/usr/lib/python3.14/concurrent/futures/interpreter.py", line 11, in do_call
return func(*args, **kwargs)
(Python) File "/tmp/interpreter_demo.py", line 8, in interpreter1_body
open(read_fd, closefd=False).read()

Traceback for thread 392462 (python3.14) (most recent call last):
In the main interpreter []
(Python) File "/tmp/interpreter_demo.py", line 25, in <module>
signal.pause()

Note that the output now shows which interpreter each thread is running in, and also shows the
transition from the main interpreter to the subinterpreters. The GIL status is shown separately for
each interpreter, as a thread may hold the GIL for one interpreter but not another. Likewise, the GC
status is shown separately, since a thread may be running a GC cycle in one interpreter but not
another.
2 changes: 2 additions & 0 deletions docs/overview.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ PyStack has the following amazing features:
- 🧵 Shows if each thread currently holds the Python GIL, is waiting to acquire it, or is
currently dropping it.
- 🗑️ Shows if a thread is running a garbage collection cycle.
- 🪆 Reports on multiple interpreters in the same process.
- 🐍 Optionally shows native function calls, as well as Python ones. In this mode, PyStack prints
the native stack trace (C/C++/Rust function calls), except that the calls to Python callables are
replaced with frames showing the Python code being executed, instead of showing the internal C
Expand Down Expand Up @@ -42,6 +43,7 @@ Contents

process
corefile
multiple_interpreters
customizing_the_reports

.. toctree::
Expand Down
1 change: 1 addition & 0 deletions news/279.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +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.
4 changes: 2 additions & 2 deletions src/pystack/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from ._version import __version__
from .traceback_formatter import print_thread
from .traceback_formatter import print_threads

__all__ = [
"__version__",
"print_thread",
"print_threads",
]
14 changes: 7 additions & 7 deletions src/pystack/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from pystack.process import is_gzip

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

for thread in get_process_threads(
threads = get_process_threads(
args.pid,
stop_process=args.block,
native_mode=args.native_mode,
locals=args.locals,
method=StackMethod.ALL if args.exhaustive else StackMethod.AUTO,
):
print_thread(thread, args.native_mode)
)
print_threads(threads, args.native_mode)


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

for thread in get_process_threads_for_core(
threads = get_process_threads_for_core(
corefile,
executable,
library_search_path=lib_search_path,
native_mode=args.native_mode,
locals=args.locals,
method=StackMethod.ALL if args.exhaustive else StackMethod.AUTO,
):
print_thread(thread, args.native_mode)
)
print_threads(threads, args.native_mode)


if __name__ == "__main__": # pragma: no cover
Expand Down
6 changes: 6 additions & 0 deletions src/pystack/_pystack.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ def get_process_threads_for_core(
def get_bss_info(binary: Union[str, pathlib.Path]) -> Optional[Dict[str, Any]]: ...
def copy_memory_from_address(pid: int, address: int, size: int) -> bytes: ...
def _check_interpreter_shutdown(manager: ProcessManager) -> None: ...
def is_eval_frame(symbol: str, python_version: Tuple[int, int]) -> bool: ...
def _normalize_threads_for_testing(
thread_descs: List[Dict[str, Any]],
native_mode: NativeReportingMode,
python_version: Tuple[int, int],
) -> List[PyThread]: ...

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

Expand Down
2 changes: 2 additions & 0 deletions src/pystack/_pystack/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ set(PYSTACK_SOURCES
logging.cpp
maps_parser.cpp
mem.cpp
native_frame.cpp
process.cpp
pycode.cpp
pyframe.cpp
Expand All @@ -21,6 +22,7 @@ set(PYSTACK_SOURCES
version.cpp
version_detector.cpp
bindings.cpp
interpreter.cpp
)

# Create the nanobind module
Expand Down
Loading
Loading