Repository navigation
gh-155619: Fix data races in the context watcher registry #155943
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2b13f29
54350da
56c8877
d297fc5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import contextvars | ||
| import sys | ||
| import threading | ||
| import unittest | ||
|
|
||
| from test import support | ||
| from test.support import import_helper, threading_helper | ||
|
|
||
| _testcapi = import_helper.import_module("_testcapi") | ||
|
|
||
| ITERS = 1000 | ||
| NTHREADS = 8 | ||
|
|
||
|
|
||
| @threading_helper.requires_working_threading() | ||
| class TestContextWatcherThreadSafety(unittest.TestCase): | ||
| # gh-155619: the per-interpreter context watcher registry | ||
| # (PyInterpreterState.context_watchers plus the active_context_watchers | ||
| # bitmask) is read and written without synchronization. Every callback | ||
| # used here is a no-op, so any failure is CPython's registry rather than | ||
| # the test's own bookkeeping. | ||
|
|
||
| def test_concurrent_add_clear_watchers(self): | ||
| """Race AddWatcher against ClearWatcher. | ||
|
|
||
| Both scan/modify the callback array and do a read-modify-write on | ||
| the bitmask, so two callers can claim the same slot or lose a | ||
| bitmask update. | ||
| """ | ||
| results = [] | ||
|
|
||
| def worker(): | ||
| for _ in range(ITERS): | ||
| try: | ||
| wid = _testcapi.add_noop_context_watcher() | ||
| except RuntimeError: | ||
| continue # all CONTEXT_MAX_WATCHERS slots taken | ||
| self.assertGreaterEqual(wid, 0) | ||
| results.append(wid) | ||
| _testcapi.clear_context_watcher(wid) | ||
|
|
||
| threading_helper.run_concurrently(worker, NTHREADS) | ||
| self.assertGreater(len(results), 0) | ||
|
|
||
| def test_concurrent_clear_same_watcher(self): | ||
| """Concurrent ClearWatcher calls on one ID: exactly one succeeds. | ||
|
|
||
| Called one after another, every call after the first raises | ||
| ValueError, so overlapping calls must give the same result. The | ||
| threads busy-wait on a round counter instead of a Barrier so that | ||
| they reach PyContext_ClearWatcher() close enough together to overlap. | ||
| """ | ||
| if sys._is_gil_enabled(): | ||
| # The calls cannot overlap, and the busy-waits would just | ||
| # contend for the GIL. | ||
| self.skipTest("needs the GIL to be disabled") | ||
|
Comment on lines
+53
to
+56
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are all tests in this module already skip with the GIL enabled? There is a already https://github.com/python/cpython/blob/main/Lib/test/test_free_threading/__init__.py#L7 |
||
| ROUNDS = 100 | ||
| round_no = 0 | ||
| wid = None | ||
| successes = [0] * NTHREADS | ||
| done = threading.Barrier(NTHREADS + 1) | ||
|
|
||
| def worker(k): | ||
| for r in range(1, ROUNDS + 1): | ||
| while round_no < r: | ||
| if done.broken: | ||
| return | ||
| try: | ||
| _testcapi.clear_context_watcher(wid) | ||
| successes[k] += 1 | ||
| except ValueError: | ||
| pass | ||
| try: | ||
| done.wait(support.SHORT_TIMEOUT) | ||
| except threading.BrokenBarrierError: | ||
| return | ||
|
|
||
| threads = [threading.Thread(target=worker, args=(k,)) | ||
| for k in range(NTHREADS)] | ||
| with threading_helper.start_threads(threads): | ||
| try: | ||
| for r in range(1, ROUNDS + 1): | ||
| before = sum(successes) | ||
| wid = _testcapi.add_noop_context_watcher() | ||
| round_no = r | ||
| done.wait(support.SHORT_TIMEOUT) | ||
| self.assertEqual(sum(successes) - before, 1) | ||
| except BaseException: | ||
| # Release the workers so that start_threads() can join them. | ||
| done.abort() | ||
| raise | ||
|
|
||
| def test_clear_watcher_races_notification(self): | ||
| """Race ClearWatcher against notification. | ||
|
|
||
| notify_context_watchers() snapshots the bitmask, then loads | ||
| context_watchers[i], which ClearWatcher may have set to NULL in | ||
| between, so that load must be atomic and must tolerate NULL. | ||
| """ | ||
| def switcher(): | ||
| # Context().run() enters and exits a context, and each switch | ||
| # dispatches to every active watcher on this thread. | ||
| for _ in range(ITERS): | ||
| contextvars.Context().run(lambda: None) | ||
|
|
||
| def churner(): | ||
| for _ in range(ITERS): | ||
| try: | ||
| wid = _testcapi.add_noop_context_watcher() | ||
| except RuntimeError: | ||
| continue | ||
| _testcapi.clear_context_watcher(wid) | ||
|
|
||
| workers = [switcher, churner] * (NTHREADS // 2) | ||
| threading_helper.run_concurrently(workers) | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| unittest.main() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| Fix data races in the per-interpreter context watcher registry on | ||
| :term:`free-threaded <free threading>` builds. :c:func:`PyContext_AddWatcher` | ||
| and :c:func:`PyContext_ClearWatcher` are now serialized, so concurrent callers | ||
| can no longer receive the same watcher ID or both clear the same one. Context | ||
| switches still read the registry without locking, now with atomic loads. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -163,13 +163,16 @@ notify_context_watchers(PyThreadState *ts, PyContextEvent event, PyObject *ctx) | |
| assert(Py_REFCNT(ctx) > 0); | ||
| PyInterpreterState *interp = ts->interp; | ||
| assert(interp->_initialized); | ||
| uint8_t bits = interp->active_context_watchers; | ||
| uint8_t bits = FT_ATOMIC_LOAD_UINT8_RELAXED(interp->active_context_watchers); | ||
| int i = 0; | ||
| while (bits) { | ||
| assert(i < CONTEXT_MAX_WATCHERS); | ||
| if (bits & 1) { | ||
| PyContext_WatchCallback cb = interp->context_watchers[i]; | ||
| // A previous callback may have cleared this watcher. | ||
| // Pairs with the release store in PyContext_AddWatcher(). | ||
| PyContext_WatchCallback cb = | ||
| FT_ATOMIC_LOAD_PTR_ACQUIRE(interp->context_watchers[i]); | ||
| // A previous callback or another thread may have cleared this | ||
| // watcher. | ||
| if (cb != NULL && cb(event, ctx) < 0) { | ||
| PyErr_FormatUnraisable( | ||
| "Exception ignored in %s watcher callback for %R", | ||
|
|
@@ -182,22 +185,54 @@ notify_context_watchers(PyThreadState *ts, PyContextEvent event, PyObject *ctx) | |
| } | ||
|
|
||
|
|
||
| static inline void | ||
| set_context_watcher_bit(PyInterpreterState *interp, int watcher_id) | ||
| { | ||
| uint8_t bit = (uint8_t)(1 << watcher_id); | ||
| #ifdef Py_GIL_DISABLED | ||
| (void)_Py_atomic_or_uint8(&interp->active_context_watchers, bit); | ||
| #else | ||
| interp->active_context_watchers |= bit; | ||
| #endif | ||
| } | ||
|
|
||
|
|
||
| static inline void | ||
| clear_context_watcher_bit(PyInterpreterState *interp, int watcher_id) | ||
| { | ||
| uint8_t bit = (uint8_t)(1 << watcher_id); | ||
| #ifdef Py_GIL_DISABLED | ||
| (void)_Py_atomic_and_uint8(&interp->active_context_watchers, (uint8_t)~bit); | ||
| #else | ||
| interp->active_context_watchers &= (uint8_t)~bit; | ||
| #endif | ||
|
Comment on lines
+204
to
+208
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should this be added as a new |
||
| } | ||
|
|
||
|
|
||
| int | ||
| PyContext_AddWatcher(PyContext_WatchCallback callback) | ||
| { | ||
| PyInterpreterState *interp = _PyInterpreterState_GET(); | ||
| assert(interp->_initialized); | ||
|
|
||
| int watcher_id = -1; | ||
| Py_BEGIN_CRITICAL_SECTION_MUTEX(&interp->context_watchers_mutex); | ||
| for (int i = 0; i < CONTEXT_MAX_WATCHERS; i++) { | ||
| if (!interp->context_watchers[i]) { | ||
| interp->context_watchers[i] = callback; | ||
| interp->active_context_watchers |= (1 << i); | ||
| return i; | ||
| if (interp->context_watchers[i] == NULL) { | ||
| // Publish the callback before setting its bit. | ||
| FT_ATOMIC_STORE_PTR_RELEASE(interp->context_watchers[i], callback); | ||
| set_context_watcher_bit(interp, i); | ||
| watcher_id = i; | ||
| break; | ||
| } | ||
| } | ||
| Py_END_CRITICAL_SECTION(); | ||
|
|
||
| PyErr_SetString(PyExc_RuntimeError, "no more context watcher IDs available"); | ||
| return -1; | ||
| if (watcher_id < 0) { | ||
| PyErr_SetString(PyExc_RuntimeError, | ||
| "no more context watcher IDs available"); | ||
| } | ||
| return watcher_id; | ||
| } | ||
|
|
||
|
|
||
|
|
@@ -210,12 +245,21 @@ PyContext_ClearWatcher(int watcher_id) | |
| PyErr_Format(PyExc_ValueError, "Invalid context watcher ID %d", watcher_id); | ||
| return -1; | ||
| } | ||
| if (!interp->context_watchers[watcher_id]) { | ||
|
|
||
| int found = 0; | ||
| Py_BEGIN_CRITICAL_SECTION_MUTEX(&interp->context_watchers_mutex); | ||
| if (interp->context_watchers[watcher_id] != NULL) { | ||
| // notify_context_watchers() skips a NULL callback. | ||
| clear_context_watcher_bit(interp, watcher_id); | ||
| FT_ATOMIC_STORE_PTR_RELEASE(interp->context_watchers[watcher_id], NULL); | ||
| found = 1; | ||
| } | ||
| Py_END_CRITICAL_SECTION(); | ||
|
|
||
| if (!found) { | ||
| PyErr_Format(PyExc_ValueError, "No context watcher set for ID %d", watcher_id); | ||
| return -1; | ||
| } | ||
| interp->context_watchers[watcher_id] = NULL; | ||
| interp->active_context_watchers &= ~(1 << watcher_id); | ||
| return 0; | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason to catch runtime errors which could mask test errors such as using too many slots?