https://github.com/python/cpython/commit/4bb1dadff42980eb133587f5798f20fb0354f039
commit: 4bb1dadff42980eb133587f5798f20fb0354f039
branch: main
author: Neil Schemenauer <[email protected]>
committer: nascheme <[email protected]>
date: 2026-10-20T12:33:27-07:00
summary:

gh-158197: Fix split dict iteration during concurrent deletion (gh-158686)

Read insertion order from the iterator's captured values snapshot instead
of re-reading ma_values and asserting against a concurrently changing
size. Retry under the dictionary lock when a value slot has been cleared.

Make insertion-order writes atomic and publish size changes with release
stores, pairing with the iterator's size load. Keep order-byte accesses
relaxed. Add _Py_atomic_store_uint8_release() and
FT_ATOMIC_STORE_UINT8_RELEASE() for this.

files:
A 
Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-13-00-00.gh-issue-158197.Qk7sVd.rst
M Include/cpython/pyatomic.h
M Include/cpython/pyatomic_gcc.h
M Include/cpython/pyatomic_msc.h
M Include/cpython/pyatomic_std.h
M Include/internal/pycore_dict.h
M Include/internal/pycore_pyatomic_ft_wrappers.h
M Lib/test/test_free_threading/test_dict.py
M Objects/dictobject.c

diff --git a/Include/cpython/pyatomic.h b/Include/cpython/pyatomic.h
index e85b360c986668c..b4b1a8ca87dd8bd 100644
--- a/Include/cpython/pyatomic.h
+++ b/Include/cpython/pyatomic.h
@@ -526,6 +526,9 @@ _Py_atomic_store_ssize_release(Py_ssize_t *obj, Py_ssize_t 
value);
 static inline void
 _Py_atomic_store_int8_release(int8_t *obj, int8_t value);
 
+static inline void
+_Py_atomic_store_uint8_release(uint8_t *obj, uint8_t value);
+
 static inline void
 _Py_atomic_store_int_release(int *obj, int value);
 
diff --git a/Include/cpython/pyatomic_gcc.h b/Include/cpython/pyatomic_gcc.h
index 253b35082aafcd2..c2441a1eb913920 100644
--- a/Include/cpython/pyatomic_gcc.h
+++ b/Include/cpython/pyatomic_gcc.h
@@ -576,6 +576,10 @@ static inline void
 _Py_atomic_store_int8_release(int8_t *obj, int8_t value)
 { __atomic_store_n(obj, value, __ATOMIC_RELEASE); }
 
+static inline void
+_Py_atomic_store_uint8_release(uint8_t *obj, uint8_t value)
+{ __atomic_store_n(obj, value, __ATOMIC_RELEASE); }
+
 static inline void
 _Py_atomic_store_ssize_release(Py_ssize_t *obj, Py_ssize_t value)
 { __atomic_store_n(obj, value, __ATOMIC_RELEASE); }
diff --git a/Include/cpython/pyatomic_msc.h b/Include/cpython/pyatomic_msc.h
index 3b3c5f7017e9575..ef5b6d2d71c5c09 100644
--- a/Include/cpython/pyatomic_msc.h
+++ b/Include/cpython/pyatomic_msc.h
@@ -1073,6 +1073,19 @@ _Py_atomic_store_int8_release(int8_t *obj, int8_t value)
 #endif
 }
 
+static inline void
+_Py_atomic_store_uint8_release(uint8_t *obj, uint8_t value)
+{
+#if defined(_M_X64) || defined(_M_IX86)
+    *(uint8_t volatile *)obj = value;
+#elif defined(_M_ARM64)
+    _Py_atomic_ASSERT_ARG_TYPE(unsigned __int8);
+    __stlr8((unsigned __int8 volatile *)obj, (unsigned __int8)value);
+#else
+#  error "no implementation of _Py_atomic_store_uint8_release"
+#endif
+}
+
 static inline void
 _Py_atomic_store_uint_release(unsigned int *obj, unsigned int value)
 {
diff --git a/Include/cpython/pyatomic_std.h b/Include/cpython/pyatomic_std.h
index faef303da70314c..b8540ff3baf20a6 100644
--- a/Include/cpython/pyatomic_std.h
+++ b/Include/cpython/pyatomic_std.h
@@ -1031,6 +1031,14 @@ _Py_atomic_store_int8_release(int8_t *obj, int8_t value)
                           memory_order_release);
 }
 
