https://github.com/python/cpython/commit/a01694dacd8e8bbbd019e8b87dbc7bc7173cb7f1
commit: a01694dacd8e8bbbd019e8b87dbc7bc7173cb7f1
branch: main
author: Sam Gross <[email protected]>
committer: colesbury <[email protected]>
date: 2026-01-30T12:20:27-05:00
summary:
gh-120321: Make gi_yieldfrom thread-safe in free-threading build (#144292)
Add a FRAME_SUSPENDED_YIELD_FROM_LOCKED state that acts as a brief
lock, preventing other threads from transitioning the frame state
while gen_getyieldfrom reads the yield-from object off the stack.
files:
A
Misc/NEWS.d/next/Core_and_Builtins/2026-01-27-17-49-43.gh-issue-120321.Vo7c9T.rst
M Include/internal/pycore_frame.h
M Include/internal/pycore_lock.h
M Lib/test/support/threading_helper.py
M Lib/test/test_free_threading/test_generators.py
M Objects/genobject.c
M Python/ceval.c
M Python/ceval_macros.h
M Python/lock.c
diff --git a/Include/internal/pycore_frame.h b/Include/internal/pycore_frame.h
index 8c410e9e208340..50908f2cb7a1d2 100644
--- a/Include/internal/pycore_frame.h
+++ b/Include/internal/pycore_frame.h
@@ -44,15 +44,16 @@ extern PyFrameObject* _PyFrame_New_NoTrack(PyCodeObject
*code);
/* other API */
typedef enum _framestate {
- FRAME_CREATED = -3,
- FRAME_SUSPENDED = -2,
- FRAME_SUSPENDED_YIELD_FROM = -1,
+ FRAME_CREATED = -4,
+ FRAME_SUSPENDED = -3,
+ FRAME_SUSPENDED_YIELD_FROM = -2,
+ FRAME_SUSPENDED_YIELD_FROM_LOCKED = -1,
FRAME_EXECUTING = 0,
FRAME_COMPLETED = 1,
FRAME_CLEARED = 4
} PyFrameState;
-#define FRAME_STATE_SUSPENDED(S) ((S) == FRAME_SUSPENDED || (S) ==
FRAME_SUSPENDED_YIELD_FROM)
+#define FRAME_STATE_SUSPENDED(S) ((S) >= FRAME_SUSPENDED && (S) <=
FRAME_SUSPENDED_YIELD_FROM_LOCKED)
#define FRAME_STATE_FINISHED(S) ((S) >= FRAME_COMPLETED)
#ifdef __cplusplus
diff --git a/Include/internal/pycore_lock.h b/Include/internal/pycore_lock.h
index c4e007e744ce0f..e31d8b4e5c68c9 100644
--- a/Include/internal/pycore_lock.h
+++ b/Include/internal/pycore_lock.h
@@ -70,6 +70,9 @@ PyMutex_LockFlags(PyMutex *m, _PyLockFlags flags)
// error messages) otherwise returns 0.
extern int _PyMutex_TryUnlock(PyMutex *m);
+// Yield the processor to other threads (e.g., sched_yield).
+extern void _Py_yield(void);
+
// PyEvent is a one-time event notification
typedef struct {
diff --git a/Lib/test/support/threading_helper.py
b/Lib/test/support/threading_helper.py
index 3e04c344a0d66f..cf87233f0e2e93 100644
--- a/Lib/test/support/threading_helper.py
+++ b/Lib/test/support/threading_helper.py
@@ -250,21 +250,32 @@ def requires_working_threading(*, module=False):
return unittest.skipUnless(can_start_thread, msg)
-def run_concurrently(worker_func, nthreads, args=(), kwargs={}):
+def run_concurrently(worker_func, nthreads=None, args=(), kwargs={}):
"""
- Run the worker function concurrently in multiple threads.
+ Run the worker function(s) concurrently in multiple threads.
+
+ If `worker_func` is a single callable, it is used for all threads.
+ If it is a list of callables, each callable is used for one thread.
"""
+ from collections.abc import Iterable
+
+ if nthreads is None:
+ nthreads = len(worker_func)
+ if not isinstance(worker_func, Iterable):
+ worker_func = [worker_func] * nthreads
+ assert len(worker_func) == nthreads
+
barrier = threading.Barrier(nthreads)
- def wrapper_func(*args, **kwargs):
+ def wrapper_func(func, *args, **kwargs):
# Wait for all threads to reach this point before proceeding.
barrier.wait()
- worker_func(*args, **kwargs)
+ func(*args, **kwargs)
with catch_threading_exception() as cm:
workers = [
- threading.Thread(target=wrapper_func, args=args, kwargs=kwargs)
- for _ in range(nthreads)
+ threading.Thread(target=wrapper_func, args=(func, *args),
kwargs=kwargs)
+ for func in worker_func
]
with start_threads(workers):
pass
diff --git a/Lib/test/test_free_threading/test_generators.py
b/Lib/test/test_free_threading/test_generators.py
index 11f59301bcd51d..2b41e28896f5a8 100644
--- a/Lib/test/test_free_threading/test_generators.py
+++ b/Lib/test/test_free_threading/test_generators.py
@@ -1,4 +1,6 @@
import concurrent.futures
+import itertools
+import threading
import unittest
from threading import Barrier
from unittest import TestCase
@@ -120,3 +122,38 @@ def drive_generator(g):
g = gen()
threading_helper.run_concurrently(drive_generator, self.NUM_THREADS,
args=(g,))
+
+ def test_concurrent_gi_yieldfrom(self):
+ def gen_yield_from():
+ yield from itertools.count()
+
+ g = gen_yield_from()
+ next(g) # Put in FRAME_SUSPENDED_YIELD_FROM state
+
+ def read_yieldfrom(gen):
+ for _ in range(10000):
+ self.assertIsNotNone(gen.gi_yieldfrom)
+
+ threading_helper.run_concurrently(read_yieldfrom, self.NUM_THREADS,
args=(g,))
+
+ def test_gi_yieldfrom_close_race(self):
+ def gen_yield_from():
+ yield from itertools.count()
+
+ g = gen_yield_from()
+ next(g)
+
+ done = threading.Event()
+
+ def reader():
+ while not done.is_set():
+ g.gi_yieldfrom
+
+ def closer():
+ try:
+ g.close()
+ except ValueError:
+ pass
+ done.set()
+
+ threading_helper.run_concurrently([reader, closer])
diff --git
a/Misc/NEWS.d/next/Core_and_Builtins/2026-01-27-17-49-43.gh-issue-120321.Vo7c9T.rst
b/Misc/NEWS.d/next/Core_and_Builtins/2026-01-27-17-49-43.gh-issue-120321.Vo7c9T.rst
new file mode 100644
index 00000000000000..052ed07c123bcf
--- /dev/null
+++
b/Misc/NEWS.d/next/Core_and_Builtins/2026-01-27-17-49-43.gh-issue-120321.Vo7c9T.rst
@@ -0,0 +1,2 @@
+Made ``gi_yieldfrom`` thread-safe in the free-threading build
+by using a lightweight lock on the frame state.
diff --git a/Objects/genobject.c b/Objects/genobject.c
index fcdb9017a35f5b..5ff4618255c852 100644
--- a/Objects/genobject.c
+++ b/Objects/genobject.c
@@ -10,6 +10,7 @@
#include "pycore_gc.h" // _PyGC_CLEAR_FINALIZED()
#include "pycore_genobject.h" // _PyGen_SetStopIterationValue()
#include "pycore_interpframe.h" // _PyFrame_GetCode()
+#include "pycore_lock.h" // _Py_yield()
#include "pycore_modsupport.h" // _PyArg_CheckPositional()
#include "pycore_object.h" // _PyObject_GC_UNTRACK()
#include "pycore_opcode_utils.h" // RESUME_AFTER_YIELD_FROM
@@ -37,8 +38,20 @@ static PyObject* async_gen_athrow_new(PyAsyncGenObject *,
PyObject *);
_Py_CAST(PyAsyncGenObject*, (op))
#ifdef Py_GIL_DISABLED
+static bool
+gen_try_set_frame_state(PyGenObject *gen, int8_t *expected, int8_t state)
+{
+ if (*expected == FRAME_SUSPENDED_YIELD_FROM_LOCKED) {
+ // Wait for the in-progress gi_yieldfrom read to complete
+ _Py_yield();
+ *expected = _Py_atomic_load_int8_relaxed(&gen->gi_frame_state);
+ return false;
+ }
+ return _Py_atomic_compare_exchange_int8(&gen->gi_frame_state, expected,
state);
+}
+
# define _Py_GEN_TRY_SET_FRAME_STATE(gen, expected, state) \
- _Py_atomic_compare_exchange_int8(&(gen)->gi_frame_state, &expected,
(state))
+ gen_try_set_frame_state((gen), &(expected), (state))
#else
# define _Py_GEN_TRY_SET_FRAME_STATE(gen, expected, state) \
((gen)->gi_frame_state = (state), true)
@@ -470,9 +483,7 @@ gen_close(PyObject *self, PyObject *args)
return NULL;
}
- assert(frame_state == FRAME_SUSPENDED_YIELD_FROM ||
- frame_state == FRAME_SUSPENDED);
-
+ assert(FRAME_STATE_SUSPENDED(frame_state));
} while (!_Py_GEN_TRY_SET_FRAME_STATE(gen, frame_state, FRAME_EXECUTING));
int err = 0;
@@ -876,12 +887,26 @@ static PyObject *
gen_getyieldfrom(PyObject *self, void *Py_UNUSED(ignored))
{
PyGenObject *gen = _PyGen_CAST(self);
- int8_t frame_state = FT_ATOMIC_LOAD_INT8_RELAXED(gen->gi_frame_state);
+#ifdef Py_GIL_DISABLED
+ int8_t frame_state = _Py_atomic_load_int8_relaxed(&gen->gi_frame_state);
+ do {
+ if (frame_state != FRAME_SUSPENDED_YIELD_FROM &&
+ frame_state != FRAME_SUSPENDED_YIELD_FROM_LOCKED)
+ {
+ Py_RETURN_NONE;
+ }
+ } while (!_Py_GEN_TRY_SET_FRAME_STATE(gen, frame_state,
FRAME_SUSPENDED_YIELD_FROM_LOCKED));
+
+ PyObject *result =
PyStackRef_AsPyObjectNew(_PyFrame_StackPeek(&gen->gi_iframe));
+ _Py_atomic_store_int8_release(&gen->gi_frame_state,
FRAME_SUSPENDED_YIELD_FROM);
+ return result;
+#else
+ int8_t frame_state = gen->gi_frame_state;
if (frame_state != FRAME_SUSPENDED_YIELD_FROM) {
Py_RETURN_NONE;
}
- // TODO: still not thread-safe with free threading
return PyStackRef_AsPyObjectNew(_PyFrame_StackPeek(&gen->gi_iframe));
+#endif
}
diff --git a/Python/ceval.c b/Python/ceval.c
index 04ae7b4d86f9d0..c59f20bbf1e803 100644
--- a/Python/ceval.c
+++ b/Python/ceval.c
@@ -3391,7 +3391,9 @@ _PyEval_GetAwaitable(PyObject *iterable, int oparg)
else if (PyCoro_CheckExact(iter)) {
PyCoroObject *coro = (PyCoroObject *)iter;
int8_t frame_state = FT_ATOMIC_LOAD_INT8_RELAXED(coro->cr_frame_state);
- if (frame_state == FRAME_SUSPENDED_YIELD_FROM) {
+ if (frame_state == FRAME_SUSPENDED_YIELD_FROM ||
+ frame_state == FRAME_SUSPENDED_YIELD_FROM_LOCKED)
+ {
/* `iter` is a coroutine object that is being awaited. */
Py_CLEAR(iter);
_PyErr_SetString(PyThreadState_GET(), PyExc_RuntimeError,
diff --git a/Python/ceval_macros.h b/Python/ceval_macros.h
index 1cbeb18d02c082..b127812b4bf703 100644
--- a/Python/ceval_macros.h
+++ b/Python/ceval_macros.h
@@ -522,19 +522,22 @@ gen_try_set_executing(PyGenObject *gen)
#ifdef Py_GIL_DISABLED
if (!_PyObject_IsUniquelyReferenced((PyObject *)gen)) {
int8_t frame_state =
_Py_atomic_load_int8_relaxed(&gen->gi_frame_state);
- while (frame_state < FRAME_EXECUTING) {
+ while (frame_state < FRAME_SUSPENDED_YIELD_FROM_LOCKED) {
if (_Py_atomic_compare_exchange_int8(&gen->gi_frame_state,
&frame_state,
FRAME_EXECUTING)) {
return true;
}
}
+ // NB: We return false for FRAME_SUSPENDED_YIELD_FROM_LOCKED as well.
+ // That case is rare enough that we can just handle it in the deopt.
return false;
}
#endif
// Use faster non-atomic modifications in the GIL-enabled build and when
// the object is uniquely referenced in the free-threaded build.
if (gen->gi_frame_state < FRAME_EXECUTING) {
+ assert(gen->gi_frame_state != FRAME_SUSPENDED_YIELD_FROM_LOCKED);
gen->gi_frame_state = FRAME_EXECUTING;
return true;
}
diff --git a/Python/lock.c b/Python/lock.c
index 12b5ebc89aeec7..ad97bfd93c8495 100644
--- a/Python/lock.c
+++ b/Python/lock.c
@@ -40,7 +40,7 @@ struct mutex_entry {
int handed_off;
};
-static void
+void
_Py_yield(void)
{
#ifdef MS_WINDOWS
_______________________________________________
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]