GIL全局解释器锁

转载:https://realpython.com/python-gil/

Python 新提案:删除全局解释器锁 GIL,解放多线程性能

据 Python 基金会博客介绍,开发者 Sam Gross 在 2022 Python 语言峰会上带来了一个新提案:完全移除 CPython 解释器的 GIL- 全局解释器锁,使 Python 程序获得更快的性能 —— 尤其是多线程程序。

Python 有多个版本,包括 JVM 、 .NET CLR  解释器以及编译器,但该语言的核心实现仍是 CPython 解释器。由于 CPython 的内存管理非线程安全,因此设计了 CPython 的 GIL (Global Interpreter Lock - 全局解释器锁),以防止竞争条件并确保线程安全。 GIL 是一个互斥锁,只允许一个线程持有 Python 解释器的控制权,从而保护对 Python 对象的访问,防止多个线程同时执行 Python 字节码。 

但事后看来,GIL 并不理想,因为它阻止了多线程的 CPython 程序充分利用多核处理器的性能。但由于 GIL 长期存在,许多官方和非官方 Python 包和模块都深度融合了 GIL 模块,移除 GIL 功能的工作变得任重而道远。此前,开发者 Larry Hastings 在其 “Gilectomy” (GIL 切除手术)项目中试图完成 CPython GIL 功能的移除,但该项目失败了,因为它使单线程 Python 代码显着变慢。

而此次 Python 语言峰会带来了另外一个项目 “nogil”该项目由 Meta 开发人员 Sam Gross 主持,从项目名称不难看出,这也是一个专注于移除 GIL 的项目。参考了 Gilectomy 项目的失败经验, Sam Gross 意识到 :如果要使 Python 在没有 GIL 的情况下有效工作,则需要添加新的锁,以确保它仍然是线程安全的。然而,向现有代码添加新锁可能非常困难,因为新的锁可能会导致在部分领域的性能大幅下降。

据  Python 基金会的介绍,Gross 将发明一种新型锁,一种 “更吉利” 的锁。如果顺利的话,这个新锁很可能在 Python 3.12 版本亮相,因为 Gross 的提案就是 “在 Python 3.12 中引入一个新的编译器标志,该标志将禁用 GIL。”

为什么选择 GIL 作为解决方案?

那么,为什么在 Python 中使用了一种看似如此阻碍的方法呢?Python 的开发人员做出了一个错误的决定吗?

好吧,用Larry Hastings 的话来说, GIL 的设计决策是让 Python 像今天这样流行的原因之一。

自从操作系统没有线程概念以来,Python 就一直存在。Python 被设计为易于使用,以使开发更快,越来越多的开发人员开始使用它。

为现有的 C 库编写了许多扩展,这些库的特性在 Python 中是必需的。为了防止不一致的更改,这些 C 扩展需要 GIL 提供的线程安全内存管理。

GIL 易于实现,并且很容易添加到 Python 中。它为单线程程序提供了性能提升,因为只需要管理一个锁。

非线程安全的 C 库变得更容易集成。而这些 C 扩展成为 Python 被不同社区欣然采用的原因之一。

如您所见,GIL 是CPython开发人员在 Python 早期面临的难题的实用解决方案。

对多线程 Python 程序的影响

当您查看一个典型的 Python 程序(或任何计算机程序)时,在性能上受 CPU 限制的程序与受 I/O 限制的程序之间存在差异。

CPU 绑定程序是那些将 CPU 推到极限的程序。这包括进行数学计算的程序,如矩阵乘法、搜索、图像处理等。

I/O 绑定程序是那些花费时间等待来自用户、文件、数据库、网络等的输入/输出的程序。I/O 绑定程序有时必须等待大量时间,直到它们从源获取他们需要的东西,因为源可能需要在输入/输出准备好之前进行自己的处理,例如,用户考虑输入什么内容到输入提示或在其运行的数据库查询自己的过程。

让我们看一个执行倒计时的简单 CPU 密集型程序:

# single_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n>0:
        n -= 1

start = time.time()
countdown(COUNT)
end = time.time()

print('Time taken in seconds -', end - start)

在我的 4 核系统上运行此代码会得到以下输出:

$ python single_threaded.py
Time taken in seconds - 6.20024037361145

现在我稍微修改了代码以使用两个并行线程来执行相同的倒计时:

# multi_threaded.py
import time
from threading import Thread

COUNT = 50000000

def countdown(n):
    while n>0:
        n -= 1