+static inline void
+_Py_atomic_store_uint8_release(uint8_t *obj, uint8_t value)
+{
+    _Py_USING_STD;
+    atomic_store_explicit((_Atomic(uint8_t)*)obj, value,
+                          memory_order_release);
+}
+
 static inline void
 _Py_atomic_store_uint_release(unsigned int *obj, unsigned int value)
 {
diff --git a/Include/internal/pycore_dict.h b/Include/internal/pycore_dict.h
index 81f1de3a5be8650..0257aaaf4534d5e 100644
--- a/Include/internal/pycore_dict.h
+++ b/Include/internal/pycore_dict.h
@@ -354,8 +354,8 @@ _PyDictValues_AddToInsertionOrder(PyDictValues *values, 
Py_ssize_t ix)
     uint8_t *array = get_insertion_order_array(values);
     assert(size < values->capacity);
     assert(((uint8_t)ix) == ix);
-    array[size] = (uint8_t)ix;
-    values->size = size+1;
+    FT_ATOMIC_STORE_UINT8_RELAXED(array[size], (uint8_t)ix);
+    FT_ATOMIC_STORE_UINT8_RELEASE(values->size, size+1);
 }
 
 // Exported for external JIT support
diff --git a/Include/internal/pycore_pyatomic_ft_wrappers.h 
b/Include/internal/pycore_pyatomic_ft_wrappers.h
index d8ec306a0dae3fc..31455f6b8153123 100644
--- a/Include/internal/pycore_pyatomic_ft_wrappers.h
+++ b/Include/internal/pycore_pyatomic_ft_wrappers.h
@@ -69,6 +69,8 @@ extern "C" {
     _Py_atomic_store_ssize_relaxed(&value, new_value)
 #define FT_ATOMIC_STORE_SSIZE_RELEASE(value, new_value) \
     _Py_atomic_store_ssize_release(&value, new_value)
+#define FT_ATOMIC_STORE_UINT8_RELEASE(value, new_value) \
+    _Py_atomic_store_uint8_release(&value, new_value)
 #define FT_ATOMIC_STORE_UINT8_RELAXED(value, new_value) \
     _Py_atomic_store_uint8_relaxed(&value, new_value)
 #define FT_ATOMIC_STORE_UINT16_RELAXED(value, new_value) \
@@ -167,6 +169,7 @@ extern "C" {
 #define FT_ATOMIC_STORE_INT8_RELEASE(value, new_value) value = new_value
 #define FT_ATOMIC_STORE_SSIZE_RELAXED(value, new_value) value = new_value
 #define FT_ATOMIC_STORE_SSIZE_RELEASE(value, new_value) value = new_value
+#define FT_ATOMIC_STORE_UINT8_RELEASE(value, new_value) value = new_value
 #define FT_ATOMIC_STORE_UINT8_RELAXED(value, new_value) value = new_value
 #define FT_ATOMIC_STORE_UINT16_RELAXED(value, new_value) value = new_value
 #define FT_ATOMIC_STORE_UINT32_RELAXED(value, new_value) value = new_value
diff --git a/Lib/test/test_free_threading/test_dict.py 
b/Lib/test/test_free_threading/test_dict.py
index 4a812275143bc4d..2180613f689c897 100644
--- a/Lib/test/test_free_threading/test_dict.py
+++ b/Lib/test/test_free_threading/test_dict.py
@@ -314,6 +314,87 @@ def reader():
 
         threading_helper.run_concurrently([writer, reader, reader])
 
+    def test_racing_split_dict_iteration_and_delete(self):
+        # Each reader owns its iterator. Mortal values exercise reference
+        # acquisition as well as the concurrent clearing of value slots.
+        class C:
+            pass
+
+        names = [f"a{i}" for i in range(8)]
+        obj = C()
+        for i, name in enumerate(names):
+            setattr(obj, name, [i])
+        d = obj.__dict__
+
+        def delattr_writer():
+            for _ in range(50):
+                for i, name in enumerate(names):
+                    delattr(obj, name)
+                    setattr(obj, name, [i])
+
+        def delitem_writer():
+            for _ in range(50):
+                for i, name in enumerate(names):
+                    del d[name]
+                    d[name] = [i]
+
+        def clear_writer():
+            for _ in range(50):
+                d.clear()
+                for i, name in enumerate(names):
+                    setattr(obj, name, [i])
+
+        def reader(view):
+            for _ in range(200):
+                try:
+                    for _ in view():
+                        pass
+                except RuntimeError:
+                    pass
+
+        for writer in (delattr_writer, delitem_writer, clear_writer):
+            for view in (d.values, d.items, d.keys):
+                with self.subTest(writer=writer.__name__, view=view.__name__):
+                    threading_helper.run_concurrently(
+                        [writer, partial(reader, view), partial(reader, view)])
+                    self.assertEqual(d, {name: [i] for i, name in 
enumerate(names)})
+
+    def test_racing_nonembedded_split_dict_iteration_and_delete(self):
+        class C:
+            pass
+
+        names = [f"a{i}" for i in range(8)]
+        # Populate shared keys without populating the copied dict's order
+        # array, so the writer also initializes previously unused order bytes.
+        template = C()
+        for i, name in enumerate(names):
+            setattr(template, name, [i])
+
+        def writer():
+            for _ in range(50):
+                for i, name in enumerate(names):
+                    d.pop(name, None)
+                    d[name] = [i]
+
+        def reader(view):
+            for _ in range(200):
+                try:
+                    for _ in view():
+                        pass
+                except RuntimeError:
+                    pass
+
+        for view_name in ("values", "items", "keys"):
+            with self.subTest(view=view_name):
+                obj = C()
+                obj.a0 = [0]
+                # Copying a split dict gives it heap-backed values.
+                d = obj.__dict__.copy()
+                view = getattr(d, view_name)
+                threading_helper.run_concurrently(
+                    [writer, partial(reader, view), partial(reader, view)])
+                self.assertEqual(d, {name: [i] for i, name in 
enumerate(names)})
+
     def test_racing_dict_update_and_method_lookup(self):
         # gh-144295: test race between dict modifications and method lookups.
         # Uses BytesIO because the race requires a type without 
Py_TPFLAGS_INLINE_VALUES
diff --git 
a/Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-13-00-00.gh-issue-158197.Qk7sVd.rst
 
b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-13-00-00.gh-issue-158197.Qk7sVd.rst
new file mode 100644
index 000000000000000..10fdabcb1f2fd84
--- /dev/null
+++ 
b/Misc/NEWS.d/next/Core_and_Builtins/2026-10-03-13-00-00.gh-issue-158197.Qk7sVd.rst
@@ -0,0 +1,5 @@
+Fix crashes and debug-build assertion failures in the :term:`free-threaded
+build` when iterating over a split dictionary while another thread deletes
+entries or clears it. Publish insertion-order updates atomically so readers
+cannot observe an increased size before the corresponding order entry is
+initialized.
diff --git a/Objects/dictobject.c b/Objects/dictobject.c
index 168a5a4118f596c..bff543d9fdede4e 100644
--- a/Objects/dictobject.c
+++ b/Objects/dictobject.c
@@ -2925,9 +2925,9 @@ delete_index_from_values(PyDictValues *values, Py_ssize_t 
ix)
     assert(i < size);
     size--;
     for (; i < size; i++) {
-        array[i] = array[i+1];
+        FT_ATOMIC_STORE_UINT8_RELAXED(array[i], array[i+1]);
     }
-    values->size = size;
+    FT_ATOMIC_STORE_UINT8_RELEASE(values->size, size);
 }
 
 static void
@@ -3122,7 +3122,7 @@ clear_embedded_values(PyDictValues *values, Py_ssize_t 
nentries)
         refs[i] = values->values[i];
         FT_ATOMIC_STORE_PTR_RELEASE(values->values[i], NULL);
     }
-    values->size = 0;
+    FT_ATOMIC_STORE_UINT8_RELEASE(values->size, 0);
     for (Py_ssize_t i = 0; i < nentries; i++) {
         Py_XDECREF(refs[i]);
     }
@@ -6103,9 +6103,14 @@ dictiter_iternext_threadsafe(PyDictObject *d, PyObject 
*self,
         // We're racing against writes to the order from 
delete_index_from_values, but
         // single threaded can suffer from concurrent modification to those as 
well and
         // can have either duplicated or skipped attributes, so we strive to 
do no better
-        // here.
-        int index = get_index_from_order(d, i);
+        // here. Use the same values snapshot as the size load above.
+        uint8_t *order = get_insertion_order_array(values);
+        int index = _Py_atomic_load_uint8_relaxed(&order[i]);
         PyObject *value = _Py_atomic_load_ptr(&values->values[index]);
+        if (value == NULL) {
+            // Deletion clears the slot before decrementing values->size.
+            goto try_locked;
+        }
         if (acquire_key_value(&DK_UNICODE_ENTRIES(k)[index].me_key, value,
                                &values->values[index], out_key, out_value) < 
0) {
             goto try_locked;

_______________________________________________
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]

Reply via email to