From a270317becadb92e680e8ebaa5988ffc0da72bb6 Mon Sep 17 00:00:00 2001 From: Lysandros Nikolaou Date: Thu, 26 Feb 2026 14:31:21 +0100 Subject: [PATCH] gh-145254: Add thread safety annotation - Implement annotation for different thread safety levels - Annotate PyMutex_* APIs --- Doc/conf.py | 1 + Doc/data/threadsafety.dat | 17 ++++++ Doc/glossary.rst | 13 +++++ Doc/tools/extensions/c_annotations.py | 78 +++++++++++++++++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 Doc/data/threadsafety.dat diff --git a/Doc/conf.py b/Doc/conf.py index d7effe2572ec44..ce83c689b6c305 100644 --- a/Doc/conf.py +++ b/Doc/conf.py @@ -566,6 +566,7 @@ # Relative filename of the data files refcount_file = 'data/refcounts.dat' stable_abi_file = 'data/stable_abi.dat' +threadsafety_file = 'data/threadsafety.dat' # Options for sphinxext-opengraph # ------------------------------- diff --git a/Doc/data/threadsafety.dat b/Doc/data/threadsafety.dat new file mode 100644 index 00000000000000..19f5bc0bedf49b --- /dev/null +++ b/Doc/data/threadsafety.dat @@ -0,0 +1,17 @@ +# Thread safety annotations for C API functions. +# +# Each line has the form: +# function_name : level +# +# Where level is one of: +# incompatible -- not safe even with external locking +# compatible -- safe if the caller serializes all access with external locks +# safe -- safe for concurrent use +# +# Lines beginning with '#' are ignored. +# The function name must match the C domain identifier used in the documentation. + +# Synchronization primitives (Doc/c-api/synchronization.rst) +PyMutex_Lock:safe: +PyMutex_Unlock:safe: +PyMutex_IsLocked:safe: diff --git a/Doc/glossary.rst b/Doc/glossary.rst index 6151143a97b420..e8f0eea5d5d0db 100644 --- a/Doc/glossary.rst +++ b/Doc/glossary.rst @@ -1587,6 +1587,19 @@ Glossary See :ref:`Thread State and the Global Interpreter Lock ` for more information. + thread-compatible + A function or operation that is safe to call from multiple threads + provided the caller supplies appropriate external synchronization, for + example by holding a :term:`lock` for the duration of each call. Without + such synchronization, concurrent calls may produce :term:`race conditions + ` or :term:`data races `. + + thread-incompatible + A function or operation that cannot be made safe for concurrent use even + with external synchronization. Thread-incompatible code typically + accesses global state in an unsynchronized way and must be called from + only one thread at a time throughout the program's lifetime. + thread-safe A module, function, or class that behaves correctly when used by multiple threads concurrently. Thread-safe code uses appropriate diff --git a/Doc/tools/extensions/c_annotations.py b/Doc/tools/extensions/c_annotations.py index e04a5f144c449b..22d059750bacbf 100644 --- a/Doc/tools/extensions/c_annotations.py +++ b/Doc/tools/extensions/c_annotations.py @@ -3,10 +3,12 @@ * Reference count annotations for C API functions. * Stable ABI annotations * Limited API annotations +* Thread safety annotations for C API functions. Configuration: * Set ``refcount_file`` to the path to the reference count data file. * Set ``stable_abi_file`` to the path to stable ABI list. +* Set ``threadsafety_file`` to the path to the thread safety data file. """ from __future__ import annotations @@ -48,6 +50,15 @@ class RefCountEntry: result_refs: int | None = None +@dataclasses.dataclass(frozen=True, slots=True) +class ThreadSafetyEntry: + # Name of the function. + name: str + # Thread safety level. + # One of: 'incompatible', 'compatible', 'safe'. + level: str + + @dataclasses.dataclass(frozen=True, slots=True) class StableABIEntry: # Role of the object. @@ -113,10 +124,38 @@ def read_stable_abi_data(stable_abi_file: Path) -> dict[str, StableABIEntry]: return stable_abi_data +_VALID_THREADSAFETY_LEVELS = frozenset({ + "incompatible", + "compatible", + "safe", +}) + + +def read_threadsafety_data(threadsafety_filename: Path) -> dict[str, ThreadSafetyEntry]: + threadsafety_data = {} + for line in threadsafety_filename.read_text(encoding="utf8").splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + # Each line is of the form: function_name : level : [comment] + parts = line.split(":", 2) + if len(parts) < 2: + raise ValueError(f"Wrong field count in {line!r}") + name, level = parts[0].strip(), parts[1].strip() + if level not in _VALID_THREADSAFETY_LEVELS: + raise ValueError( + f"Unknown thread safety level {level!r} for {name!r}. " + f"Valid levels: {sorted(_VALID_THREADSAFETY_LEVELS)}" + ) + threadsafety_data[name] = ThreadSafetyEntry(name=name, level=level) + return threadsafety_data + + def add_annotations(app: Sphinx, doctree: nodes.document) -> None: state = app.env.domaindata["c_annotations"] refcount_data = state["refcount_data"] stable_abi_data = state["stable_abi_data"] + threadsafety_data = state["threadsafety_data"] for node in doctree.findall(addnodes.desc_content): par = node.parent if par["domain"] != "c": @@ -126,6 +165,12 @@ def add_annotations(app: Sphinx, doctree: nodes.document) -> None: name = par[0]["ids"][0].removeprefix("c.") objtype = par["objtype"] + # Thread safety annotation — inserted first so it appears last (bottom-most) + # among all annotations. + if entry := threadsafety_data.get(name): + annotation = _threadsafety_annotation(entry.level) + node.insert(0, annotation) + # Stable ABI annotation. if record := stable_abi_data.get(name): if ROLE_TO_OBJECT_TYPE[record.role] != objtype: @@ -256,6 +301,35 @@ def _unstable_api_annotation() -> nodes.admonition: ) +_THREADSAFETY_DISPLAY = { + "incompatible": "Not safe to call from multiple threads.", + "compatible": "Safe to call from multiple threads with external synchronization only.", + "safe": "Safe for concurrent use.", +} + +# Maps each thread safety level to the glossary term it should link to. +_THREADSAFETY_TERM = { + "incompatible": "thread-incompatible", + "compatible": "thread-compatible", + "safe": "thread-safe", +} + + +def _threadsafety_annotation(level: str) -> nodes.emphasis: + display = sphinx_gettext(_THREADSAFETY_DISPLAY[level]) + ref_node = addnodes.pending_xref( + display, + nodes.Text(display), + refdomain="std", + reftarget=_THREADSAFETY_TERM[level], + reftype="term", + refexplicit="True", + ) + prefix = sphinx_gettext("Thread safety:") + " " + classes = [f"threadsafety-{level}"] + return nodes.emphasis("", prefix, ref_node, classes=classes) + + def _return_value_annotation(result_refs: int | None) -> nodes.emphasis: classes = ["refcount"] if result_refs is None: @@ -342,11 +416,15 @@ def init_annotations(app: Sphinx) -> None: state["stable_abi_data"] = read_stable_abi_data( Path(app.srcdir, app.config.stable_abi_file) ) + state["threadsafety_data"] = read_threadsafety_data( + Path(app.srcdir, app.config.threadsafety_file) + ) def setup(app: Sphinx) -> ExtensionMetadata: app.add_config_value("refcount_file", "", "env", types={str}) app.add_config_value("stable_abi_file", "", "env", types={str}) + app.add_config_value("threadsafety_file", "", "env", types={str}) app.add_directive("limited-api-list", LimitedAPIList) app.add_directive("corresponding-type-slot", CorrespondingTypeSlot) app.connect("builder-inited", init_annotations)