有所收获,特将原文翻译如下。
原文:What is the Python Global Interpreter Lock(GIL)?
URL: https://realpython.com/python-gil/
目录
GIL为Python解决了什么问题
为什么选择GIL作为解决方案
给Python多线程应用带来的影响
为什么GIL至今未被移除
为什么未从Python3中移除
如何解决Python GIL带来的问题
简而言之,Python全局解释器锁(Global Interpreter Lock, GIL)是一个互斥信号量(互斥锁),保证仅有一个线程能持有Python解释器的控制权。
意味着任何时刻都只有一个线程可以处于执行状态。GIL不会对执行单线程应用的开发者带来影响,但可能会成为计算密集型(CPU bound)和多线程型应用的性能瓶颈。
即使在拥有多CPU内核的多线程架构中,GIL一次也只允许执行一个线程,被认为是Python臭名昭著的特性。
阅读本文,你能了解到GIL为什么会影响Python程序的性能,以及如何减轻由此带来的对代码的影响。
GIL为Python解决了什么问题
Python使用引用计数(reference counting)来进行内存管理。这意味着Python里创建对象会持有一个引用计数变量,用于追溯指向该对象的引用的数量。当引用计数的值减为零时,该对象占有的内存会被释放。
来看一个简短的代码示例,演示了引用计数的工作原理:
>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3
在示例中,指向空列表对象[]的引用计数为3,3个引用分别是:a, b, 传递给sys.getrefcount()的参数。
回到GIL:
问题在于引用计数变量需要被保护,以防在竞争情况下的两个线程同时增加或减少它的值。如果这种情况发生了,可能会引起从未释放的内存泄露,更坏的可能是在仍存在对象的引用时错误地释放内存。这些可能导致Python程序的崩溃或其他不知所以然的bug。
为跨线程共享的所有数据结构加锁,可以保证引用计数变量的安全,防止不一致的修改。
但为一个或者一组对象加锁,意味着多个锁将同时存在,可能会引起死锁(Deadlocks,只在拥有多个锁时才可能发生)。另一副作用是重复地获取和释放锁会带来性能下降。
GIL是作用于解释器自身的唯一的锁,并新定一条规则:执行任何Python字节码都需要获取解释器锁。由此防止死锁(现在就只有一个锁啦),也不会引入过多额外的性能开销。但是它强制所有计算密集型的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,是在Python的历史早期 CPython开发人员所面临的一个难题的务实的解决方案。
给Python多线程应用带来的影响
当你在阅读典型的Python程序(或任何与此相关的计算机程序)时,CPU密集型和I/O密集型应用的性能差异往往很大。
CPU密集型应用指CPU占用率极高的应用,包括数学计算(比如矩阵乘法),搜索,图像处理等等应用。
I/O密集型应用是指将大量时间花费等待在I/O读写的应用,I/O可以来自于用户、文件、数据库、网络等。I/O源在准备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)
我的四核电脑上跑这段代码获取的输出如下:
$ 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开发人员收到很多对GIL的怨言,但对于一门像Python这样被广泛应用的语言而言,在不引起向后不兼容问题的情况下,删除GIL这种操作实在是太大的改动了。
GIL当然可以被移除,开发人员和研究人员也尝试过多次,但无一例外地破坏了现有的C扩展,这些扩展在很大程度上都依赖于GIL提供的解决方案。
当然哈,对于GIL解决的问题也有其他的解决方案,但是其中某些降低了单线程和多线程I/O应用的性能,而另外某些真的太难了。毕竟,没人愿意新版本发布后 现有的Python程序跑得更慢,是吧?
Python的创建者和“仁慈的独裁者”(Benevolent Dictator for Life,BDFL)Guido van Rossum,在2007年9月的文章中给了社区一个答案:删除GIL并不容易。
只有当单线程应用(以及多线程的I/O应用)的性能不会降低时,我在愿意在Py3k中发布一系列补丁。
但至那以后的所有尝试都没有满足这个条件。
为什么未从Python3中移除
Python3确实有机会从头开始启用一些功能,并在此过程中破坏一些现有的C扩展,这些扩展需要更新并移植到Python3中才能使用。这也是Python3早期版本被社区接纳较慢的原因。
但为什么不将GIL一起移除呢?
移除GIL会让Python3的单线程性能比Python2更慢,能想到会带来什么结果。确实无法质疑GIL带来的单线程优势,所以最后结果是Python3 仍然留下了GIL。
但是Python3也对现有的GIL进行了重大的改进。
我们讨论了GIL对“仅计算密集(only CPU bound)”和“仅I/O密集(only I/O bound)”的多线程应用带来的影响,但对于有些线程是I/O型、有些线程是CPU型的应用呢?
众所周知(嘤嘤嘤,我不知道吖),对于这类应用,Python的GIL会导致I/O线程饥饿,因为这类线程不能从CPU型线程中获取到GIL。
因为Python内置了一种机制,以强制线程在一段固定的连续使用后释放GIL,如果没有其他获取到GIL,则该现场继续使用GIL。
>>> import sys
>>> # The interval is set to 100 instructions:
>>> sys.getcheckinterval()
100
这种机制的问题在于,多数情况下,CPU型现场会在其他线程可以获取GIL之前重新获取到GIL。结论由David Beazley得出,也可以找到相关的可视化解释。
[ 我的理解:线程会在一个时间段后释放GIL,但在释放的时间点,I/O仍可能处于等待中而不去获取GIL,于是CPU线程则会继续持有该GIL,如此导致IO线程饥饿。 ]
如何解决Python GIL带来的问题
如果Python GIL给你带来了困扰,可以尝试以下几种解决方案。
多进程 vs 多线程(multi-processing vs multi-threading)
最常用的方式是使用多进程而非多线程。每个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只在原始的Python实现(Cpython)中存在。如果你的程序和相关库有其他可用的实现,不妨尝试一下。
多等一会会儿
(不是在卖萌。)许多Python用户都利用了GIL带来的单线程优势。多线程码农也不用为此烦恼,因为Python社区中最聪明的一些人正在努力从CPython中移除GIL,“Gilectomy”是其中较闻名的尝试之一。
Python GIL经常被认为是一个神秘且具难度的话题。但是请记住,作为一个Python高手(Pythonista),通常只有在编写C扩展或者在程序中使用CPU型多线程时才会被影响。
在这种情况下,本文已为您提供了解GIL及如何处理它的相关内容。如果你想了解GIL底层工作原理,我建议您观看David Beazley的Python GIL演讲。