t1 = Thread(target=countdown, args=(COUNT//2,))
t2 = Thread(target=countdown, args=(COUNT//2,))

start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
end = time.time()

print('Time taken in seconds -', end - start)

当我再次运行它时:

$ python multi_threaded.py
Time taken in seconds - 6.924342632293701

如您所见,两个版本都需要几乎相同的时间来完成。在多线程版本中,GIL 阻止了 CPU-bound 线程并行执行。

GIL 对 I/O 绑定的多线程程序的性能没有太大影响,因为在线程等待 I/O 时,锁是在线程之间共享的。

但是一个线程完全受 CPU 限制的程序,例如,一个使用线程处理部分图像的程序,不仅会因为锁而变成单线程,而且还会看到执行时间增加,如上面的示例所示,与将其编写为完全单线程的情况相比。

这种增加是锁增加的获取和释放开销的结果。

为什么 GIL 还没有被删除?

Python 的开发人员收到了很多关于这一点的抱怨,但是像 Python 这样流行的语言不能带来像删除 GIL 这样重大的变化而不会导致向后不兼容的问题。

GIL 显然可以被删除,过去开发人员和研究人员已经多次这样做,但所有这些尝试都破坏了现有的 C 扩展,这些扩展严重依赖于 GIL 提供的解决方案。

当然,GIL 解决的问题还有其他解决方案,但其中一些会降低单线程和多线程 I/O 绑定程序的性能,其中一些太难了。毕竟,您不希望现有的 Python 程序在新版本发布后运行得更慢,对吧?

Python 的创建者和 BDFL,Guido van Rossum,于 2007 年 9 月在他的文章“移除 GIL 并不容易”中对社区做出了回答:

“只有在单线程程序(以及多线程但受 I/O 绑定的程序)的性能不降低的情况下,我才会欢迎 Py3k 中的一组补丁

从那以后所做的任何尝试都没有满足这一条件。

为什么它没有在 Python 3 中被删除?

Python 3 确实有机会从头开始很多功能,并且在此过程中,破坏了一些现有的 C 扩展,这些扩展随后需要更新并移植以与 Python 3 一起使用。这就是早期版本的原因Python 3 的社区采用速度较慢。

但是为什么 GIL 没有一起被移除呢?

与 Python 2 相比,删除 GIL 在单线程性能方面会使 Python 3 变慢,您可以想象这会导致什么结果。您无法与 GIL 的单线程性能优势争论。所以结果是 Python 3 仍然有 GIL。

但 Python 3 确实为现有的 GIL 带来了重大改进——

我们讨论了 GIL 对“仅受 CPU 限制”和“仅受 I/O 限制”的多线程程序的影响,但是对于某些线程受 I/O 限制而某些线程受 CPU 限制的程序呢?

在这样的程序中,众所周知,Python 的 GIL 会饿死 I/O 密集型线程,因为它们不给它们从 CPU 密集型线程获取 GIL 的机会。

这是因为 Python 内置的一种机制,强制线程在连续使用固定间隔后释放 GIL ,如果没有其他人获得 GIL,同一个线程可以继续使用它。

>>>
>>> import sys
>>> # The interval is set to 100 instructions:
>>> sys.getcheckinterval()
100

这种机制的问题在于,大多数情况下,受 CPU 限制的线程会在其他线程获取 GIL 之前重新获取 GIL 本身。这是由 David Beazley 研究的,可以在此处找到可视化。

这个问题在 2009 年的 Python 3.2 中由 Antoine Pitrou 修复,他添加了一种机制,可以查看其他线程被丢弃的 GIL 获取请求的数量,并且不允许当前线程在其他线程有机会运行之前重新获取 GIL。

如何处理 Python 的 GIL

如果 GIL 给您带来问题,您可以尝试以下几种方法:

多处理与多线程:最流行的方法是使用多处理方法,在这种方法中使用多个进程而不是线程。每个 Python 进程都有自己的 Python 解释器和内存空间,因此 GIL 不会成为问题。Python 有一个multiprocessing模块可以让我们像这样轻松地创建进程:

from multiprocessing import Pool
import time

COUNT = 50000000
def countdown(n):
    while n>0:
        n -= 1

if __name__ == '__main__':
    pool = Pool(processes=2)
    start = time.time()
    r1 = pool.apply_async(countdown, [COUNT//2])
    r2 = pool.apply_async(countdown, [COUNT//2])
    pool.close()
    pool.join()
    end = time.time()
    print('Time taken in seconds -', end - start)

在我的系统上运行它会给出以下输出:

$ python multiprocess.py
Time taken in seconds - 4.060242414474487

与多线程版本相比,性能得到了不错的提升,对吧?

时间并没有减少到我们上面看到的一半,因为流程管理有它自己的开销。多进程比多线程重,因此请记住,这可能会成为扩展瓶颈。

替代 Python 解释器: Python 有多个解释器实现。分别用CJava 、C# 和 Python编写的CPython、Jython、IronPython 和PyPy是最受欢迎的。GIL 仅存在于 CPython 的原始 Python 实现中。如果您的程序及其库可用于其他实现之一,那么您也可以尝试它们。

等等吧:虽然许多 Python 用户利用了 GIL 的单线程性能优势。多线程程序员不必担心,因为 Python 社区中一些最聪明的人正在努力从 CPython 中删除 GIL。一种这样的尝试被称为Gilectomy

Python GIL 通常被认为是一个神秘而困难的话题。但请记住,作为 Pythonista,您通常只有在编写 C 扩展或在程序中使用受 CPU 限制的多线程时才会受到它的影响。

在这种情况下,本文应该为您提供了解 GIL 是什么以及如何在您自己的项目中处理它所需的一切。如果您想了解 GIL 的低级内部工作原理,我建议您观看 David Beazley 的“了解 Python GIL”演讲。

posted @ 2022-05-18 22:09  linhaifeng  阅读(296)  评论(0编辑  收藏  举报