Python 移除 GIL 了!但是换了一种锁...—— PEP307导读

PEP 703 正式宣布,从 Python 3.13 起全局解释器锁(GIL)将成为可选配置!

Intro

在当下数据科学和 AI 领域,凭借简单易上手的 Python 可谓占据大半江山,然而随着使用的深入,天下苦 GIL 久成为一众研究人员和开发者多么痛的领悟,例如:

  • 数据科学与机器学习:多核 CPU 在训练模型和数据处理中的潜力难以被充分利用,许多团队(Numpy、TensorFlow 等)被迫采用其他语言(如 C++ 或 Rust)来实现核心逻辑,随着而来,开发维护成本直线上升,使用上也有诸多限制。

  • 游戏与图形处理:实时计算与渲染任务中,Python 的多线程能力受到严重抑制,使其难以承担性能要求较高的任务。

终于,Python 社区有了突破进展,可以在 3.13 版本开始禁用 GIL 了。没了 GIL,在当下技术条件,只要涉及共享数据,依然离不开锁

在 PEP 703 的长篇大论中,聊了引用计数、内存管理、容器线程安全、锁和原子 API,初次看云里雾里,知道它在说什么,做什么,但是怎么想到的?我读到了三个核心思路:

  • 锁的粒度
  • 要不要锁
  • 锁的实现

通过这三个思路,再把几个章节串起来理解就简单多了。

锁的粒度

首先是锁的粒度,既然锁不能完全消失,新的锁必然是粒度更小,锁的资源越小,锁争用越少,并发性能越高

我们可以在 MySQL 上看到类似演进:早期 MyISAM 引擎阶段,表级锁在高并发下读写争用严重,到 InnoDB 引擎,改为行级锁提高并发,MySQL 的 REDO 日志也经历了同样的演变。

PEP 307 将全局解释器锁换成了基于对象的锁(Per-Object Lock),这和提案中的容器线程安全部分挂钩。

当每个容器(如列表、字典)持有自己的锁,读写操作只需要锁定对应的对象即可。

不过,这引入了其他问题,例如嵌套操作对象导致死锁、读操作非原子导致引用无法访问等等,解决方案则涉及到了引用计数、锁和原子 API、内存管理。

要不要锁

既然锁针对单个对象了,每个对象有自己的特征、操作,是不是所有对象都需要锁?所有操作都需要锁?

不是所有对象都要修改引用计数

如果对象不需要回收,自然也就不需要引用计数这个共享资源了。

当对象贯穿整个程序生命周期便不需要回收重复创建,提案引入了永生化(Immortalization
)的概念:

  • 字符常量
  • (较小的)整型
  • 静态对象
  • None, 布尔值等

这些对象在其引用计数字段用 UInt32 最大值标识,当需要变更计数值,则不需要原子操作该字段:

  • INCREF增加引用: 先复制引用值加1再比较,如越界=0(C++的实现) 则为永生,直接返回
  • DECREF减少引用:直接比较是否为 UInt32MAX,是则为永生,跳过计数减法。

不是所有对象都要马上修改引用计数

例如顶级函数、代码对象、模块和方法,往往会被许多线程同时频繁访问。

这些对象几乎都是只读(为什么这里说的是几乎,读者不妨思考一下)但又不一定在整个生命周期存在。

针对这些对象,PEP 提出了延迟引用计数。引用计数的变化只记录在当前线程局部数据中。在垃圾回收时这样的时间点,才加锁合并到全局计数

  • 顶级函数 Top-level function
def my_function():  
    return "Hello, World!"  

# The function's behavior is fixed; you can't change its internal code.
  • 代码对象 Code objects
code_obj = my_function.__code__  # Get the code object of the function  
# You cannot modify code_obj; it is read-only.
  • 模块 Modules
import math  
# You can access math functions, but you can't change the math module's internal implementation.
  • 方法 Methods
class MyClass:  
    def my_method(self):  
        return "This is a method."  

# The method's implementation cannot be changed after it's defined.

不是所有引用计数的修改都要加锁

引用计数是多线程争用的核心区,以确定对象是否需要回收。

提案引入了偏向引用计数。只对共享对象做原子操作(称为 Slow Path),如果对象隶属于创建线程,那么引用计数的修改无需加锁 (Fast Path)。至于怎么识别,加上一个线程 ID 标志位即可。

不是所有容器操作都需要锁

这是单个对象锁的更进一步,PEP 提出了在线程安全部分提出了乐观锁(Optimistic Avoid Locking)。

