gh-145254: Add thread safety annotation in docs (#145255)

This commit is contained in:
Lysandros Nikolaou 2026-03-12 07:48:43 +01:00 committed by GitHub
parent d19de375a2
commit 0dce4c6eab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 195 additions and 0 deletions

View file

@ -569,6 +569,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
# -------------------------------

19
Doc/data/threadsafety.dat Normal file
View file

@ -0,0 +1,19 @@
# 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
# distinct -- safe on distinct objects without external synchronization
# shared -- safe for concurrent use on the same object
# atomic -- atomic
#
# 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:shared:
PyMutex_Unlock:shared:
PyMutex_IsLocked:atomic:

View file

@ -13,6 +13,88 @@ For general guidance on writing thread-safe code in free-threaded Python, see
:ref:`freethreading-python-howto`.
.. _threadsafety-levels:
Thread safety levels
====================
The C API documentation uses the following levels to describe the thread
safety guarantees of each function. The levels are listed from least to
most safe.
.. _threadsafety-level-incompatible:
Incompatible
------------
A function or operation that cannot be made safe for concurrent use even
with external synchronization. Incompatible code typically accesses
global state in an unsynchronized way and must only be called from a single
thread throughout the program's lifetime.
Example: a function that modifies process-wide state such as signal handlers
or environment variables, where concurrent calls from any threads, even with
external locking, can conflict with the runtime or other libraries.
.. _threadsafety-level-compatible:
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
<race condition>` or :term:`data races <data race>`.
Example: a function that reads from or writes to an object whose internal
state is not protected by a lock. Callers must ensure that no two threads
access the same object at the same time.
.. _threadsafety-level-distinct:
Safe on distinct objects
------------------------
A function or operation that is safe to call from multiple threads without
external synchronization, as long as each thread operates on a **different**
object. Two threads may call the function at the same time, but they must
not pass the same object (or objects that share underlying state) as
arguments.
Example: a function that modifies fields of a struct using non-atomic
writes. Two threads can each call the function on their own struct
instance safely, but concurrent calls on the *same* instance require
external synchronization.
.. _threadsafety-level-shared:
Safe on shared objects
----------------------
A function or operation that is safe for concurrent use on the **same**
object. The implementation uses internal synchronization (such as
:term:`per-object locks <per-object lock>` or
:ref:`critical sections <python-critical-section-api>`) to protect shared
mutable state, so callers do not need to supply their own locking.
Example: :c:func:`PyList_GetItemRef` can be called from multiple threads on the
same :c:type:`PyListObject` - it uses internal synchronization to serialize
access.
.. _threadsafety-level-atomic:
Atomic
------
A function or operation that appears :term:`atomic <atomic operation>` with
respect to other threads - it executes instantaneously from the perspective
of other threads. This is the strongest form of thread safety.
Example: :c:func:`PyMutex_IsLocked` performs an atomic read of the mutex
state and can be called from any thread at any time.
.. _thread-safety-list:
Thread safety for list objects

View file

@ -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,42 @@ def read_stable_abi_data(stable_abi_file: Path) -> dict[str, StableABIEntry]:
return stable_abi_data
_VALID_THREADSAFETY_LEVELS = frozenset({
"incompatible",
"compatible",
"distinct",
"shared",
"atomic",
})
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 +169,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 +305,46 @@ def _unstable_api_annotation() -> nodes.admonition:
)
def _threadsafety_annotation(level: str) -> nodes.emphasis:
match level:
case "incompatible":
display = sphinx_gettext("Not safe to call from multiple threads.")
reftarget = "threadsafety-level-incompatible"
case "compatible":
display = sphinx_gettext(
"Safe to call from multiple threads"
" with external synchronization only."
)
reftarget = "threadsafety-level-compatible"
case "distinct":
display = sphinx_gettext(
"Safe to call without external synchronization"
" on distinct objects."
)
reftarget = "threadsafety-level-distinct"
case "shared":
display = sphinx_gettext(
"Safe for concurrent use on the same object."
)
reftarget = "threadsafety-level-shared"
case "atomic":
display = sphinx_gettext("Atomic.")
reftarget = "threadsafety-level-atomic"
case _:
raise AssertionError(f"Unknown thread safety level {level!r}")
ref_node = addnodes.pending_xref(
display,
nodes.Text(display),
refdomain="std",
reftarget=reftarget,
reftype="ref",
refexplicit="True",
)
prefix = sphinx_gettext("Thread safety:") + " "
classes = ["threadsafety", 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 +431,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)