CPython 无 GIL 构建下类型特殊方法并发赋值死锁的修复解析
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
导读
本文围绕 CPython 仓库中一条针对 free-threaded(无 GIL)构建的核心缺陷修复记录展开,剖析两个线程同时对同一个类的特殊方法(如__repr__、__hash__)赋值时可能触发的死锁问题。文章将以 Misc/NEWS.d/next/Core_and_Builtins/2026-08-07-18-32-15.gh-issue-155400.Kq3Vpt.rst 为骨架,结合 Objects/typeobject.c 的源码实现,还原缺陷成因、修复策略及其底层同步机制,帮助你理解 CPython 在无 GIL 模式下如何安全地维护类型系统的一致性。
一、缺陷修复记录原文
该条 NEWS 记录的全文如下:
Fix a deadlock in the free-threaded build between two threads assigning to a special method of the same class. While applying the type slot updates with the world stopped, only the type lock was prevented from being released; the type dict mutex could still be released and re-acquired, in the wrong order, if the thread blocked on the stop-the-world mutex.
翻译并拆解其技术要点:
- 场景:free-threaded 构建(即
--disable-gil编译出的 CPython)下,两个线程同时对同一个类的特殊方法(special method,如__repr__、__add__、__hash__)进行赋值; - 现象:发生死锁;
- 根因:在"世界停止"(world stopped,即 stop-the-world 暂停)状态下应用类型插槽(type slot)更新时,代码只阻止了type lock(类型锁)被释放;而type dict mutex(类型字典互斥锁)在线程因阻塞在 stop-the-world 互斥锁上时仍可能被释放,随后又以错误的顺序重新获取,从而形成死锁。
这条记录虽短,却指向了无 GIL 构建中类型系统同步的一个关键设计问题。下面逐层深入源码验证。
二、背景:free-threaded 构建与类型锁体系
CPython 的无 GIL 构建通过Py_GIL_DISABLED宏区分代码路径。Objects/typeobject.c 中大量#ifdef Py_GIL_DISABLED分支(例如 Objects/typeobject.c#L56、Objects/typeobject.c#L1148)即为该模式下的专用同步逻辑。
2.1 两把互斥锁的分工
在该构建下,类型对象由两把锁共同保护(见 Objects/typeobject.c#L56-L78):
- TYPE_LOCK:定义为
&_PyInterpreterState_GET()->types.mutex,是解释器级(位于_PyRuntime运行时结构内)的全局类型锁。它保护tp_version_tag、_spec_cache、tp_mro、tp_bases、tp_base等关键字段的一致性; - type dict mutex:即类型
__dict__对象自身的ob_mutex(堆上分配的对象锁),保护对类型字典的写入。
当同时需要两把锁时,代码使用双互斥锁临界区:
#define BEGIN_TYPE_DICT_LOCK(d) \ Py_BEGIN_CRITICAL_SECTION2_MUTEX(TYPE_LOCK, &_PyObject_CAST(d)->ob_mutex) #define END_TYPE_DICT_LOCK() Py_END_CRITICAL_SECTION2()关键细节在于两锁获取顺序:注释明确指出(Objects/typeobject.c#L138-L149),type dict mutex 位于堆上,而 TYPE_LOCK 位于_PyRuntime中,因此双锁临界区按地址排序时通常先获取 dict mutex、后获取 TYPE_LOCK。这一顺序在后续死锁分析中至关重要。
2.2 插槽更新为何要"停世界"
给类的特殊方法赋值后,CPython 需要重新计算对应的类型插槽(如tp_repr、tp_hash)。这一过程由update_one_slot()完成,其注释(Objects/typeobject.c#L11831-L11841)说明:
If the
queued_updatespointer is provided, the actual updates to the slot pointers are queued, rather than being immediately performed. That argument is only used for the free-threaded build since those updates need to be done while the world is stopped.
即:无 GIL 构建下,插槽指针的实际写入必须延迟到世界停止时统一执行,否则其他线程可能在未加锁、无原子操作的情况下读到半更新的插槽。相关的队列应用函数apply_slot_updates()也以assert(types_world_is_stopped())强校验这一前提(Objects/typeobject.c#L3853-L3869)。
2.3 世界停止机制
stop-the-world(STW)暂停由解释器状态中的_stoptheworld_state支撑(Include/internal/pycore_interp_structs.h#L410-L425):
struct _stoptheworld_state { PyMutex mutex; // Serializes stop-the-world attempts. bool requested; // Set when a pause is requested. bool world_stopped; // Set when the world is stopped. PyEvent stop_event; // Set when thread_countdown reaches zero. Py_ssize_t thread_countdown; // Number of threads that must pause. PyThreadState *requester; // Thread that requested the pause. };类型模块通过types_stop_world()/types_start_world()封装_PyEval_StopTheWorld()/_PyEval_StartTheWorld()(Objects/typeobject.c#L116-L132),在暂停期间独占执行插槽更新。
三、根因:阻塞时锁被"释放再重取"引发顺序反转
要理解死锁,必须先理解临界区与线程状态的关系。Include/internal/pycore_pystate.h#L20-L49 描述了无 GIL 构建下线程的三态模型:
[attached] <-> [detached] <-> [suspended]- attached:线程可执行 Python API;
- detached:线程不占用解释器资源,可自行迁回 attached;
- suspended:仅用于实现 STW 暂停(如循环垃圾回收)。线程不能自行从 suspended 迁出,只有执行暂停的线程能将其恢复为 detached。
关键机制在于:当持有临界区的线程发生阻塞(如等待 STW 互斥锁)时,线程会被挂起(detach),其持有的临界区随之被挂起,锁被释放。这正是缺陷的温床。
结合 Objects/typeobject.c#L3871-L3903 中apply_type_slot_updates()的注释,还原死锁场景:
- 线程 A 更新了类的
__dict__(持有了 TYPE_LOCK 与 type dict mutex),准备应用插槽更新队列; - 为避免其他线程在插槽更新完成前改写字典,A 调用
types_stop_world()请求 STW; - 若 A 在等待 STW 互斥锁时发生阻塞,旧代码只"钉住"(prevent release)了 TYPE_LOCK,type dict mutex 仍被释放;
- 恢复时,
_PyCriticalSection_Resume()需要在持有 TYPE_LOCK 的状态下重新获取 type dict mutex; - 此时若线程 B 恰好持有 type dict mutex 并等待 TYPE_LOCK(这正是
BEGIN_TYPE_DICT_LOCK()的典型路径),则 A 等 B 释放 dict mutex、B 等 A 释放 TYPE_LOCK——经典的 ABBA 死锁。
注释原文一针见血地概括了这一点(Objects/typeobject.c#L138-L145):
If only TYPE_LOCK was pinned then
_PyCriticalSection_Resume()would have to re-acquire the other mutex while TYPE_LOCK is held. That deadlocks against a thread that holds that mutex and is waiting for TYPE_LOCK, which is exactly whatBEGIN_TYPE_DICT_LOCK()does.
四、修复方案:type_lock_prevent_release钉住全部互斥锁
修复的核心思路是:在等待 STW 期间,不仅钉住 TYPE_LOCK,而是钉住当前临界区持有的所有互斥锁,使恢复时无需重新获取任何锁,从根源上消除"以错误顺序重取锁"的可能性。
4.1 数据结构与两个辅助函数
钉住操作使用pinned_mutexes_t记录被"借出"的锁(Objects/typeobject.c#L49-L54):
typedef struct { PyMutex *mutex1; PyMutex *mutex2; } pinned_mutexes_t;type_lock_prevent_release()(Objects/typeobject.c#L150-L165):从当前最顶层临界区中取出_cs_mutex(必要时还有双锁临界区的_cs_mutex2),将其置空并保存到pinned中。由于阻塞挂起时只会释放临界区中记录的锁,置空后这些锁便"不会因阻塞而被释放",从而被钉住;type_lock_allow_release()(Objects/typeobject.c#L167-L183):STW 结束、插槽更新完成后,将锁归还给临界区,恢复正常的挂起/恢复语义。
4.2 修复后的调用序列
修复后的apply_type_slot_updates()完整流程如下(Objects/typeobject.c#L3871-L3903):
pinned_mutexes_t pinned; type_lock_prevent_release(&pinned); // 1. 钉住 TYPE_LOCK 与 dict mutex types_stop_world(); // 2. 请求 STW(阻塞期间锁不释放) apply_slot_updates(updates); // 3. 世界已停止,安全写入插槽 types_start_world(); // 4. 恢复世界 type_lock_allow_release(&pinned); // 5. 归还锁,恢复常规语义同样,_PyType_SetFlagsRecursive()(Objects/typeobject.c#L6379-L6402)在修改tp_flags时也采用了完全相同的"先钉锁、再停世界"模式,保证在 STW 期间无人能重新分配 version tag。
4.3 为什么钉住锁不会阻碍 STW
一个自然的疑问是:线程 A 持锁阻塞等待 STW,会不会反过来导致其他线程无法进入暂停状态?注释给出了答案(Objects/typeobject.c#L146-L149):
Holding the mutexes while blocked does not prevent the world from being stopped: a thread waiting on either of them parks with
_PY_LOCK_DETACHand so is detached while it waits.
即:其他线程在等待这两把锁时,会以_PY_LOCK_DETACH方式挂起等待,处于 detached 状态,同样会被纳入 STW 暂停范围。因此钉住锁既保证了 A 自身的恢复路径安全,也不会拖延 STW 的达成——这是该修复能够成立的两个关键前提。
五、修复的普适模式与工程启示
梳理本案例,可以看到无 GIL 构建下类型系统同步的三个核心原则:
- 插槽/标志等非原子字段的更新必须发生在世界停止时:这是数据一致性的底线,由
ASSERT_WORLD_STOPPED_OR_NEW_TYPE等断言(Objects/typeobject.c#L104-L106)在调试构建中强制校验; - 临界区恢复时不得以颠倒的顺序重取锁:通过
type_lock_prevent_release全量钉住临界区持有的锁,保证"释放→重取"路径根本不会发生; - 持有锁等待 STW 是安全的:因为等待方会 detached,从而被 STW 机制覆盖,不会形成"持锁者阻止停世界"的间接死锁。
这套模式已在apply_type_slot_updates()与_PyType_SetFlagsRecursive()两处落地,属于 free-threaded 构建中类型修改路径的标准同步范式。
六、验证与复现思路
若需在本地验证该修复,可先以无 GIL 模式构建当前仓库(配置阶段指定--disable-gil,构建产物即 free-threaded 解释器,相关代码路径由Py_GIL_DISABLED宏启用),随后编写多线程脚本:两个线程反复对同一个类的同一特殊方法(如__repr__)并发赋值,循环多轮以放大竞争窗口。修复前在该竞争下可能挂起,修复后应稳定运行。可配合调试构建(Py_DEBUG)运行,此时 Objects/typeobject.c 中ASSERT_TYPE_LOCK_HELD、ASSERT_WORLD_STOPPED_OR_NEW_TYPE等断言会在锁序或停世界条件不满足时第一时间失败,帮助定位问题。
七、小结
本缺陷修复记录虽仅有数行,却浓缩了 CPython free-threaded 构建中一个典型的锁序死锁案例:世界停止等待期间,只钉住类型锁而放行字典锁,导致临界区恢复时以错误顺序重取锁,与持有字典锁、等待类型锁的线程形成 ABBA 死锁。修复通过type_lock_prevent_release将临界区持有的全部互斥锁一并钉住,配合"等待者 detached 不影响 STW"的机制,既消除了错误重取路径,又不引入新的暂停阻碍。理解这一案例,对深入研读无 GIL 构建的类型系统、垃圾回收(STW 的另一主要用户)乃至任何"停世界 + 多锁"同步设计都有直接参考价值。
【免费下载链接】cpythonThe Python programming language项目地址: https://gitcode.com/GitHub_Trending/cp/cpython
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考