![](https://i-blog.csdnimg.cn/blog_migrate/81f3b7338daf0349dfb0a2ee243f29e1.jpeg)
目录
一、Python 全局解释锁(GIL)是啥?
Python 全局解释锁(Global Interpreter Lock,GIL)是一种互斥(或称为锁)的机制,在一个时间点下它只允许一个线程获取 Python 解释器的控制权。这意味着在一个时间点下只有一个线程在执行。对于单线程开发工作,GIL 的影响是看不到的,但是对于绑定 CPU 和多线程代码而言,GIL 将成为瓶颈。所以即使你的代码采用的是多线程机制,即使你的 CPU 是多核的,在任意时间点实际上仍只有一个线程在执行,这就是 Python 在多线程方面 “臭名昭著” 的原因,我们下面将探讨 GIL 对你 Python 代码的影响以及如何尽可能降低这种影响。
1.1、GIL 解决了 Python 什么问题
Python 使用引用计数进行内存管理,这意味着在 Python 中创建的每个对象都有一个引用计数变量,该变量标记指向该对象的引用数量。当计数达到零时,该对象占用的内存将被释放。我们通过一个简单的例子来演示一下:
>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3
在上面的例子中,空列表对象 []
的引用计数是 3,分别被变量 a
、b
以及传递到 sys.getrefcount()
的参数所引用。
回到 GIL 问题上,由于这个引用计数变量需要保护,以避免两个具有竞争条件的线程同时增加或减少其值。如果发生这种情况,它可能导致内存泄露,内存永远不会释放,或者更糟糕的是,错误地释放内存,而该对象的引用却仍然存在,这可能会导致 Python 程序崩溃或其他“奇怪”的错误。(译者注:我们目前使用的基本都是 CPython 解释器,它的内存管理不是线程安全的,Jython 和 IronPython 就不存在 GIL 的问题)
所以这个引用计数变量可以通过向多线程共享的所有数据结构添加锁来保持数据安全,这样它们就不会被不一致地修改(译者注:多线程是 share memory 的)。但是,对每个对象或对象组都添加一个锁,这意味着将存在多个锁,这会导致另一个问题,死锁(死锁只有在一个以上的锁时才会发生)。另一个副作用是重复获取和释放锁会降低性能。因此 GIL 是解释器的一个锁,它添加了这样的一个规则,即执行任何 Python 字节码前都需要先获取解释器锁。这样可以防止死锁(因为只有一个锁),并且不会带来太多性能的开销。它可以有效地使任何 CPU 绑定的 Python 程序都是单线程的。
GIL虽然也被 Ruby 等其他语言的解释器使用,但这并不是解决这个问题的唯一方法。一些语言也通过使用引用计数以外的方法(如垃圾收集)来避免 GIL 对线程安全内存管理的要求。但这意味着这些语言通常必须通过添加其他性能提升功能(如JIT编译器)来补偿 GIL 的单线程性能优势的损失。
小结:
Cpython 解释器的内存管理不是线程安全的,Python 使用的是引用计数进行内存管理的,因此存在多线程同时修改对象引用计数的可能,对此需要引入锁机制,但每个对象均加锁会造成死锁等问题,索性只加一个全局锁,这样一来严格意义上讲,不存在多线程了,因此任何一个时间点,只允许一个线程在执行。
1.2、为什么选择了 GIL 这个解决方案
那么,为什么要在 Python 中使用这种看起来如此智障的方案呢?是因为 Python 开发人员的错误决定吗?当然不是,Python 在操作系统还没有线程的概念以前就存在,Python 被设计为易于使用,以便使开发更快,使越来越多的开发人员开始使用它。为了满足 Python 的需要,他们用已存在的 C 库写了许多扩展,但为了避免不一致的修改问题,这些 C 库需要 GIL 提供的线程安全的内存管理机制。另外 GIL 易于实现,可以很容易的添加到 Python 中,而且只需要管理一个锁,因此这提高了单线程程序的性能。这样一来,非线程安全的 C 库也变得更容易集成,这些 C 扩展也成为 Python 被不同社区采用的原因之一。如你所见,GIL 是解决早期 Python 开发人员面临的一个难题的实用解决方案。
1.3、对 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)
# on my system with 4 cores, 3.1 GHz Intel Core i7
# Time taken in seconds - 3.3155179023742676 (2.86-3.3)
# 原文作者给出的时间: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)
# Time taken in seconds - 3.0341949462890625 (2.73-3.03)
# 作者给出的时间是 Time taken in seconds - 6.924342632293701
如你所见,两个版本的完成时间几乎相同。在多线程版本中,GIL 阻止 CPU 密集型多线程并行执行。GIL 对 I/O 密集型的多线程程序性能没有太大的影响,因为在线程等待 I/O 时,锁在线程之间是共享的。但是,如果一个程序的线程完全受到 CPU 的限制,那么它不仅会由于锁的存在而变成单线程,而且执行时间也会增加,如上面的例子所示。这一增长是由锁的获取和释放开销造成的。
1.4、为什么 GIL 还没有被移除
Python 的开发人员对此收到了很多抱怨,但是像 Python 这样流行的语言在不引起向后不兼容问题的情况下,不能带来像删除 GIL 那样重要的变化。显然,GIL 可以被移除,这在过去由开发者和研究者多次完成,但是所有这些尝试都打破了现有的 C 扩展,这很大程度上依赖于 GIL 提供的解决方案。当然,还有很多解决方案,但其中一些解决方案会降低单线程和多线程 I/O 密集型程序的性能,有些解决方案太难了。毕竟,在新版本出现后,您不会希望现有的 Python 程序运行得更慢,对吧?
Guido van Rossum 是 Python 的创建者和 BDFL(Benevolent Dictator For Life,仁慈的独裁者),他在 2007年 9 月的一篇文章 “删除 GIL 并不容易” 中向社区给出了答案:
“I’d welcome a set of patches into Py3k only if the performance for a single-threaded program (and for a multi-threaded but I/O-bound program) does not decrease”
“仅当单线程程序(以及 I/O 密集型多线程程序)的性能没有降低时,我才欢迎在 Py3k 中使用一组修补程序”
从那以后的任何尝试都没有满足这个条件。
1.5、为什么不在 Python 3 中移除 GIL
Python 3 确实有机会从零开始进行大量的功能开发,但在这个过程中,打破了一些现有的 C 扩展,这需要更新才能移植到 Python 3。这就是为什么早期版本的 Python 3 被社区采用的速度较慢的原因。
但是为什么 GIL 没有被删除?删除 GIL 会使 Python 3 在单线程性能上比 Python 2 慢,您可以想象这会导致什么结果。因此,结果是 Python 3 仍然拥有 GIL。但是 Python 3 确实给现有的 GIL 带来了很大的改进。
我们前面讨论了 GIL 对“单纯CPU密集型”和“纯 I/O 密集型”多线程的影响,但是对于部分线程是 I/O 密集型的,部分线程是 CPU 密集型的程序呢?在这样的程序中,Python 的 GIL 会使 I/O 密集型的线程长时间等待(strave,挨饿),因此他们没有从 CPU 密集型线程中获取 GIL 的机会。这是一种 Python 内置的机制,该机制强制线程在固定的连续使用间隔之后释放 GIL,但如果没有线程获得 GIL,则同一线程可以继续使用 GIL。
>>> import sys
>>> # The interval is set to 100 instructions:
>>> sys.getcheckinterval()
100
这种机制的问题是,在大多数情况下,CPU 密集型的线程会在其他线程获取 GIL 之前重新获取它本身。这是 David Beazley 的研究和可视化效果。
2009年,Antoine Pitrou 在Python 3.2 中修复了这个问题,他添加了一种机制,查看其他线程丢弃的GIL获取请求的数量,并且不允许当前线程在其他线程有机会运行之前重新获取 GIL。
1.6、怎样处理 Python 的 GIL
如果 GIL 让你不爽,你可以尝试以下几种方法:
Multi-processing vs multi-threading: 可以使用多进程而不是多线程的方式,每个 Python 进程都有自己的 Python 解释器和内存空间,因此不存在 GIL 问题。
多处理与多线程:最流行的方法是使用多处理方法,即使用多个进程而不是线程。每个Python进程都有自己的Python解释器和内存空间,因此GIL不会成为问题。Python有一个多处理模块,让我们可以轻松地创建如下流程:
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)
# Time taken in seconds - 1.45 ~ 1.96
# 原文作者给出的时间:Time taken in seconds - 4.060242414474487
与多线程版本相比,性能有了一些提高,但时间并没有减少到我们上面看到的一半,因为进程管理有自己的管理开销。多进程比多线程更繁重,因此需要知道,这可能会成为一个扩展瓶颈。
其他可选的 Python 解释器: Python 有多个解释器实现。CPython、Jython、IronPython 和 PyPy 分别用C、Java、C 和 Python 编写。GIL 只存在于原来的 Python 实现中,即 CPython。如果您的程序及其库可用于其他实现之一,那么您也可以尝试它们。
稍微等等: 尽管许多 Python 用户可以利用 GIL 的单线程性能优势。但多线程编程人员也不必担心,因为 Python 社区中一些最聪明的人正在努力从 CPython 中删除 GIL。其中一种尝试被称为 Gilectomy.。
Python GIL 通常被认为是一个神秘而困难的话题。但请记住,作为一个 Pythonista,只有在编写 C 扩展或者在程序中使用 CPU 密集型的多线程时,才会受到它的影响。如果你想知道 GIL 的底层工作原理,推荐观看 David Beazley 的 Understanding the Python GIL