Skip to content

Commit 2ae998f

Browse files
lysnikolaoumiss-islington
authored andcommitted
gh-145254: Add thread safety annotation in docs (GH-145255)
(cherry picked from commit 0dce4c6) Co-authored-by: Lysandros Nikolaou <lisandrosnik@gmail.com>
1 parent 705e3ea commit 2ae998f

File tree

4 files changed

+195
-0
lines changed

4 files changed

+195
-0
lines changed

Doc/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,7 @@
566566
# Relative filename of the data files
567567
refcount_file = 'data/refcounts.dat'
568568
stable_abi_file = 'data/stable_abi.dat'
569+
threadsafety_file = 'data/threadsafety.dat'
569570

570571
# Options for sphinxext-opengraph
571572
# -------------------------------

Doc/data/threadsafety.dat

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Thread safety annotations for C API functions.
2+
#
3+
# Each line has the form:
4+
# function_name : level
5+
#
6+
# Where level is one of:
7+
# incompatible -- not safe even with external locking
8+
# compatible -- safe if the caller serializes all access with external locks
9+
# distinct -- safe on distinct objects without external synchronization
10+
# shared -- safe for concurrent use on the same object
11+
# atomic -- atomic
12+
#
13+
# Lines beginning with '#' are ignored.
14+
# The function name must match the C domain identifier used in the documentation.
15+
16+
# Synchronization primitives (Doc/c-api/synchronization.rst)
17+
PyMutex_Lock:shared:
18+
PyMutex_Unlock:shared:
19+
PyMutex_IsLocked:atomic:

Doc/library/threadsafety.rst

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,88 @@ For general guidance on writing thread-safe code in free-threaded Python, see
1313
:ref:`freethreading-python-howto`.
1414

1515

16+
.. _threadsafety-levels:
17+
18+
Thread safety levels
19+
====================
20+
21+
The C API documentation uses the following levels to describe the thread
22+
safety guarantees of each function. The levels are listed from least to
23+
most safe.
24+
25+
.. _threadsafety-level-incompatible:
26+
27+
Incompatible
28+
------------
29+
30+
A function or operation that cannot be made safe for concurrent use even
31+
with external synchronization. Incompatible code typically accesses
32+
global state in an unsynchronized way and must only be called from a single
33+
thread throughout the program's lifetime.
34+
35+
Example: a function that modifies process-wide state such as signal handlers
36+
or environment variables, where concurrent calls from any threads, even with
37+
external locking, can conflict with the runtime or other libraries.
38+
39+
.. _threadsafety-level-compatible:
40+
41+
Compatible
42+
----------
43+
44+
A function or operation that is safe to call from multiple threads
45+
*provided* the caller supplies appropriate external synchronization, for
46+
example by holding a :term:`lock` for the duration of each call. Without
47+
such synchronization, concurrent calls may produce :term:`race conditions
48+
<race condition>` or :term:`data races <data race>`.
49+
50+
Example: a function that reads from or writes to an object whose internal
51+
state is not protected by a lock. Callers must ensure that no two threads
52+
access the same object at the same time.
53+
54+
.. _threadsafety-level-distinct:
55+
56+
Safe on distinct objects
57+
------------------------
58+
59+
A function or operation that is safe to call from multiple threads without
60+
external synchronization, as long as each thread operates on a **different**
61+
object. Two threads may call the function at the same time, but they must
62+
not pass the same object (or objects that share underlying state) as
63+
arguments.
64+
65+
Example: a function that modifies fields of a struct using non-atomic
66+
writes. Two threads can each call the function on their own struct
67+
instance safely, but concurrent calls on the *same* instance require
68+
external synchronization.
69+
70+
.. _threadsafety-level-shared:
71+
72+
Safe on shared objects
73+
----------------------
74+
75+
A function or operation that is safe for concurrent use on the **same**
76+
object. The implementation uses internal synchronization (such as
77+
:term:`per-object locks <per-object lock>` or
78+
:ref:`critical sections <python-critical-section-api>`) to protect shared
79+
mutable state, so callers do not need to supply their own locking.
80+
81+
Example: :c:func:`PyList_GetItemRef` can be called from multiple threads on the
82+
same :c:type:`PyListObject` - it uses internal synchronization to serialize
83+
access.
84+
85+
.. _threadsafety-level-atomic:
86+
87+
Atomic
88+
------
89+
90+
A function or operation that appears :term:`atomic <atomic operation>` with
91+
respect to other threads - it executes instantaneously from the perspective
92+
of other threads. This is the strongest form of thread safety.
93+
94+
Example: :c:func:`PyMutex_IsLocked` performs an atomic read of the mutex
95+
state and can be called from any thread at any time.
96+
97+
1698
.. _thread-safety-list:
1799

18100
Thread safety for list objects

