https://github.com/python/cpython/commit/8a4895985f42282504d83b9bd0c77b129f95a5d5
commit: 8a4895985f42282504d83b9bd0c77b129f95a5d5
branch: main
author: Alper <[email protected]>
committer: colesbury <[email protected]>
date: 2026-05-11T11:39:55-04:00
summary:
gh-145235: Make dict watcher API thread-safe for free-threaded builds
(gh-145233)
In free-threaded builds, concurrent calls to PyDict_AddWatcher,
PyDict_ClearWatcher, PyDict_Watch, and PyDict_Unwatch can race on the shared
callback array and the per-dict watcher tags. This change adds a mutex to
serialize watcher registration and removal, atomic operations for tag updates,
and atomic acquire/release synchronization for callback dispatch in
_PyDict_SendEvent.
files:
A Lib/test/test_free_threading/test_dict_watcher.py
A Misc/NEWS.d/next/C_API/2026-02-25-13-37-10.gh-issue-145235.-1ySNR.rst
M Include/internal/pycore_dict_state.h
M Include/internal/pycore_pyatomic_ft_wrappers.h
M Modules/_testcapi/watchers.c
M Objects/dictobject.c
M Python/optimizer_analysis.c
M Python/pystate.c
M Tools/c-analyzer/cpython/ignored.tsv
diff --git a/Include/internal/pycore_dict_state.h
b/Include/internal/pycore_dict_state.h
index 11932b8d1e1ab6..bb6fe262597559 100644
--- a/Include/internal/pycore_dict_state.h
+++ b/Include/internal/pycore_dict_state.h
@@ -13,6 +13,8 @@ extern "C" {
struct _Py_dict_state {
uint32_t next_keys_version;
+ PyMutex watcher_mutex; // Protects the watchers array
(free-threaded builds)
+ _PyOnceFlag watcher_setup_once; // One-time optimizer watcher setup
PyDict_WatchCallback watchers[DICT_MAX_WATCHERS];
};
diff --git a/Include/internal/pycore_pyatomic_ft_wrappers.h
b/Include/internal/pycore_pyatomic_ft_wrappers.h
index fafdd728a8229a..d8ec306a0dae3f 100644
--- a/Include/internal/pycore_pyatomic_ft_wrappers.h
+++ b/Include/internal/pycore_pyatomic_ft_wrappers.h
@@ -138,6 +138,7 @@ extern "C" {
#define FT_ATOMIC_ADD_SSIZE(value, new_value) \
(void)_Py_atomic_add_ssize(&value, new_value)
#define FT_MUTEX_LOCK(lock) PyMutex_Lock(lock)
+#define FT_MUTEX_LOCK_FLAGS(lock, flags) PyMutex_LockFlags(lock, flags)
#define FT_MUTEX_UNLOCK(lock) PyMutex_Unlock(lock)
#else
@@ -201,6 +202,7 @@ extern "C" {
#define FT_ATOMIC_STORE_ULLONG_RELAXED(value, new_value) value = new_value
#define FT_ATOMIC_ADD_SSIZE(value, new_value) (void)(value += new_value)
#define FT_MUTEX_LOCK(lock) do {} while (0)
+#define FT_MUTEX_LOCK_FLAGS(lock, flags) do {} while (0)
#define FT_MUTEX_UNLOCK(lock) do {} while (0)
#endif
diff --git a/Lib/test/test_free_threading/test_dict_watcher.py
b/Lib/test/test_free_threading/test_dict_watcher.py
new file mode 100644
index 00000000000000..6a6843f9344f64
--- /dev/null
+++ b/Lib/test/test_free_threading/test_dict_watcher.py
@@ -0,0 +1,89 @@
+import unittest
+
+from test.support import import_helper, threading_helper
+
+_testcapi = import_helper.import_module("_testcapi")
+
+ITERS = 100
+NTHREADS = 20
+
+
+@threading_helper.requires_working_threading()
+class TestDictWatcherThreadSafety(unittest.TestCase):
+ # Watcher kinds from _testcapi
+ EVENTS = 0 # appends dict events as strings to global event list
+
+ def test_concurrent_add_clear_watchers(self):
+ """Race AddWatcher and ClearWatcher from multiple threads.
+
+ Uses more threads than available watcher slots (5 user slots out
+ of DICT_MAX_WATCHERS=8).
+ """
+ results = []
+
+ def worker():
+ for _ in range(ITERS):
+ try:
+ wid = _testcapi.add_dict_watcher(self.EVENTS)
+ except RuntimeError:
+ continue # All slots taken
+ self.assertGreaterEqual(wid, 0)
+ results.append(wid)
+ _testcapi.clear_dict_watcher(wid)
+
+ threading_helper.run_concurrently(worker, NTHREADS)
+
+ # Verify at least some watchers were successfully added
+ self.assertGreater(len(results), 0)
+
+ def test_concurrent_watch_unwatch(self):
+ """Race Watch and Unwatch on the same dict from multiple threads."""
+ wid = _testcapi.add_dict_watcher(self.EVENTS)
+ dicts = [{} for _ in range(10)]
+
+ def worker():
+ for _ in range(ITERS):
+ for d in dicts:
+ _testcapi.watch_dict(wid, d)
+ for d in dicts:
+ _testcapi.unwatch_dict(wid, d)
+
+ try:
+ threading_helper.run_concurrently(worker, NTHREADS)
+
+ # Verify watching still works after concurrent watch/unwatch
+ _testcapi.watch_dict(wid, dicts[0])
+ dicts[0]["key"] = "value"
+ events = _testcapi.get_dict_watcher_events()
+ self.assertIn("new:key:value", events)
+ finally:
+ _testcapi.clear_dict_watcher(wid)
+
+ def test_concurrent_modify_watched_dict(self):
+ """Race dict mutations (triggering callbacks) with watch/unwatch."""
+ wid = _testcapi.add_dict_watcher(self.EVENTS)
+ d = {}
+ _testcapi.watch_dict(wid, d)
+
+ def mutator():
+ for i in range(ITERS):
+ d[f"key_{i}"] = i
+ d.pop(f"key_{i}", None)
+
+ def toggler():
+ for i in range(ITERS):
+ _testcapi.watch_dict(wid, d)
+ d[f"toggler_{i}"] = i
+ _testcapi.unwatch_dict(wid, d)
+
+ workers = [mutator, toggler] * (NTHREADS // 2)
+ try:
+ threading_helper.run_concurrently(workers)
+ events = _testcapi.get_dict_watcher_events()
+ self.assertGreater(len(events), 0)
+ finally:
+ _testcapi.clear_dict_watcher(wid)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git
a/Misc/NEWS.d/next/C_API/2026-02-25-13-37-10.gh-issue-145235.-1ySNR.rst
b/Misc/NEWS.d/next/C_API/2026-02-25-13-37-10.gh-issue-145235.-1ySNR.rst
new file mode 100644
index 00000000000000..98a8c268735726
--- /dev/null
+++ b/Misc/NEWS.d/next/C_API/2026-02-25-13-37-10.gh-issue-145235.-1ySNR.rst
@@ -0,0 +1,3 @@
+Made :c:func:`PyDict_AddWatcher`, :c:func:`PyDict_ClearWatcher`,
+:c:func:`PyDict_Watch`, and :c:func:`PyDict_Unwatch` thread-safe on the
+:term:`free threaded <free threading>` build.
diff --git a/Modules/_testcapi/watchers.c b/Modules/_testcapi/watchers.c
index e0abf6b1845d8e..71cdc54009017a 100644
--- a/Modules/_testcapi/watchers.c
+++ b/Modules/_testcapi/watchers.c
@@ -9,6 +9,7 @@
#include "pycore_function.h" // FUNC_MAX_WATCHERS
#include "pycore_interp_structs.h" // CODE_MAX_WATCHERS
#include "pycore_context.h" // CONTEXT_MAX_WATCHERS
+#include "pycore_lock.h" // _PyOnceFlag
/*[clinic input]
module _testcapi
@@ -18,6 +19,14 @@ module _testcapi
// Test dict watching
static PyObject *g_dict_watch_events = NULL;
static int g_dict_watchers_installed = 0;
+static _PyOnceFlag g_dict_watch_once = {0};
+
+static int
+_init_dict_watch_events(void *arg)
+{
+ g_dict_watch_events = PyList_New(0);
+ return g_dict_watch_events ? 0 : -1;
+}
static int
dict_watch_callback(PyDict_WatchEvent event,
@@ -106,13 +115,10 @@ add_dict_watcher(PyObject *self, PyObject *kind)
if (watcher_id < 0) {
return NULL;
}
- if (!g_dict_watchers_installed) {
- assert(!g_dict_watch_events);
- if (!(g_dict_watch_events = PyList_New(0))) {
- return NULL;
- }
+ if (_PyOnceFlag_CallOnce(&g_dict_watch_once, _init_dict_watch_events,
NULL) < 0) {
+ return NULL;
}
- g_dict_watchers_installed++;
+ _Py_atomic_add_int(&g_dict_watchers_installed, 1);
return PyLong_FromLong(watcher_id);
}
@@ -122,10 +128,8 @@ clear_dict_watcher(PyObject *self, PyObject *watcher_id)
if (PyDict_ClearWatcher(PyLong_AsLong(watcher_id))) {
return NULL;
}
- g_dict_watchers_installed--;
- if (!g_dict_watchers_installed) {
- assert(g_dict_watch_events);
- Py_CLEAR(g_dict_watch_events);
+ if (_Py_atomic_add_int(&g_dict_watchers_installed, -1) == 1) {
+ PyList_Clear(g_dict_watch_events);
}
Py_RETURN_NONE;
}
@@ -164,7 +168,7 @@ _testcapi_unwatch_dict_impl(PyObject *module, int
watcher_id, PyObject *dict)
static PyObject *
get_dict_watcher_events(PyObject *self, PyObject *Py_UNUSED(args))
{
- if (!g_dict_watch_events) {
+ if (_Py_atomic_load_int(&g_dict_watchers_installed) <= 0) {
PyErr_SetString(PyExc_RuntimeError, "no watchers active");
return NULL;
}
diff --git a/Objects/dictobject.c b/Objects/dictobject.c
index 42bc63acd9049c..09135e031e6fc7 100644
--- a/Objects/dictobject.c
+++ b/Objects/dictobject.c
@@ -8015,13 +8015,19 @@ validate_watcher_id(PyInterpreterState *interp, int
watcher_id)
PyErr_Format(PyExc_ValueError, "Invalid dict watcher ID %d",
watcher_id);
return -1;
}
- if (!interp->dict_state.watchers[watcher_id]) {
+ PyDict_WatchCallback cb = FT_ATOMIC_LOAD_PTR_RELAXED(
+ interp->dict_state.watchers[watcher_id]);
+ if (cb == NULL) {
PyErr_Format(PyExc_ValueError, "No dict watcher set for ID %d",
watcher_id);
return -1;
}
return 0;
}
+// In free-threaded builds, Add/Clear serialize on watcher_mutex and publish
+// callbacks with release stores. SendEvent reads them lock-free using
+// acquire loads.
+
int
PyDict_Watch(int watcher_id, PyObject* dict)
{
@@ -8033,7 +8039,8 @@ PyDict_Watch(int watcher_id, PyObject* dict)
if (validate_watcher_id(interp, watcher_id)) {
return -1;
}
- FT_ATOMIC_OR_UINT64(((PyDictObject*)dict)->_ma_watcher_tag, (1LL <<
watcher_id));
+ FT_ATOMIC_OR_UINT64(((PyDictObject*)dict)->_ma_watcher_tag,
+ 1ULL << watcher_id);
return 0;
}
@@ -8048,36 +8055,48 @@ PyDict_Unwatch(int watcher_id, PyObject* dict)
if (validate_watcher_id(interp, watcher_id)) {
return -1;
}
- FT_ATOMIC_AND_UINT64(((PyDictObject*)dict)->_ma_watcher_tag, ~(1LL <<
watcher_id));
+ FT_ATOMIC_AND_UINT64(((PyDictObject*)dict)->_ma_watcher_tag,
+ ~(1ULL << watcher_id));
return 0;
}
int
PyDict_AddWatcher(PyDict_WatchCallback callback)
{
+ int watcher_id = -1;
PyInterpreterState *interp = _PyInterpreterState_GET();
+ FT_MUTEX_LOCK_FLAGS(&interp->dict_state.watcher_mutex,
+ _Py_LOCK_DONT_DETACH);
/* Some watchers are reserved for CPython, start at the first available
one */
for (int i = FIRST_AVAILABLE_WATCHER; i < DICT_MAX_WATCHERS; i++) {
if (!interp->dict_state.watchers[i]) {
- interp->dict_state.watchers[i] = callback;
- return i;
+ FT_ATOMIC_STORE_PTR_RELEASE(interp->dict_state.watchers[i],
callback);
+ watcher_id = i;
+ goto done;
}
}
-
PyErr_SetString(PyExc_RuntimeError, "no more dict watcher IDs available");
- return -1;
+done:
+ FT_MUTEX_UNLOCK(&interp->dict_state.watcher_mutex);
+ return watcher_id;
}
int
PyDict_ClearWatcher(int watcher_id)
{
+ int res = 0;
PyInterpreterState *interp = _PyInterpreterState_GET();
+ FT_MUTEX_LOCK_FLAGS(&interp->dict_state.watcher_mutex,
+ _Py_LOCK_DONT_DETACH);
if (validate_watcher_id(interp, watcher_id)) {
- return -1;
+ res = -1;
+ goto done;
}
- interp->dict_state.watchers[watcher_id] = NULL;
- return 0;
+ FT_ATOMIC_STORE_PTR_RELEASE(interp->dict_state.watchers[watcher_id], NULL);
+done:
+ FT_MUTEX_UNLOCK(&interp->dict_state.watcher_mutex);
+ return res;
}
static const char *
@@ -8102,7 +8121,8 @@ _PyDict_SendEvent(int watcher_bits,
PyInterpreterState *interp = _PyInterpreterState_GET();
for (int i = 0; i < DICT_MAX_WATCHERS; i++) {
if (watcher_bits & 1) {
- PyDict_WatchCallback cb = interp->dict_state.watchers[i];
+ PyDict_WatchCallback cb = FT_ATOMIC_LOAD_PTR_ACQUIRE(
+ interp->dict_state.watchers[i]);
if (cb && (cb(event, (PyObject*)mp, key, value) < 0)) {
// We don't want to resurrect the dict by potentially having an
// unraisablehook keep a reference to it, so we don't pass the
diff --git a/Python/optimizer_analysis.c b/Python/optimizer_analysis.c
index 1dc3a248f45f0c..e726dc0e6fd111 100644
--- a/Python/optimizer_analysis.c
+++ b/Python/optimizer_analysis.c
@@ -18,6 +18,7 @@
#include "pycore_opcode_metadata.h"
#include "pycore_opcode_utils.h"
#include "pycore_pystate.h" // _PyInterpreterState_GET()
+#include "pycore_pyatomic_ft_wrappers.h" // FT_ATOMIC_*
#include "pycore_tstate.h" // _PyThreadStateImpl
#include "pycore_uop_metadata.h"
#include "pycore_long.h"
@@ -127,7 +128,7 @@ static void
increment_mutations(PyObject* dict) {
assert(PyDict_CheckExact(dict));
PyDictObject *d = (PyDictObject *)dict;
- FT_ATOMIC_ADD_UINT64(d->_ma_watcher_tag, (1 << DICT_MAX_WATCHERS));
+ FT_ATOMIC_ADD_UINT64(d->_ma_watcher_tag, 1ULL << DICT_MAX_WATCHERS);
}
/* The first two dict watcher IDs are reserved for CPython,
@@ -156,6 +157,17 @@ type_watcher_callback(PyTypeObject* type)
return 0;
}
+static int
+_setup_optimizer_watchers(void *Py_UNUSED(arg))
+{
+ PyInterpreterState *interp = _PyInterpreterState_GET();
+ FT_ATOMIC_STORE_PTR_RELEASE(
+ interp->dict_state.watchers[GLOBALS_WATCHER_ID],
+ globals_watcher_callback);
+ interp->type_watchers[TYPE_WATCHER_ID] = type_watcher_callback;
+ return 0;
+}
+
static void
watch_type(PyTypeObject *type, _PyBloomFilter *filter)
{
@@ -580,10 +592,8 @@ optimize_uops(
// Make sure that watchers are set up
PyInterpreterState *interp = _PyInterpreterState_GET();
- if (interp->dict_state.watchers[GLOBALS_WATCHER_ID] == NULL) {
- interp->dict_state.watchers[GLOBALS_WATCHER_ID] =
globals_watcher_callback;
- interp->type_watchers[TYPE_WATCHER_ID] = type_watcher_callback;
- }
+ _PyOnceFlag_CallOnce(&interp->dict_state.watcher_setup_once,
+ _setup_optimizer_watchers, NULL);
_Py_uop_abstractcontext_init(ctx, dependencies);
_Py_UOpsAbstractFrame *frame = _Py_uop_frame_new(ctx, (PyCodeObject
*)func->func_code, NULL, 0);
diff --git a/Python/pystate.c b/Python/pystate.c
index bf2616a49148a7..ff712019affbf9 100644
--- a/Python/pystate.c
+++ b/Python/pystate.c
@@ -320,6 +320,7 @@ _Py_COMP_DIAG_POP
&(runtime)->allocators.mutex, \
&(runtime)->_main_interpreter.types.mutex, \
&(runtime)->_main_interpreter.code_state.mutex, \
+ &(runtime)->_main_interpreter.dict_state.watcher_mutex, \
}
static void
diff --git a/Tools/c-analyzer/cpython/ignored.tsv
b/Tools/c-analyzer/cpython/ignored.tsv
index 7af64ed017ba73..ddfb93a424c018 100644
--- a/Tools/c-analyzer/cpython/ignored.tsv
+++ b/Tools/c-analyzer/cpython/ignored.tsv
@@ -467,6 +467,7 @@ Modules/_testcapi/object.c - MyObject_dealloc_called
-
Modules/_testcapi/object.c - MyType -
Modules/_testcapi/structmember.c - test_structmembersType_OldAPI
-
Modules/_testcapi/watchers.c - g_dict_watch_events -
+Modules/_testcapi/watchers.c - g_dict_watch_once -
Modules/_testcapi/watchers.c - g_dict_watchers_installed -
Modules/_testcapi/watchers.c - g_type_modified_events -
Modules/_testcapi/watchers.c - g_type_watchers_installed -
_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]