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._shutdown),C 层面的实现位于 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 that's 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 = 0 | ThreadHandle_new() | Modules/_threadmodule.c | 构造期独占,无并发 |
写入ident = ident | ThreadHandle_start() | Modules/_threadmodule.c | 持handle->mutex |
读取ident | ThreadHandle_ident() | Modules/_threadmodule.c | 持handle->mutex |
| 状态迁移辅助 | set_thread_handle_state() | Modules/_threadmodule.c | 持handle->mutex |
PyMutex是 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事件再比较 ident;thread_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.py:
test_join_nondaemon_on_shutdown(L558-L576):验证主线程抛出SystemExit后,threading._shutdown()仍会等待非守护子线程完成打印——该测试直接对应"关闭时必须等待非守护线程"的语义,若本次修复引入回归(如跳过未写完 ident 的线程),此类测试会失败或导致解释器退出时崩溃。test_finalization_shutdown(L947 起,对应 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或编译时追加-fsanitize=thread)运行上述脚本,修复前可报告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),仅供参考