Skip to content

Commit a270317

Browse files
committed
gh-145254: Add thread safety annotation
- Implement annotation for different thread safety levels - Annotate PyMutex_* APIs
1 parent 812ef66 commit a270317

File tree

4 files changed

+109
-0
lines changed

4 files changed

+109
-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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
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+
# safe -- safe for concurrent use
10+
#
11+
# Lines beginning with '#' are ignored.
12+
# The function name must match the C domain identifier used in the documentation.
13+
14+
# Synchronization primitives (Doc/c-api/synchronization.rst)
15+
PyMutex_Lock:safe:
16+
PyMutex_Unlock:safe:
17+
PyMutex_IsLocked:safe:

Doc/glossary.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,6 +1587,19 @@ Glossary
15871587
See :ref:`Thread State and the Global Interpreter Lock <threads>` for more
15881588
information.
15891589

1590+
thread-compatible
1591+
A function or operation that is safe to call from multiple threads
1592+
provided the caller supplies appropriate external synchronization, for
1593+
example by holding a :term:`lock` for the duration of each call. Without
1594+
such synchronization, concurrent calls may produce :term:`race conditions
1595+
<race condition>` or :term:`data races <data race>`.
1596+
1597+
thread-incompatible
1598+
A function or operation that cannot be made safe for concurrent use even
1599+
with external synchronization. Thread-incompatible code typically
1600+
accesses global state in an unsynchronized way and must be called from
1601+
only one thread at a time throughout the program's lifetime.
1602+
15901603
thread-safe
15911604
A module, function, or class that behaves correctly when used by multiple
15921605
threads concurrently. Thread-safe code uses appropriate

Doc/tools/extensions/c_annotations.py

Lines changed: 78 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,38 @@ 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+
"safe",
131+
})
132+
133+
134+
def read_threadsafety_data(threadsafety_filename: Path) -> dict[str, ThreadSafetyEntry]:
135+
threadsafety_data = {}
136+
for line in threadsafety_filename.read_text(encoding="utf8").splitlines():
137+
line = line.strip()
138+
if not line or line.startswith("#"):
139+
continue
140+
# Each line is of the form: function_name : level : [comment]
141+
parts = line.split(":", 2)
142+
if len(parts) < 2:
143+
raise ValueError(f"Wrong field count in {line!r}")
144+
name, level = parts[0].strip(), parts[1].strip()
145+
if level not in _VALID_THREADSAFETY_LEVELS:
146+
raise ValueError(
147+
f"Unknown thread safety level {level!r} for {name!r}. "
148+
f"Valid levels: {sorted(_VALID_THREADSAFETY_LEVELS)}"
149+
)
150+
threadsafety_data[name] = ThreadSafetyEntry(name=name, level=level)
151+
return threadsafety_data
152+
153+
116154
def add_annotations(app: Sphinx, doctree: nodes.document) -> None:
117155
state = app.env.domaindata["c_annotations"]
118156
refcount_data = state["refcount_data"]
119157
stable_abi_data = state["stable_abi_data"]
158+
threadsafety_data = state["threadsafety_data"]
120159
for node in doctree.findall(addnodes.desc_content):
121160
par = node.parent
122161
if par["domain"] != "c":
@@ -126,6 +165,12 @@ def add_annotations(app: Sphinx, doctree: nodes.document) -> None:
126165
name = par[0]["ids"][0].removeprefix("c.")
127166
objtype = par["objtype"]
128167

168+
# Thread safety annotation — inserted first so it appears last (bottom-most)
169+
# among all annotations.
170+
if entry := threadsafety_data.get(name):
171+
annotation = _threadsafety_annotation(entry.level)
172+
node.insert(0, annotation)
173+
129174
# Stable ABI annotation.
130175
if record := stable_abi_data.get(name):
131176
if ROLE_TO_OBJECT_TYPE[record.role] != objtype:
@@ -256,6 +301,35 @@ def _unstable_api_annotation() -> nodes.admonition:
256301
)
257302

258303

304+
_THREADSAFETY_DISPLAY = {
305+
"incompatible": "Not safe to call from multiple threads.",
306+
"compatible": "Safe to call from multiple threads with external synchronization only.",
307+
"safe": "Safe for concurrent use.",
308+
}
309+
310+
# Maps each thread safety level to the glossary term it should link to.
311+
_THREADSAFETY_TERM = {
312+
"incompatible": "thread-incompatible",
313+
"compatible": "thread-compatible",
314+
"safe": "thread-safe",
315+
}
316+
317+
318+
def _threadsafety_annotation(level: str) -> nodes.emphasis:
319+
display = sphinx_gettext(_THREADSAFETY_DISPLAY[level])
320+
ref_node = addnodes.pending_xref(
321+
display,
322+
nodes.Text(display),
323+
refdomain="std",
324+
reftarget=_THREADSAFETY_TERM[level],
325+
reftype="term",
326+
refexplicit="True",
327+
)
328+
prefix = sphinx_gettext("Thread safety:") + " "
329+
classes = [f"threadsafety-{level}"]
330+
return nodes.emphasis("", prefix, ref_node, classes=classes)
331+
332+
259333
def _return_value_annotation(result_refs: int | None) -> nodes.emphasis:
260334
classes = ["refcount"]
261335
if result_refs is None:
@@ -342,11 +416,15 @@ def init_annotations(app: Sphinx) -> None:
342416
state["stable_abi_data"] = read_stable_abi_data(
343417
Path(app.srcdir, app.config.stable_abi_file)
344418
)
419+
state["threadsafety_data"] = read_threadsafety_data(
420+
Path(app.srcdir, app.config.threadsafety_file)
421+
)
345422

346423

347424
def setup(app: Sphinx) -> ExtensionMetadata:
348425
app.add_config_value("refcount_file", "", "env", types={str})
349426
app.add_config_value("stable_abi_file", "", "env", types={str})
427+
app.add_config_value("threadsafety_file", "", "env", types={str})
350428
app.add_directive("limited-api-list", LimitedAPIList)
351429
app.add_directive("corresponding-type-slot", CorrespondingTypeSlot)
352430
app.connect("builder-inited", init_annotations)

0 commit comments

Comments
 (0)