CPython 修复 enumerate.__reduce__ 线程安全问题:临界区同步与原子索引的实现剖析

发布时间:2026/9/10 3:17:49
CPython 修复 enumerate.__reduce__ 线程安全问题:临界区同步与原子索引的实现剖析 CPython 修复 enumerate.reduce线程安全问题临界区同步与原子索引的实现剖析【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython本文以 CPython 源码中的一条 NEWS 变更gh-issue-153932为线索深入剖析enumerate迭代器__reduce__方法的线程安全问题及其修复方案。读者将了解 CPython 如何在 free-threaded无 GIL构建下通过临界区Critical Section与原子加载/存储保护enumerate的内部索引状态并掌握该问题的复现测试与实现细节。变更背景一个仅有一行描述的 NEWS 条目在 CPython 仓库的 Misc/NEWS.d/next/Core_and_Builtins/2026-07-19-12-33-27.gh-issue-153932.lKw2bo.rst 中记录了这样一条变更Fix thread safety issue in the__reduce__method ofenumerate.虽然条目只有一句话但它背后对应的是一个真实的数据竞争缺陷当多个线程同时对一个enumerate对象执行迭代与序列化pickle时__reduce__读取内部索引的方式与迭代器推进索引的方式之间存在竞争条件可能导致未定义行为。该问题属于Core_and_Builtins核心与内建对象类别影响的是 CPython 的 free-threaded 构建模式。enumerate 的内部结构索引存放在哪里要理解竞争条件的根源需要先了解enumerate对象在 C 层的表示。在 Objects/enumobject.c 中定义了enumobject结构体typedef struct { PyObject_HEAD Py_ssize_t en_index; /* current index of enumeration */ PyObject* en_sit; /* secondary iterator of enumeration */ PyObject* en_result; /* result tuple */ PyObject* en_longindex; /* index for sequences PY_SSIZE_T_MAX */ PyObject* one; /* borrowed reference */ } enumobject;四个字段各司其职en_index当前枚举索引Py_ssize_t类型对应enumerate(iterable, start)中从start开始的计数值en_sit底层可迭代对象的迭代器enumerate通过它获取下一个元素en_result缓存的二元结果元组(index, item)迭代时优先复用仅在唯一引用时原地更新见_PyObject_IsUniquelyReferenced分支避免每次next()都分配新元组en_longindex当索引超过PY_SSIZE_T_MAX即sys.maxsize时启用的大数索引路径此时索引以任意精度整数PyLong形式存储。从构造逻辑Objects/enumobject.c可以看到两条索引路径的切换在enum_new_impl中若start可以安全转换为Py_ssize_t则存入en_index且en_longindex NULL若start过大PyLong_AsSsize_t溢出并设置异常则清空错误并将en_index置为PY_SSIZE_T_MAX、把start挂到en_longindex。竞争条件的根源两处状态访问缺乏同步在 free-threaded 构建中多个线程可能共享同一个enumerate对象。迭代推进索引与__reduce__读取索引就可能并发发生迭代路径enum_next与enum_next_long普通的索引推进在 Objects/enumobject.c 的enum_next中完成它使用原子操作读写en_indexPy_ssize_t en_index FT_ATOMIC_LOAD_SSIZE_RELAXED(en-en_index); if (en_index PY_SSIZE_T_MAX) return enum_next_long(en, next_item); next_index PyLong_FromSsize_t(en_index); if (next_index NULL) { Py_DECREF(next_item); return NULL; } FT_ATOMIC_STORE_SSIZE_RELAXED(en-en_index, en_index 1);一旦索引到达PY_SSIZE_T_MAX迭代转入enum_next_longObjects/enumobject.c此时en_longindex的递增通过临界区保护Py_BEGIN_CRITICAL_SECTION(en); next_index increment_longindex_lock_held(en); Py_END_CRITICAL_SECTION();其中的increment_longindex_lock_heldObjects/enumobject.c会在持锁状态下执行PyNumber_Add(next_index, en-one)完成任意精度整数自增并返回旧值作为本次迭代的索引。修复前的__reduce__无锁裸读__reduce__是 pickle 协议的核心钩子用于返回重建对象所需的状态信息。修复前的实现直接、无保护地读取索引字段这在迭代线程并发推进索引时会构成数据竞争。修复后的实现在 Objects/enumobject.cstatic PyObject * enum_reduce(PyObject *op, PyObject *Py_UNUSED(ignored)) { enumobject *en _enumobject_CAST(op); PyObject *result; Py_BEGIN_CRITICAL_SECTION(en); if (en-en_longindex ! NULL) { result Py_BuildValue(O(OO), Py_TYPE(en), en-en_sit, en-en_longindex); } else { Py_ssize_t en_index FT_ATOMIC_LOAD_SSIZE_RELAXED(en-en_index); result Py_BuildValue(O(On), Py_TYPE(en), en-en_sit, en_index); } Py_END_CRITICAL_SECTION(); return result; }该函数的返回值遵循 pickle 的__reduce__契约是一个(callable, args)二元组Py_TYPE(en)enumerate类型本身作为重建时的可调用对象en-en_sit底层迭代器enumerate构造时通过PyObject_GetIter(iterable)获得因此还原后的enumerate会从当前位置继续迭代索引大数路径传en-en_longindex普通路径传en_index即下一个待分配的索引。对应地enumerate的重建过程就是enumerate(iterable, index)这正是enum_new_impl支持的构造签名。修复要点临界区 原子读两种路径分别对待本次修复的关键在于按状态类型选择同步手段普通索引en_indexPy_ssize_tenum_next使用FT_ATOMIC_LOAD_SSIZE_RELAXED/FT_ATOMIC_STORE_SSIZE_RELAXED原子读写因此__reduce__中同样使用原子加载读取与迭代路径保持一致的宽松内存序relaxed ordering语义。原子读保证不会读到撕裂torn的中间值宽松序在此场景下足够——因为__reduce__并不要求拿到最新索引只要拿到一个一致的值即可用于 pickle。大数索引en_longindexPyObject*指针 引用计数en_longindex的更新在enum_next_long中已经受临界区保护且指针本身可能被替换为新的PyLong。如果__reduce__在持锁前裸读该指针将可能与PyNumber_Add的赋值并发。因此修复将其读取也纳入Py_BEGIN_CRITICAL_SECTION(en)/Py_END_CRITICAL_SECTION()块内与enum_next_long的写路径形成互斥确保读到的en_longindex指针有效、引用计数稳定。从源码结构看Py_BEGIN_CRITICAL_SECTION宏是 CPython free-threaded 构建下保护对象内部状态的通用机制其实现位于 Include/cpython/critical_section.h在 GIL 构建下退化为空操作no-op因此该修复对传统 CPython 构建零开销、纯属防御性正确性加固。回归测试如何复现与验证该竞争针对 gh-153932仓库在 Lib/test/test_enumerate.py 中新增了专门的线程安全测试类TestThreadSafetythreading_helper.requires_working_threading() class TestThreadSafety(EnumerateStartTestCase): def test_thread_safety_while_iterating(self): # gh-153932: calling reduce while iterating should pass with TSAN en enumerate(range(10_000)) stop threading.Event() def advance(): for _ in en: pass stop.set() def read(): while not stop.is_set(): en.__reduce__() threads [threading.Thread(targetadvance), threading.Thread(targetread)] with threading_helper.start_threads(threads): pass该测试的设计意图非常明确advance线程完整消费enumerate(range(10_000))持续触发enum_next对索引的原子推进read线程在advance结束前无限循环调用en.__reduce__()模拟 pickle 过程中读取索引状态两个线程共享同一个en对象形成真实的读写并发测试注释明确指出目标是pass with TSAN——即在 ThreadSanitizer 加持的 free-threaded 构建下运行该测试时不应报告任何数据竞争。修复前该测试会暴露竞争修复后通过。threading_helper.requires_working_threading()装饰器则保证在不支持线程的平台/构建上自动跳过避免误报。值得注意的是test_enumerate.py中还包含更早的 pickle 行为测试PickleTest.check_pickle遍历所有 pickle 协议版本验证dumps/loads往返后enumerate从正确位置继续迭代它们与本修复共同保证了__reduce__不仅线程安全而且语义正确。应用场景与工程启示这项修复的实际意义体现在两个层面free-threaded 构建下的并发 pickle在 CPython 3.13 的 free-threaded--disable-gil构建中多线程可真正并行执行字节码。若某个线程对共享的enumerate对象执行pickle.dumps(en)而其他线程同时迭代该对象没有本修复就可能产生竞争。修复后enumerate与其他内建迭代器如reversed其__reduce__在 Objects/enumobject.c 中使用FT_ATOMIC_LOAD_SSIZE_RELAXED读取索引一样可在并发场景下安全序列化。原子读 临界区的混合同步范式本修复展示了 CPython 内核处理对象内部可变状态的一贯做法——标量索引用原子操作指针/引用计数敏感字段用临界区。读者在阅读其他内建对象如dict、list迭代器的并发修复时会反复看到同一范式。总结gh-issue-153932 的修复虽以一条 NEWS 条目收尾但其背后是 CPython 为 free-threaded 构建所做的系统性并发加固enumerate.__reduce__现在以临界区保护en_longindex的读取、以原子加载读取en_index与迭代路径的同步方式严格对齐配套的TestThreadSafety回归测试则确保该修复在 TSAN 下可被持续验证。对 CPython 使用者而言这意味着在多线程环境下对enumerate对象进行 pickle 操作是安全且可预期的对 CPython 内核研究者而言这是一个理解临界区与原子操作如何在真实对象上协作的绝佳样例。【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考