Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 Doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# -------------------------------
Expand Down
19 changes: 19 additions & 0 deletions Doc/data/threadsafety.dat
Original file line number Diff line number Diff line change
@@ -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:
82 changes: 82 additions & 0 deletions Doc/library/threadsafety.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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:`PyMutex_Lock` can be called from multiple threads on the
same :c:type:`PyMutex` - 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
Expand Down
93 changes: 93 additions & 0 deletions Doc/tools/extensions/c_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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":
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
Loading