https://github.com/python/cpython/commit/a56532771a9e24689576a0c38146f0ab2d82b491
commit: a56532771a9e24689576a0c38146f0ab2d82b491
branch: main
author: Alper <[email protected]>
committer: DinoV <[email protected]>
date: 2026-02-20T10:52:18-08:00
summary:
gh-144981: Make PyUnstable_Code_SetExtra/GetExtra thread-safe (#144980)
* Make PyUnstable_Code_SetExtra/GetExtra thread-safe
files:
A Misc/NEWS.d/next/C_API/2026-02-18-15-12-34.gh-issue-144981.4ZdM63.rst
M Lib/test/test_free_threading/test_code.py
M Objects/codeobject.c
M Python/ceval.c
diff --git a/Lib/test/test_free_threading/test_code.py
b/Lib/test/test_free_threading/test_code.py
index a5136a3ba4edc7..2fc5eea3773c39 100644
--- a/Lib/test/test_free_threading/test_code.py
+++ b/Lib/test/test_free_threading/test_code.py
@@ -1,9 +1,41 @@
import unittest
+try:
+ import ctypes
+except ImportError:
+ ctypes = None
+
from threading import Thread
from unittest import TestCase
from test.support import threading_helper
+from test.support.threading_helper import run_concurrently
+
+if ctypes is not None:
+ capi = ctypes.pythonapi
+
+ freefunc = ctypes.CFUNCTYPE(None, ctypes.c_voidp)
+
+ RequestCodeExtraIndex = capi.PyUnstable_Eval_RequestCodeExtraIndex
+ RequestCodeExtraIndex.argtypes = (freefunc,)
+ RequestCodeExtraIndex.restype = ctypes.c_ssize_t
+
+ SetExtra = capi.PyUnstable_Code_SetExtra
+ SetExtra.argtypes = (ctypes.py_object, ctypes.c_ssize_t, ctypes.c_voidp)
+ SetExtra.restype = ctypes.c_int
+
+ GetExtra = capi.PyUnstable_Code_GetExtra
+ GetExtra.argtypes = (
+ ctypes.py_object,
+ ctypes.c_ssize_t,
+ ctypes.POINTER(ctypes.c_voidp),
+ )
+ GetExtra.restype = ctypes.c_int
+
+# Note: each call to RequestCodeExtraIndex permanently allocates a slot
+# (the counter is monotonically increasing), up to MAX_CO_EXTRA_USERS (255).
+NTHREADS = 20
+
@threading_helper.requires_working_threading()
class TestCode(TestCase):
@@ -25,6 +57,83 @@ def run_in_thread():
for thread in threads:
thread.join()
+ @unittest.skipUnless(ctypes, "ctypes is required")
+ def test_request_code_extra_index_concurrent(self):
+ """Test concurrent calls to RequestCodeExtraIndex"""
+ results = []
+
+ def worker():
+ idx = RequestCodeExtraIndex(freefunc(0))
+ self.assertGreaterEqual(idx, 0)
+ results.append(idx)
+
+ run_concurrently(worker_func=worker, nthreads=NTHREADS)
+
+ # Every thread must get a unique index.
+ self.assertEqual(len(results), NTHREADS)
+ self.assertEqual(len(set(results)), NTHREADS)
+
+ @unittest.skipUnless(ctypes, "ctypes is required")
+ def test_code_extra_all_ops_concurrent(self):
+ """Test concurrent RequestCodeExtraIndex + SetExtra + GetExtra"""
+ LOOP = 100
+
+ def f():
+ pass
+
+ code = f.__code__
+
+ def worker():
+ idx = RequestCodeExtraIndex(freefunc(0))
+ self.assertGreaterEqual(idx, 0)
+
+ for i in range(LOOP):
+ ret = SetExtra(code, idx, ctypes.c_voidp(i + 1))
+ self.assertEqual(ret, 0)
+
+ for _ in range(LOOP):
+ extra = ctypes.c_voidp()
+ ret = GetExtra(code, idx, extra)
+ self.assertEqual(ret, 0)
+ # The slot was set by this thread, so the value must
+ # be the last one written.
+ self.assertEqual(extra.value, LOOP)
+
+ run_concurrently(worker_func=worker, nthreads=NTHREADS)
+
+ @unittest.skipUnless(ctypes, "ctypes is required")
+ def test_code_extra_set_get_concurrent(self):
+ """Test concurrent SetExtra + GetExtra on a shared index"""
+ LOOP = 100
+
+ def f():
+ pass
+
+ code = f.__code__
+
+ idx = RequestCodeExtraIndex(freefunc(0))
+ self.assertGreaterEqual(idx, 0)
+
+ def worker():
+ for i in range(LOOP):
+ ret = SetExtra(code, idx, ctypes.c_voidp(i + 1))
+ self.assertEqual(ret, 0)
+
+ for _ in range(LOOP):
+ extra = ctypes.c_voidp()
+ ret = GetExtra(code, idx, extra)
+ self.assertEqual(ret, 0)
+ # Value is set by any writer thread.
+ self.assertTrue(1 <= extra.value <= LOOP)
+
+ run_concurrently(worker_func=worker, nthreads=NTHREADS)
+
+ # Every thread's last write is LOOP, so the final value must be LOOP.
+ extra = ctypes.c_voidp()
+ ret = GetExtra(code, idx, extra)
+ self.assertEqual(ret, 0)
+ self.assertEqual(extra.value, LOOP)
+
if __name__ == "__main__":
unittest.main()
diff --git
a/Misc/NEWS.d/next/C_API/2026-02-18-15-12-34.gh-issue-144981.4ZdM63.rst
b/Misc/NEWS.d/next/C_API/2026-02-18-15-12-34.gh-issue-144981.4ZdM63.rst
new file mode 100644
index 00000000000000..d86886ab09704a
--- /dev/null
+++ b/Misc/NEWS.d/next/C_API/2026-02-18-15-12-34.gh-issue-144981.4ZdM63.rst
@@ -0,0 +1,3 @@
+Made :c:func:`PyUnstable_Code_SetExtra`, :c:func:`PyUnstable_Code_GetExtra`,
+and :c:func:`PyUnstable_Eval_RequestCodeExtraIndex` thread-safe on the
+:term:`free threaded <free threading>` build.
diff --git a/Objects/codeobject.c b/Objects/codeobject.c
index 776444a0cc2086..520190824fbf1a 100644
--- a/Objects/codeobject.c
+++ b/Objects/codeobject.c
@@ -1575,6 +1575,67 @@ typedef struct {
} _PyCodeObjectExtra;
+static inline size_t
+code_extra_size(Py_ssize_t n)
+{
+ return sizeof(_PyCodeObjectExtra) + (n - 1) * sizeof(void *);
+}
+
+#ifdef Py_GIL_DISABLED
+static int
+code_extra_grow_ft(PyCodeObject *co, _PyCodeObjectExtra *old_co_extra,
+ Py_ssize_t old_ce_size, Py_ssize_t new_ce_size,
+ Py_ssize_t index, void *extra)
+{
+ _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(co);
+ _PyCodeObjectExtra *new_co_extra = PyMem_Malloc(
+ code_extra_size(new_ce_size));
+ if (new_co_extra == NULL) {
+ PyErr_NoMemory();
+ return -1;
+ }
+
+ if (old_ce_size > 0) {
+ memcpy(new_co_extra->ce_extras, old_co_extra->ce_extras,
+ old_ce_size * sizeof(void *));
+ }
+ for (Py_ssize_t i = old_ce_size; i < new_ce_size; i++) {
+ new_co_extra->ce_extras[i] = NULL;
+ }
+ new_co_extra->ce_size = new_ce_size;
+ new_co_extra->ce_extras[index] = extra;
+
+ // Publish new buffer and its contents to lock-free readers.
+ FT_ATOMIC_STORE_PTR_RELEASE(co->co_extra, new_co_extra);
+ if (old_co_extra != NULL) {
+ // QSBR: defer old-buffer free until lock-free readers quiesce.
+ _PyMem_FreeDelayed(old_co_extra, code_extra_size(old_ce_size));
+ }
+ return 0;
+}
+#else
+static int
+code_extra_grow_gil(PyCodeObject *co, _PyCodeObjectExtra *old_co_extra,
+ Py_ssize_t old_ce_size, Py_ssize_t new_ce_size,
+ Py_ssize_t index, void *extra)
+{
+ _PyCodeObjectExtra *new_co_extra = PyMem_Realloc(
+ old_co_extra, code_extra_size(new_ce_size));
+ if (new_co_extra == NULL) {
+ PyErr_NoMemory();
+ return -1;
+ }
+
+ for (Py_ssize_t i = old_ce_size; i < new_ce_size; i++) {
+ new_co_extra->ce_extras[i] = NULL;
+ }
+ new_co_extra->ce_size = new_ce_size;
+ new_co_extra->ce_extras[index] = extra;
+ co->co_extra = new_co_extra;
+ return 0;
+}
+#endif
+
int
PyUnstable_Code_GetExtra(PyObject *code, Py_ssize_t index, void **extra)
{
@@ -1583,15 +1644,19 @@ PyUnstable_Code_GetExtra(PyObject *code, Py_ssize_t
index, void **extra)
return -1;
}
- PyCodeObject *o = (PyCodeObject*) code;
- _PyCodeObjectExtra *co_extra = (_PyCodeObjectExtra*) o->co_extra;
+ PyCodeObject *co = (PyCodeObject *)code;
+ *extra = NULL;
- if (co_extra == NULL || index < 0 || co_extra->ce_size <= index) {
- *extra = NULL;
+ if (index < 0) {
return 0;
}
- *extra = co_extra->ce_extras[index];
+ // Lock-free read; pairs with release stores in SetExtra.
+ _PyCodeObjectExtra *co_extra = FT_ATOMIC_LOAD_PTR_ACQUIRE(co->co_extra);
+ if (co_extra != NULL && index < co_extra->ce_size) {
+ *extra = FT_ATOMIC_LOAD_PTR_ACQUIRE(co_extra->ce_extras[index]);
+ }
+
return 0;
}
@@ -1601,40 +1666,59 @@ PyUnstable_Code_SetExtra(PyObject *code, Py_ssize_t
index, void *extra)
{
PyInterpreterState *interp = _PyInterpreterState_GET();
- if (!PyCode_Check(code) || index < 0 ||
- index >= interp->co_extra_user_count) {
+ // co_extra_user_count is monotonically increasing and published with
+ // release store in RequestCodeExtraIndex, so once an index is valid
+ // it stays valid.
+ Py_ssize_t user_count = FT_ATOMIC_LOAD_SSIZE_ACQUIRE(
+ interp->co_extra_user_count);
+
+ if (!PyCode_Check(code) || index < 0 || index >= user_count) {
PyErr_BadInternalCall();
return -1;
}
- PyCodeObject *o = (PyCodeObject*) code;
- _PyCodeObjectExtra *co_extra = (_PyCodeObjectExtra *) o->co_extra;
+ PyCodeObject *co = (PyCodeObject *)code;
+ int result = 0;
+ void *old_slot_value = NULL;
- if (co_extra == NULL || co_extra->ce_size <= index) {
- Py_ssize_t i = (co_extra == NULL ? 0 : co_extra->ce_size);
- co_extra = PyMem_Realloc(
- co_extra,
- sizeof(_PyCodeObjectExtra) +
- (interp->co_extra_user_count-1) * sizeof(void*));
- if (co_extra == NULL) {
- return -1;
- }
- for (; i < interp->co_extra_user_count; i++) {
- co_extra->ce_extras[i] = NULL;
- }
- co_extra->ce_size = interp->co_extra_user_count;
- o->co_extra = co_extra;
+ Py_BEGIN_CRITICAL_SECTION(co);
+
+ _PyCodeObjectExtra *old_co_extra = (_PyCodeObjectExtra *)co->co_extra;
+ Py_ssize_t old_ce_size = (old_co_extra == NULL)
+ ? 0 : old_co_extra->ce_size;
+
+ // Fast path: slot already exists, update in place.
+ if (index < old_ce_size) {
+ old_slot_value = old_co_extra->ce_extras[index];
+ FT_ATOMIC_STORE_PTR_RELEASE(old_co_extra->ce_extras[index], extra);
+ goto done;
}
- if (co_extra->ce_extras[index] != NULL) {
- freefunc free = interp->co_extra_freefuncs[index];
- if (free != NULL) {
- free(co_extra->ce_extras[index]);
+ // Slow path: buffer needs to grow.
+ Py_ssize_t new_ce_size = user_count;
+#ifdef Py_GIL_DISABLED
+ // FT build: allocate new buffer and swap; QSBR reclaims the old one.
+ result = code_extra_grow_ft(
+ co, old_co_extra, old_ce_size, new_ce_size, index, extra);
+#else
+ // GIL build: grow with realloc.
+ result = code_extra_grow_gil(
+ co, old_co_extra, old_ce_size, new_ce_size, index, extra);
+#endif
+
+done:;
+ Py_END_CRITICAL_SECTION();
+ if (old_slot_value != NULL) {
+ // Free the old slot value if a free function was registered.
+ // The caller must ensure no other thread can still access the old
+ // value after this overwrite.
+ freefunc free_extra = interp->co_extra_freefuncs[index];
+ if (free_extra != NULL) {
+ free_extra(old_slot_value);
}
}
- co_extra->ce_extras[index] = extra;
- return 0;
+ return result;
}
diff --git a/Python/ceval.c b/Python/ceval.c
index 8e905a5e689ed9..2cd7c7bfd28d09 100644
--- a/Python/ceval.c
+++ b/Python/ceval.c
@@ -3493,11 +3493,27 @@ PyUnstable_Eval_RequestCodeExtraIndex(freefunc free)
PyInterpreterState *interp = _PyInterpreterState_GET();
Py_ssize_t new_index;
- if (interp->co_extra_user_count == MAX_CO_EXTRA_USERS - 1) {
+#ifdef Py_GIL_DISABLED
+ struct _py_code_state *state = &interp->code_state;
+ FT_MUTEX_LOCK(&state->mutex);
+#endif
+
+ if (interp->co_extra_user_count >= MAX_CO_EXTRA_USERS - 1) {
+#ifdef Py_GIL_DISABLED
+ FT_MUTEX_UNLOCK(&state->mutex);
+#endif
return -1;
}
- new_index = interp->co_extra_user_count++;
+
+ new_index = interp->co_extra_user_count;
interp->co_extra_freefuncs[new_index] = free;
+
+ // Publish freefuncs[new_index] before making the index visible.
+ FT_ATOMIC_STORE_SSIZE_RELEASE(interp->co_extra_user_count, new_index + 1);
+
+#ifdef Py_GIL_DISABLED
+ FT_MUTEX_UNLOCK(&state->mutex);
+#endif
return new_index;
}
_______________________________________________
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]