news 2026/9/10 1:47:55

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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._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; }

其工作流程为:

  1. 记录当前线程的标识符ident(通过PyThread_get_thread_ident_ex(),见 Python/thread_pthread.h 等平台实现)。
  2. _PyRuntime的 HEAD 锁保护下遍历state->shutdown_handles链表(非守护线程句柄列表)。
  3. 通过ThreadHandle_ident(cur) != ident排除调用者自身,找到第一个尚未结束的非守护线程。
  4. 调用ThreadHandle_join(handle, -1)无限期等待该线程退出。
  5. 循环往复,直到链表中没有可等待的线程。

关键点在于第 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.chandle->mutex
读取identThreadHandle_ident()Modules/_threadmodule.chandle->mutex
状态迁移辅助set_thread_handle_state()Modules/_threadmodule.chandle->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->mutexThreadHandle_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()

对于数据竞争类缺陷,常规功能测试往往难以稳定触发。实践中可配合以下手段复现与验证:

  1. 构建 free-threaded 解释器./configure --disable-gilmake,得到无 GIL 的python可执行文件;
  2. 压力脚本:主线程循环启动大量非守护线程,同时(例如在另一个 OS 线程中)反复调用threading._shutdown()或模拟解释器退出路径,制造"启动写入 ident"与"遍历读取 ident"的高频交错;
  3. 竞态检测工具:使用 ThreadSanitizer(./configure --with-tsan或编译时追加-fsanitize=thread)运行上述脚本,修复前可报告handle->ident上的 data race 告警,修复后告警消失——这是验证此类修复最直接的工程手段。

需要说明的是,仓库当前测试套件主要覆盖功能语义(等待非守护线程、最终化顺序),尚未发现专门针对该竞态的确定性回归测试;修复的有效性更多依赖 TSan 等动态竞态检测工具在 free-threaded 构建下的扫描结果。

修复的价值与工程启示

从这条 NEWS 条目可以提炼出三层信息:

  1. 现象层:free-threaded 构建中,_thread._shutdown()与非守护线程启动并发时,线程句柄标识符存在数据竞争;
  2. 机制层:竞态源于ident字段的写入(ThreadHandle_start(),持锁)与读取(ThreadHandle_ident(),曾无锁)不同步,加上句柄先于线程真正启动被挂入shutdown_handles链表的设计,放大了读写重叠的概率;
  3. 修复层:为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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 1:47:33

IFA 2026:Potensic重磅押注Atom 3无人机

轻便型无人机持续主导消费市场&#xff0c;而Potensic正借助IFA 2026向外界展示&#xff0c;为何其最新机型值得列入买家的候选清单。这家无人机制造商连续第三年参展这场年度柏林科技展会&#xff0c;将聚焦点集中放在了Atom 3航拍无人机上&#xff0c;展示其面向旅行者、户外…

作者头像 李华
网站建设 2026/9/10 1:47:02

Ghostty 终端模拟器深度解析:本地开发如何优雅替代 tmux 窗口管理

如果你平时用终端比较多&#xff0c;大概率已经听过 Ghostty 这个名字。这是 HashiCorp 联合创始人 Mitchell Hashimoto 用 Zig 语言写的一个现代化终端模拟器&#xff0c;2024 年底发布 1.0 之后热度直接拉满&#xff0c;GitHub 上 Star 上涨速度非常夸张。标题里说的“替换上…

作者头像 李华
网站建设 2026/9/10 1:46:32

CANN/GE ATC算子调试配置指南

--op_debug_config 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、TensorF…

作者头像 李华
网站建设 2026/9/10 1:42:45

大数据可视化大屏模板实操指南:从部署到二次开发全流程解析

简介&#xff1a;一套大数据可视化前端大屏模板&#xff0c;主要面向需要快速搭建数据监控中心、汇报演示大屏的前端开发者、数据分析师与项目交付人员&#xff0c;能够省去从零配置图表库、设计酷炫布局和调试交互效果的时间&#xff0c;适合在会议、指挥中心等场景直接演示。…

作者头像 李华