ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

CPython 修复 free-threaded 构建下线程句柄标识符数据竞争:`_thread._shutdown()` 与非守护线程并发启动的竞态分析

CPython 修复 free-threaded 构建下线程句柄标识符数据竞争:`_thread._shutdown()` 与非守护线程并发启动的竞态分析 CPython 修复 free-threaded 构建下线程句柄标识符数据竞争_thread._shutdown()与非守护线程并发启动的竞态分析【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython导读本文围绕 CPython 主线仓库中的一条核心变更记录Misc/NEWS.d/next/Core_and_Builtins/2026-08-02-17-55-29.gh-issue-154937.m9IBba.rst展开聚焦于_thread._shutdown()与非守护线程启动并发执行时线程句柄ThreadHandle标识符ident上的数据竞争data race问题。读者将理解线程标识符在 CPython 内部如何被读写与保护、为什么 free-threaded 构建会放大这一竞态、修复的核心手段以互斥锁保护ident字段的读写以及如何通过测试验证此类竞态行为。变更记录全景一条 NEWS 条目背后的修复仓库中的该 NEWS 条目位于Misc/NEWS.d/next/Core_and_Builtins/目录属于 CPython 的碎片化变更日志体系原文为Fix a data race on thread handle identifiers when_thread._shutdown()runs concurrently with the startup of non-daemon threads in the free-threaded build.翻译并展开解读问题对象线程句柄thread handle上的线程标识符thread handle identifier。触发场景_thread._shutdown()与非守护线程non-daemon threads的启动过程并发执行。受影响构建free-threaded build即不带 GIL 的实验性构建Python 3.13 引入--disable-gil配置。问题性质数据竞争data race即多个线程同时访问同一内存位置且至少一方在写入且访问未同步。这条 NEWS 条目虽然只有两行但它指向的是_thread模块底层线程句柄生命周期管理的一次真实竞态修复。下面结合仓库源码还原这次修复的完整技术图景。背景非守护线程的关闭语义与_shutdown的职责Python 解释器在退出Py_Finalize/Py_Main流程时需要等待所有非守护线程结束否则会因线程状态被提前销毁而崩溃。这一等待逻辑由threading._shutdown()承担# Lib/threading.py (L1695-L1720) def _shutdown(): Wait until the Python thread state of all non-daemon threads get deleted. if _main_thread._os_thread_handle.is_done() and _is_main_interpreter(): return _thread_shutdown() global _SHUTTING_DOWN _SHUTTING_DOWN True for atexit_call in reversed(_threading_atexits): atexit_call() if _is_main_interpreter(): _main_thread._os_thread_handle._set_done() # Wait for all non-daemon threads to exit. _thread_shutdown()其核心是调用_thread._shutdown()在Lib/threading.py第 40 行绑定为_thread_shutdown _thread._shutdownC 层面的实现位于 Modules/_threadmodule.c 的thread_shutdown()// Modules/_threadmodule.c (L2402-L2440) static PyObject * thread_shutdown(PyObject *self, PyObject *args) { PyThread_ident_t ident PyThread_get_thread_ident_ex(); thread_module_state *state get_thread_state(self); for (;;) { ThreadHandle *handle NULL; // Find a thread thats not yet finished. HEAD_LOCK(_PyRuntime); struct llist_node *node; llist_for_each_safe(node, state-shutdown_handles) { ThreadHandle *cur llist_data(node, ThreadHandle, shutdown_node); if (ThreadHandle_ident(cur) ! ident) { ThreadHandle_incref(cur); handle cur; break; } } HEAD_UNLOCK(_PyRuntime); if (!handle) { // No more threads to wait on! break; } // Wait for the thread to finish. if (ThreadHandle_join(handle, -1) 0) { ThreadHandle_decref(handle); return NULL; } ThreadHandle_decref(handle); } Py_RETURN_NONE; }其工作流程为记录当前线程的标识符ident通过PyThread_get_thread_ident_ex()见 Python/thread_pthread.h 等平台实现。在_PyRuntime的 HEAD 锁保护下遍历state-shutdown_handles链表非守护线程句柄列表。通过ThreadHandle_ident(cur) ! ident排除调用者自身找到第一个尚未结束的非守护线程。调用ThreadHandle_join(handle, -1)无限期等待该线程退出。循环往复直到链表中没有可等待的线程。关键点在于第 3 步每个非守护线程的句柄都通过ident字段与当前线程做比较。如果ident的读取结果不稳定读到半写状态或被并发写坏就可能误判——例如把正在启动的线程当作调用者自身而跳过导致解释器退出时没有等待它进而引发线程状态销毁后的非法访问。竞态根源ident 字段的写入时机与读取窗口非守护线程在启动时被加入关闭等待链表其句柄的ident字段是在线程真正启动之后才写入的见ThreadHandle_start()// Modules/_threadmodule.c (L473-L495) PyThread_ident_t ident; PyThread_handle_t os_handle; if (PyThread_start_joinable_thread(thread_run, boot, ident, os_handle)) { ... } // Mark the handle running PyMutex_Lock(self-mutex); assert(self-state THREAD_HANDLE_STARTING); self-ident ident; // -- 写入 ident self-has_os_handle 1; self-os_handle os_handle; self-state THREAD_HANDLE_RUNNING; PyMutex_Unlock(self-mutex);而启动前ThreadHandle_new()将ident初始化为 0// Modules/_threadmodule.c (L222-L245) self-ident 0; self-os_handle 0; self-has_os_handle 0; ... HEAD_LOCK(_PyRuntime); llist_insert_tail(_PyRuntime.threads.handles, self-node); HEAD_UNLOCK(_PyRuntime);竞态窗口由此产生add_to_shutdown_handles(state, handle)见 Modules/_threadmodule.c在ThreadHandle_start()之前把句柄挂入shutdown_handles链表见do_start_new_thread()中 L1912-L1917 的注释Add the handle before starting the thread to avoid adding a handle to a thread that has already finished。这意味着写入方ThreadHandle_start()在持handle-mutex时写入self-ident ident此时新线程可能尚未真正开始执行。读取方thread_shutdown()遍历链表时调用ThreadHandle_ident(cur)读取ident。在 free-threaded 构建--disable-gil下多个 OS 线程可以真正并行执行若_shutdown()恰好在新线程启动的写窗口内读取ident且读写未同步就构成标准的数据竞争——在 C 语言层面属于未定义行为UB轻则读到陈旧/撕裂值导致误判跳过该线程或误 join 自身重则在不同架构如宽松内存序下产生难以复现的崩溃。在经典 GIL 构建下这类窗口因 GIL 的互斥作用而大概率被掩盖free-threaded 构建消除了这层隐式同步使问题显性化——这正是 NEWS 条目特别标注 in the free-threaded build 的原因。修复方案用互斥锁同步 ident 的读写对照源码可见ident字段的读取路径同样通过handle-mutex进行保护这正是针对该数据竞争的修复手段// Modules/_threadmodule.c (L172-L179) static PyThread_ident_t ThreadHandle_ident(ThreadHandle *handle) { PyMutex_Lock(handle-mutex); PyThread_ident_t ident handle-ident; PyMutex_Unlock(handle-mutex); return ident; }由此ident的每一次访问都处于同一把PyMutex临界区之内操作函数位置同步方式初始化ident 0ThreadHandle_new()Modules/_threadmodule.c构造期独占无并发写入ident identThreadHandle_start()Modules/_threadmodule.c持handle-mutex读取identThreadHandle_ident()Modules/_threadmodule.c持handle-mutex状态迁移辅助set_thread_handle_state()Modules/_threadmodule.c持handle-mutexPyMutex是 CPython 内部提供的互斥锁原语定义于 Include/cpython/critical_section.h 等头文件在 free-threaded 构建下承担显式同步职责。通过同一把锁保护ident的读写消除数据竞争写者持锁写入、读者持锁读取形成 happens-before 关系杜绝撕裂读与陈旧读保持链表遍历语义thread_shutdown()在HEAD_LOCK(_PyRuntime)保护下遍历链表而ThreadHandle_ident()内部再取handle-mutex两层锁各司其职——外层锁保护链表结构本身内层锁保护句柄字段符合 CPython 中链表遍历与节点字段分别加锁的一贯模式不引入死锁ThreadHandle_start()中先取mutex再在do_start_new_thread()的调用序中先加入链表而thread_shutdown()先持 HEAD 锁遍历、再调ThreadHandle_ident()取句柄锁由于句柄锁的持有时间极短且不反向获取 HEAD 锁锁序保持一致不会形成环。竞态之外ident 在多处的并发使用修复之后ThreadHandle_ident()这个加锁读取接口还被用于多处需要稳定读取标识符的路径印证了统一走加锁访问器的工程价值ThreadHandle_join()中判断不能 join 自身ThreadHandle_ident(self) PyThread_get_thread_ident_ex()Modules/_threadmodule.c——注意此处注释特别说明线程退出后标识符可能被 OS 复用因此要先检查thread_is_exiting事件再比较 identthread_PyThread_start_new_thread()中把新线程标识符返回给 Python 层PyLong_FromUnsignedLongLong(ThreadHandle_ident(handle))Modules/_threadmodule.c即_thread.start_new_thread()的返回值ThreadHandle_ident_get暴露给_ThreadHandle.ident属性Modules/_threadmodule.c_PyThread_AfterFork()在 fork 后的子进程中遍历所有句柄、用handle-ident current排除当前线程Modules/_threadmodule.c。从源码结构看ident字段在ThreadHandle生命周期内被初始化 → 启动写入 → 多处读取三阶段访问其中读取遍布 join、ident 属性、start_new_thread 返回值与 after-fork 等路径。本次修复的核心思路是凡是跨线程访问ident一律经由持有handle-mutex的ThreadHandle_ident()从而把该字段的并发访问统一收敛到一把锁之下。如何验证测试与复现思路仓库中与_shutdown语义相关的测试主要位于 Lib/test/test_threading.pytest_join_nondaemon_on_shutdownL558-L576验证主线程抛出SystemExit后threading._shutdown()仍会等待非守护子线程完成打印——该测试直接对应关闭时必须等待非守护线程的语义若本次修复引入回归如跳过未写完 ident 的线程此类测试会失败或导致解释器退出时崩溃。test_finalization_shutdownL947 起对应 bpo-36402验证Py_Finalize()调用threading._shutdown()时必须等待非守护线程防止线程状态提前销毁。L2837 附近用例在子解释器/多线程场景下直接调用threading._shutdown()。对于数据竞争类缺陷常规功能测试往往难以稳定触发。实践中可配合以下手段复现与验证构建 free-threaded 解释器./configure --disable-gil后make得到无 GIL 的python可执行文件压力脚本主线程循环启动大量非守护线程同时例如在另一个 OS 线程中反复调用threading._shutdown()或模拟解释器退出路径制造启动写入 ident与遍历读取 ident的高频交错竞态检测工具使用 ThreadSanitizer./configure --with-tsan或编译时追加-fsanitizethread运行上述脚本修复前可报告handle-ident上的 data race 告警修复后告警消失——这是验证此类修复最直接的工程手段。需要说明的是仓库当前测试套件主要覆盖功能语义等待非守护线程、最终化顺序尚未发现专门针对该竞态的确定性回归测试修复的有效性更多依赖 TSan 等动态竞态检测工具在 free-threaded 构建下的扫描结果。修复的价值与工程启示从这条 NEWS 条目可以提炼出三层信息现象层free-threaded 构建中_thread._shutdown()与非守护线程启动并发时线程句柄标识符存在数据竞争机制层竞态源于ident字段的写入ThreadHandle_start()持锁与读取ThreadHandle_ident()曾无锁不同步加上句柄先于线程真正启动被挂入shutdown_handles链表的设计放大了读写重叠的概率修复层为ident的读取路径补上handle-mutex与写入路径使用同一把锁彻底消除未定义行为同时保留HEAD_LOCK对链表遍历的保护形成结构锁 字段锁的分层同步模型。对 CPython 贡献者而言这条记录也是理解 free-threaded 构建同步纪律的典型案例凡是被多个线程共享的可变字段访问必须显式加锁不能依赖 GIL 兜底。对应用开发者而言它提醒我们在--disable-gil构建上运行高并发线程程序时解释器内部的竞态修复仍在持续演进使用较新的 CPython 版本可获得更稳健的多线程语义保障。【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表