Doc/tools/extensions/c_annotations.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
* Reference count annotations for C API functions.
44
* Stable ABI annotations
55
* Limited API annotations
6+
* Thread safety annotations for C API functions.
67
78
Configuration:
89
* Set ``refcount_file`` to the path to the reference count data file.
910
* Set ``stable_abi_file`` to the path to stable ABI list.
11+
* Set ``threadsafety_file`` to the path to the thread safety data file.
1012
"""
1113

1214
from __future__ import annotations
@@ -48,6 +50,15 @@ class RefCountEntry:
4850
result_refs: int | None = None
4951

5052

53+
@dataclasses.dataclass(frozen=True, slots=True)
54+
class ThreadSafetyEntry:
55+
# Name of the function.
56+
name: str
57+
# Thread safety level.
58+
# One of: 'incompatible', 'compatible', 'safe'.
59+
level: str
60+
61+
5162
@dataclasses.dataclass(frozen=True, slots=True)
5263
class StableABIEntry:
5364
# Role of the object.
@@ -113,10 +124,42 @@ def read_stable_abi_data(stable_abi_file: Path) -> dict[str, StableABIEntry]:
113124
return stable_abi_data
114125

115126

127+
_VALID_THREADSAFETY_LEVELS = frozenset({
128+
"incompatible",
129+
"compatible",
130+
"distinct",
131+
"shared",
132+
"atomic",
133+
})
134+
135+
136+
def read_threadsafety_data(
137+
threadsafety_filename: Path,
138+
) -> dict[str, ThreadSafetyEntry]:
139+
threadsafety_data = {}
140+
for line in threadsafety_filename.read_text(encoding="utf8").splitlines():
141+
line = line.strip()
142+
if not line or line.startswith("#"):
143+
continue
144+
# Each line is of the form: function_name : level : [comment]
145+
parts = line.split(":", 2)
146+
if len(parts) < 2:
147+
raise ValueError(f"Wrong field count in {line!r}")
148+
name, level = parts[0].strip(), parts[1].strip()
149+
if level not in _VALID_THREADSAFETY_LEVELS:
150+
raise ValueError(
151+
f"Unknown thread safety level {level!r} for {name!r}. "
152+
f"Valid levels: {sorted(_VALID_THREADSAFETY_LEVELS)}"
153+
)
154+
threadsafety_data[name] = ThreadSafetyEntry(name=name, level=level)
155+
return threadsafety_data
156+
157+
116158
def add_annotations(app: Sphinx, doctree: nodes.document) -> None:
117159
state = app.env.domaindata["c_annotations"]
118160
refcount_data = state["refcount_data"]
119161
stable_abi_data = state["stable_abi_data"]
162+
threadsafety_data = state["threadsafety_data"]
120163
for node in doctree.findall(addnodes.desc_content):
121164
par = node.parent
122165
if par["domain"] != "c":
@@ -126,6 +169,12 @@ def add_annotations(app: Sphinx, doctree: nodes.document) -> None:
126169
name = par[0]["ids"][0].removeprefix("c.")
127170
objtype = par["objtype"]
128171

172+
# Thread safety annotation — inserted first so it appears last (bottom-most)
173+
# among all annotations.
174+
if entry := threadsafety_data.get(name):
175+
annotation = _threadsafety_annotation(entry.level)
176+
node.insert(0, annotation)
177+
129178
# Stable ABI annotation.
130179
if record := stable_abi_data.get(name):
131180
if ROLE_TO_OBJECT_TYPE[record.role] != objtype:
@@ -256,6 +305,46 @@ def _unstable_api_annotation() -> nodes.admonition:
256305
)
257306

258307

308+
def _threadsafety_annotation(level: str) -> nodes.emphasis:
309+
match level:
310+
case "incompatible":
311+
display = sphinx_gettext("Not safe to call from multiple threads.")
312+
reftarget = "threadsafety-level-incompatible"
313+
case "compatible":
314+
display = sphinx_gettext(
315+
"Safe to call from multiple threads"
316+
" with external synchronization only."
317+
)
318+
reftarget = "threadsafety-level-compatible"
319+
case "distinct":
320+
display = sphinx_gettext(
321+
"Safe to call without external synchronization"
322+
" on distinct objects."
323+
)
324+
reftarget = "threadsafety-level-distinct"
325+
case "shared":
326+
display = sphinx_gettext(
327+
"Safe for concurrent use on the same object."
328+
)
329+
reftarget = "threadsafety-level-shared"
330+
case "atomic":
331+
display = sphinx_gettext("Atomic.")
332+
reftarget = "threadsafety-level-atomic"
333+
case _:
334+
raise AssertionError(f"Unknown thread safety level {level!r}")
335+
ref_node = addnodes.pending_xref(
336+
display,
337+
nodes.Text(display),
338+
refdomain="std",
339+
reftarget=reftarget,
340+
reftype="ref",
341+
refexplicit="True",
342+
)
343+
prefix = sphinx_gettext("Thread safety:") + " "
344+
classes = ["threadsafety", f"threadsafety-{level}"]
345+
return nodes.emphasis("", prefix, ref_node, classes=classes)
346+
347+
259348
def _return_value_annotation(result_refs: int | None) -> nodes.emphasis:
260349
classes = ["refcount"]
261350
if result_refs is None:
@@ -342,11 +431,15 @@ def init_annotations(app: Sphinx) -> None:
342431
state["stable_abi_data"] = read_stable_abi_data(
343432
Path(app.srcdir, app.config.stable_abi_file)
344433
)
434+
state["threadsafety_data"] = read_threadsafety_data(
435+
Path(app.srcdir, app.config.threadsafety_file)
436+
)
345437

346438

347439
def setup(app: Sphinx) -> ExtensionMetadata:
348440
app.add_config_value("refcount_file", "", "env", types={str})
349441
app.add_config_value("stable_abi_file", "", "env", types={str})
442+
app.add_config_value("threadsafety_file", "", "env", types={str})
350443
app.add_directive("limited-api-list", LimitedAPIList)
351444
app.add_directive("corresponding-type-slot", CorrespondingTypeSlot)
352445
app.connect("builder-inited", init_annotations)

0 commit comments

Comments
 (0)