![10fa6ae83fc912b5abaf33332670e60f.png](https://i-blog.csdnimg.cn/blog_migrate/50d807e766375647c5065301d3c53692.jpeg)
本文翻译自:https://realpython.com/python-gil/
作者:Abhinav Ajitsaria 译者:@__bot__
简单来说,Python全局解释器锁(Global Interpreter Lock)或GIL是一个互斥锁,它只允许一个线程来控制Python解释器。
这意味着在任何时间点只有一个线程可以处于执行状态。执行单线程程序的开发人员感受不到GIL的影响,但它可能是CPU限制型和多线程代码中的性能瓶颈。
由于即使在具有多个CPU核心的多线程架构中,GIL一次只允许一个线程执行,因此GIL已经成为Python“臭名昭着”的特性。
在本文中,您将了解GIL如何影响Python程序的性能,以及如何减轻它可能对代码产生的影响。
GIL为Python解决了什么问题?
Python使用引用计数进行内存管理。这意味着在Python中创建的对象具有引用计数变量,该变量用于跟踪指向该对象的引用数。当此计数达到零时,释放对象占用的内存。
让我们看一个简短的代码示例来演示引用计数的工作原理:
>>>
>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount (a)
3
在上面的示例中,空列表对象的引用计数为3。列表对象由a,b引用并且参数传递给sys.getrefcount()。
回到GIL:
问题是这个引用计数变量需要保护竞争条件。如果其中两个线程同时增加或减少其值,如果发生这种情况,它可能导致从未释放的内存泄漏,或者更糟糕的是,在对该对象的引用仍然存在时错误地释放内存。这可能会导致Python程序中出现崩溃或其他“怪异”错误。通过向跨线程共享的所有数据结构添加锁,可以保持此引用计数变量的安全性,从而不会对它们进行不一致的修改。
但是为每个对象或对象组添加一个锁意味着将存在多个锁,这可能导致另一个问题 - 死锁(死锁只有在有多个锁时才会发生)。另一个副作用是由于重复获取和释放锁而导致性能下降。
GIL是解释器本身的单个锁,它增加了一条规则,即执行任何Python字节码都需要获取解释器锁。这可以防止死锁(因为只有一个锁)并且不会引入太多的性能开销。但它有效地使任何受CPU限制的Python程序都是单线程的。
GIL虽然被解释器用于其他语言(如Ruby),但并不是解决此问题的唯一方法。有些语言通过使用除引用计数之外的方法(例如垃圾收集)来避免GIL对线程安全内存管理的要求。
另一方面,这意味着这些语言通常需要通过添加其他性能提升性能(如JIT编译器)来弥补GIL单线程性能优势的损失。
为什么选择GIL作为解决方案?
那么,为什么在Python中使用的方法看似如此阻碍呢?这是Python开发人员的糟糕决定吗?
好吧,用Larry Hastings的话来说, GIL的设计决定是让Python像今天一样受欢迎的原因之一。
自从操作系统没有线程概念以来,Python就已存在。Python的设计易于使用,以便更快地开发,越来越多的开发人员开始使用它。
开发人员正在为Python需要的功能编写许多C库扩展。为了防止不一致的更改,这些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并没有被删除?
删除GIL会使Python 3在单线程性能方面比Python 2慢,你可以想象会产生什么结果。你不能争论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研究的,可以在这里找到可视化。
Antoine Pitrou在2009年的Python 3.2中修复了这个问题,他添加了一种机制,可以查看被抛弃的其他线程的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有多个解释器实现。分别用C,Java,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的演讲。