什么是 Python 全局解释器锁 (GIL)?

Python 全局解释器锁或GIL,简单来说,是一个互斥锁(或锁),它只允许一个线程控制 Python 解释器。

这意味着在任何时间点都只能有一个线程处于执行状态。执行单线程程序的开发人员看不到 GIL 的影响,但它可能是 CPU 密集型和多线程代码中的性能瓶颈。

由于即使在具有多个 CPU 内核的多线程架构中,GIL 也只允许一次执行一个线程,因此 GIL 被称为 Python 的“臭名昭著”的特性。

在本文中,您将了解 GIL 如何影响 Python 程序的性能,以及如何减轻它可能对您的代码产生的影响。

GIL 为 Python 解决了什么问题?

Python 使用引用计数进行内存管理。这意味着在 Python 中创建的对象有一个引用计数变量,用于跟踪指向该对象的引用数量。当这个计数达到零时,对象占用的内存被释放。

让我们看一个简短的代码示例来演示引用计数的工作原理:

>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

在上面的示例中,空列表对象的引用计数为[]3。列表对象由 引用ab参数传递给sys.getrefcount()

回到 GIL:

问题是这个引用计数变量需要保护免受两个线程同时增加或减少其值的竞争条件。如果发生这种情况,可能会导致永远不会释放的内存泄漏,或者更糟糕的是,在对该对象的引用仍然存在的情况下错误地释放内存。这可能会导致 Python 程序崩溃或其他“奇怪”的错误。

这个引用计数变量可以通过向所有线程共享的数据结构添加来保持安全,这样它们就不会被不一致地修改。

但是为每个对象或对象组添加锁意味着将存在多个锁,这可能会导致另一个问题——死锁(死锁只有在有多个锁时才会发生)。另一个副作用是重复获取和释放锁导致的性能下降。

GIL 是解释器本身的单锁,它添加了一条规则,即执行任何 Python 字节码都需要获取解释器锁。这可以防止死锁(因为只有一个锁)并且不会引入太多的性能开销。但它有效地使任何受 CPU 限制的 Python 程序成为单线程的。

尽管 GIL 被其他语言(如 Ruby)的解释器使用,但并不是解决这个问题的唯一方法。某些语言通过使用引用计数以外的方法(例如垃圾收集)来避免线程安全内存管理对 GIL 的要求。

另一方面,这意味着这些语言通常必须通过添加其他性能提升功能(如 JIT 编译器)来弥补 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 绑定线程并行执行。

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 有多种解释器实现。CPython、Jython、IronPython 和PyPy 分别用C、Java、C# 和 Python编写,是最受欢迎的。GIL 仅存在于 CPython 的原始 Python 实现中。如果您的程序及其库可用于其他实现之一,那么您也可以尝试使用它们。

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

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

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

你可能感兴趣的:(什么是 Python 全局解释器锁 (GIL)?)