对于容器而言,大部分操作只需要锁单个,appendinsertrepeat 等,少部分锁两个,extendconcat__eq__(另一个通过线程安全迭代器访问),修改对象如 clear() 等必须持有该锁。

读操作则需要分开讨论:

  • 不需要锁也可以读取的情况:
    • 直接原子访问:len(x)
  • 乐观避免锁定:contains, iter, dict[k], list[idx]
  • 需要上锁的操作:__repr__

所谓乐观地避免锁定,是指读取操作时没有其他线程修改时,保持无锁状态,检测到冲突才回退到加锁状态

对于锁两个容器的操作,操作者容器持有轻量锁,另一个容器便是乐观锁,它只需要做迭代操作。

那为什么 __repr__ 操作不能向迭代访问一样用乐观锁呢,它返回的是快照,要么成功要么失败,不存在部分成功的情况,因此是原子操作,必须上锁

锁的实现

最后落实到具体锁的实现,有一些方案引发的问题需要解决,这里挑两项阐述:

  • 避免死锁
  • 页面重用

避免死锁

当锁的粒度为单个对象,线程可以同时持有多个对象的锁,若对象是嵌套的,如果线程尝试以不同的顺序获取相同的锁,它们将会死锁。

简单复现一下,T1 持有锁 A,T2 持有锁 B,此时 T1 嵌套操作 B,需等待锁 B,同时 T2 也嵌套操作 A,两个线程都要等待,即造成死锁。

T1:
Lock A

T2: 
Lock B

T1:
Waiting for Lock B

T2:
Waiting for Lock A

为了解决这个问题,PEP 引入了**临界区(Critical Section)**的概念,对象锁多了一个挂起的状态。

  • 开始嵌套操作时,外层锁挂起(Suspend)
  • 嵌套操作完成,外层锁释放(Release)

处于挂起状态的锁,其他对象可以获取,锁用完不一定归还给原线程。除非线程显式结束临界区,参与竞争。

如下面时序图中,T1 归还 Lock B 后,其他线程可以持有 LockA,此时 T1 需要等待。

在这里插入图片描述

页面重用

获取容器元素,存在从借用(borrowed)引用升级为拥有(owned)引用的情况,在下面的代码中,获取对象到修改引用计数之间,引用对象可能已经被其他线程修改或删除。

PyObject *item = PyList_GetItem(list, idx);
Py_INCREF(item);

为了解决这个问题,PEP 使用了新的获取元素 API,获取对象后返回新的引用PyList_FetchItem(list, idx) for PyList_GetItem
同时,为了避免元素被释放或修改,引入了页面重用。

移除 GIL 后,PEP 采用线程安全的 Mimalloc 内存分配器代替 pymalloc。Mimalloc 用堆、页、块来实现内存管理。

Mimalloc 内存层级关系
- 堆 Heap
  - 页 page 不同大小类别,如没分配任何快,会被堆重新初始化页面
    - block 相同大小

对访问期间的对象,被释放的元素采用 **RCU(Read-Copy-Update)**移动到单独的堆,再择时释放。就不用担心元素找不到引用了。

但这些页被重用既不能太精确——接近同步而失去意义,也不能太宽松——留到下一个 GC 周期导致内存上涨

为此,PEP 实现类似 Linux 中的 GUS(Global Unbounded Sequences)全局无界序列。它的实现接近于生产者消费者模式。

  1. 有个单增全局写入序列号(例如某生产者,持续生产数字);
  2. 当页面为空(或元素被释放),线程(消费者)标记当前写入序号,生产线程继续原子单增写入序列号;
  3. 每个线程都有一个本地读取序列号,记录其观察到的最近的写入序列号;
  4. 当线程没有在访问列表或字典,就可以观察写入序列号。定期调用此函数,(类似)上报消费点位。
  5. 有一个全局读取序列号,定期存储所有活动线程的读取序列号中的最小值。当全局读取序号 > 页面标签号,空的 Mimalloc 页面就可以被重用。

总结

PEP 703 提出的移除 GIL 的设计,不仅解决了 GIL 带来的多线程性能瓶颈,还通过细粒度锁、乐观锁、RCU 和 STW 等多种机制,在性能和线程安全之间实现了巧妙的平衡,这些基本离不开本文导读提到的三个思路:

  • 锁的粒度
  • 要不要锁
  • 锁的实现

希望对你阅读 PEP 703 有帮助。

据 Python 路线图显示,至少要到 2028 年,GIL 才会被默认禁用

Ref: PEP 703 – Making the Global Interpreter Lock Optional in CPython

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值