Repository navigation
sys._setprofileallthreads race condition #137400
Description
Activity
- addedtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error3.13only security fixesonly security fixes3.14bugs and security fixesbugs and security fixes3.15bugs and security fixesbugs and security fixes
on Aug 5, 2025 cc @pablogsal
Seems like this could be fixed by adding
LOCK_SETUPandUNLOCK_SETUPbefore and after C profile function calls to prevent concurrent modifications. If this approach is acceptable, I may raise an PR for that.- addedinterpreter-core(Objects, Python, Grammar, and Parser dirs)(Objects, Python, Grammar, and Parser dirs)
on Aug 5, 2025 Seems like this could be fixed by adding LOCK_SETUP and UNLOCK_SETUP before and after C profile function calls to prevent concurrent modifications. If this approach is acceptable, I may raise an PR for that.
No, we don't want to lock before every C profile function call. We need to refactor how we install/uninstall trace hooks.
Reacted by Xuanteng HuangI see two options that shouldn't kill performance:
- Switch
c_profilefuncandc_profileobjreads and writes to atomic operations, and then load them each a single time incall_profile_func. - Use an RW lock for
call_profile_func, where_PyEval_SetProfilehas exclusive access.
Which do you think makes the most sense?
- Switch
I think the eventual goal should be to perform all the setup under:
- A stop-the-world pause
HEAD_LOCK(runtime)for things likePyEval_SetProfileAllThreadsthat currently iterate over all threads. Currently the iteration is unsafe (even with the GIL) and may crash if a thread concurrently terminates.
That means we would need to refactor things so that:
_PySys_Auditcalls happen outside the stop the world and lock- Any objects with non-trivial destructors get collected and decref'd only oustide the lock and stop-the world pause
Near term, we may want to consider a smaller change where we just add another stop-the-world pause around the modifications to
tstate->c_tracefuncand similar.Near term, we may want to consider a smaller change where we just add another stop-the-world pause around the modifications to
tstate->c_tracefuncand similar.This small patch seems to be enough to fix the crash in your reproducer (and in Memray, too!):
diff --git a/Python/legacy_tracing.c b/Python/legacy_tracing.c index dbd19d7755c..0a0d231cb12 100644 --- a/Python/legacy_tracing.c +++ b/Python/legacy_tracing.c @@ -484,13 +484,19 @@ setup_profile(PyThreadState *tstate, Py_tracefunc func, PyObject *arg, PyObject } } + _PyEval_StopTheWorld(tstate->interp); + int delta = (func != NULL) - (tstate->c_profilefunc != NULL); tstate->c_profilefunc = func; *old_profileobj = tstate->c_profileobj; tstate->c_profileobj = Py_XNewRef(arg); tstate->interp->sys_profiling_threads += delta; assert(tstate->interp->sys_profiling_threads >= 0); - return tstate->interp->sys_profiling_threads; + Py_ssize_t ret = tstate->interp->sys_profiling_threads; + + _PyEval_StartTheWorld(tstate->interp); + + return ret; } int
But that pays the cost of a stop-the-world even if we're setting the profile function for our own attached thread state, and still accesses
tstate->interp->sys_profile_initializedfrom a different thread without holding a lock... Maybe we need something more like this?diff --git a/Python/legacy_tracing.c b/Python/legacy_tracing.c index dbd19d7755c..4f08ed5c2af 100644 --- a/Python/legacy_tracing.c +++ b/Python/legacy_tracing.c @@ -439,12 +439,24 @@ is_tstate_valid(PyThreadState *tstate) #endif static Py_ssize_t -setup_profile(PyThreadState *tstate, Py_tracefunc func, PyObject *arg, PyObject **old_profileobj) +setup_profile(PyThreadState *tstate, Py_tracefunc func, PyObject *arg, PyObject **old_profileobj, int different_tstate) { *old_profileobj = NULL; + + if (different_tstate) { + /* Stop the world: we're modifying another thread's thread state. */ + _PyEval_StopTheWorld(tstate->interp); + } + /* Setup PEP 669 monitoring callbacks and events. */ if (!tstate->interp->sys_profile_initialized) { tstate->interp->sys_profile_initialized = true; + + if (different_tstate) { + /* set_callbacks can't be called with the world stopped. */ + _PyEval_StartTheWorld(tstate->interp); + } + if (set_callbacks(PY_MONITORING_SYS_PROFILE_ID, sys_profile_start, PyTrace_CALL, PY_MONITORING_EVENT_PY_START, @@ -482,6 +494,11 @@ setup_profile(PyThreadState *tstate, Py_tracefunc func, PyObject *arg, PyObject PY_MONITORING_EVENT_C_RAISE, -1)) { return -1; } + + if (different_tstate) { + /* re-stop the world after set_callbacks. */ + _PyEval_StopTheWorld(tstate->interp); + } } int delta = (func != NULL) - (tstate->c_profilefunc != NULL); @@ -490,7 +507,13 @@ setup_profile(PyThreadState *tstate, Py_tracefunc func, PyObject *arg, PyObject tstate->c_profileobj = Py_XNewRef(arg); tstate->interp->sys_profiling_threads += delta; assert(tstate->interp->sys_profiling_threads >= 0); - return tstate->interp->sys_profiling_threads; + Py_ssize_t ret = tstate->interp->sys_profiling_threads; + + if (different_tstate) { + _PyEval_StartTheWorld(tstate->interp); + } + + return ret; } int @@ -510,7 +533,8 @@ _PyEval_SetProfile(PyThreadState *tstate, Py_tracefunc func, PyObject *arg) // needs to be decref'd outside of the lock PyObject *old_profileobj; LOCK_SETUP(); - Py_ssize_t profiling_threads = setup_profile(tstate, func, arg, &old_profileobj); + Py_ssize_t profiling_threads = setup_profile( + tstate, func, arg, &old_profileobj, tstate != current_tstate); UNLOCK_SETUP(); Py_XDECREF(old_profileobj);
I think the simpler change is probably okay for now, even at the cost of an extra stop-the-world pause.
Later on, I'll refactor it so that
PyEval_SetProfileAllThreadsstops the world once instead of 1xNTHREADS or 2xNTHREADS.... and still accesses tstate->interp->sys_profile_initialized from a different thread without holding a lock
It's currently within a
LOCK_SETUP()UNLOCK_SETUP()call, so I think it's okay.I think the simpler change is probably okay for now, even at the cost of an extra stop-the-world pause.
Yeah I think this is fine as this happens only on setting/unsetting and is simpler to reason about. Maybe a bit trickier at finalization....
11 remaining items
- added a commit that references this issue
on Aug 12, 2025 - added a commit that references this issue
on Aug 12, 2025 - marked PyEval_SetProfileAllThreads is racy under free-threading #132817 as a duplicate of this issue
on Aug 31, 2025 - added a commit that references this issue
on Oct 7, 2025 - added a commit that references this issue
on Oct 9, 2025
Bug report
There's a race on
tstate->c_profilefuncif profiling is disable concurrently viasys._setprofileallthreadsorthreading.setprofile_all_threadsorPyEval_SetProfileAllThreads.cpython/Python/legacy_tracing.c
Lines 37 to 57 in 001461a
Repro
Linked PRs