GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — gh-155619: Fix data races in the context watcher registry by cobed95 · Pull Request #155943 · python/cpython Conversation
|
| Test | Fails | How |
|---|---|---|
test_concurrent_add_clear_watchers |
10/10 | Two threads get the same ID, so one clear raises ValueError: No context watcher set for ID 0 |
test_concurrent_clear_same_watcher |
10/10 | Two, three or four clears of the same ID all succeed |
test_clear_watcher_races_notification |
9/10 | The same duplicate-ID ValueError |
The same binary with PYTHON_GIL=1 passes. (The double-clear test skips itself there, since the calls can't overlap.)
gh-159739 already replaced the assert(cb != NULL) in notify_context_watchers() with a NULL check, so a clear racing with a context switch no longer crashes. What's left is that the registry is read and written with plain accesses from several threads, and an add or clear isn't atomic as a whole.
PyContext_AddWatcher() and PyContext_ClearWatcher() now run under Py_BEGIN_CRITICAL_SECTION_MUTEX() on a new PyMutex in the interpreter state, the same pattern as TYPE_LOCK. They run about once per extension, so the lock costs nothing that matters.
notify_context_watchers() runs on every context switch and still takes no lock. It loads each callback with an acquire load, paired with the release store in PyContext_AddWatcher(), and keeps gh-159739's NULL check for a watcher cleared by an earlier callback or by another thread. I kept the bitmask rather than scanning all eight slots because it makes the common zero-watcher case one load and a not-taken branch.
Doc/c-api/contextvars.rst had no threading guidance at all, unlike dict and type watchers, so I added some.
The new tests pass 20/20 runs. test_free_threading, test_context, test_capi, test_contextlib and test_asyncio pass on a free-threaded debug build (5,149 tests), and so does the same binary with PYTHON_GIL=1 (1,690 tests).
test_concurrent_clear_same_watcher releases its threads with a busy-wait instead of a Barrier. A barrier wakes them too far apart to overlap and found nothing in 20,000 rounds, while the busy-wait hits the race in about a quarter of rounds.
No ThreadSanitizer run from me. It wouldn't catch the double clear anyway: in the previous revision every access was already atomic, so there was no data race to report, just clears that weren't atomic as a whole.
pyperformance async_tree, async_tree_io, async_generators (asyncio runs each task step through ctx.run()), plus nbody as a control that never enters a context. Free-threaded build, baseline vs. this change:
| Benchmark | Baseline | Patched | |
|---|---|---|---|
| async_generators | 245 ms +- 23 ms | 236 ms +- 5 ms | 1.04x faster, not significant |
| async_tree_io | 374 ms +- 23 ms | 363 ms +- 5 ms | 1.03x faster, not significant |
| async_tree_none | 175 ms +- 8 ms | 177 ms +- 8 ms | 1.01x slower, not significant |
| nbody (control) | 77.0 ms +- 5.6 ms | 76.6 ms +- 1.5 ms | 1.01x faster, not significant |
These were measured on the previous revision. Notification, the only path they exercise, hasn't changed since; add and clear now take a lock, but they aren't on that path.
The default build was the same story, except async_generators came out 1.08x slower there. I think that one is noise: its spread is about 12% of the mean, t=-2.54, and the same benchmark went faster on the free-threaded build. These were plain -O3 builds with --fast on a laptop, so they answer "does this cost anything" and not much else; happy to redo them properly if it matters.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Documentation build overview
450 files changed ·
|
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
@cobed95 Sorry for the delay, I will take a look at this week. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Thank you @cobed95 for looking into the issue I reported and putting together a fix! @corona10, would you be able to review this when you have a chance? Thanks! |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Yeah I will do this during this sprint |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| // lets two threads select the same slot and return the same ID. | ||
| // Losing the race means the slot is taken, so just try the next one. | ||
| PyContext_WatchCallback expected = NULL; | ||
| if (_Py_atomic_compare_exchange_ptr(&interp->context_watchers[i], |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
It doesn not gurentee to set set_context_watcher_bit if CAS is not success, you may run loop here.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
You’re right. The CAS only made claiming the slot atomic; setting the bit was still a separate step, so the add as a whole wasn’t. (It’s a strong CAS, so the claim itself didn’t need a retry loop.) I’ve dropped it: PyContext_AddWatcher() now does a plain check and store inside a critical section.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| clear_context_watcher_bit(interp, watcher_id); | ||
| FT_ATOMIC_STORE_PTR_RELEASE(interp->context_watchers[watcher_id], NULL); |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
This will make issue if one thread clear watcher_id and other thread try to add watcher_id.
Seqeuntial operation should be gurenteed.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Agreed, and I could reproduce it. With the threads lined up, two concurrent PyContext_ClearWatcher() calls on the same ID both returned 0, and on current main sometimes three or four do. Add and Clear are now serialized by a critical section on a new per-interpreter mutex. Notification still doesn’t take it.
I added test_concurrent_clear_same_watcher for this. It fails every time on main and passes now.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| // Stop notification selecting this slot before retiring the callback, so | ||
| // the window in which a notifier sees the bit but loads NULL is as short | ||
| // as possible. That window is benign: notify_context_watchers() skips a | ||
| // NULL callback. |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Please rewrite comment as concise as possible.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Done, it’s one line now:
// notify_context_watchers() skips a NULL callback.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
I thint that following functions should use critical section to gurentee sequetiallly consistant output.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
A Python core developer has requested some changes be made to your pull request before we can consider merging it. If you could please address their requests along with any other requests in other reviews from core developers that would be appreciated. Once you have made the requested changes, please leave a comment on this pull request containing the phrase |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Hi everyone, I will work on this by the end of this weekend. Thanks for your patience. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
Resolve the conflict with pythongh-159739 in notify_context_watchers(): keep the acquire load, and cover both ways the callback can be NULL in the comment.
Each step of the lock-free version was atomic, but an add or clear as a whole was not: two concurrent PyContext_ClearWatcher() calls on one ID could both succeed, and a clear landing in the middle of an add could leave the active bit set over an empty slot. Run both under a critical section on a new per-interpreter mutex and drop the CAS. Notification still does not take the lock. Add test_concurrent_clear_same_watcher, which fails on the previous revision, and drop a test docstring's reference to the assert that pythongh-159739 removed from main. Update NEWS to match.
|
Thanks for the review. Every access in the previous revision was atomic, so this was a race TSan couldn’t have flagged.
I have made the requested changes; please review again |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Thanks for making the requested changes! @corona10: please review the changes made to this pull request. |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
The test aborted its barrier in a finally block, so on success it could break the last round while the workers were still waking from it. They then raised BrokenBarrierError, which regrtest reports as an uncaught thread exception, failing the free-threaded CI jobs under --fail-env-changed. Abort only when the main loop fails, and let the workers return if the barrier is broken.
| 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") |
There was a problem hiding this comment.
The 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
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| #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 |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
should this be added as a new FT_ATOMIC_ wrapper?
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| except RuntimeError: | ||
| continue # all CONTEXT_MAX_WATCHERS slots taken |
There was a problem 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?
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.