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

发布于:2021-01-21 09:08:26

0

288

0

Abhinav Ajitsaria 高级 python

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

这意味着在任何时间点,只有一个线程可以处于执行状态。GIL的影响对于执行单线程程序的开发人员来说是看不到的,但是它可能成为CPU受限和多线程代码的性能瓶颈。

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

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

GIL为Python解决了什么问题?

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

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

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

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

参数返回到GIL:

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

可以通过向线程间共享的所有数据结构添加锁来确保此引用计数变量的安全,这样就不会对它们进行不一致的修改。

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

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

GIL虽然被Ruby等其他语言的解释器使用,但并不是解决这个问题的唯一方法。有些语言通过使用引用计数以外的方法(如垃圾收集)来避免线程安全内存管理对GIL的要求。另一方面,这意味着这些语言通常必须通过添加其他性能提升功能(如JIT编译器)来弥补GIL单线程性能优势的损失。

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

那么,为什么在Python中使用了一种看起来非常困难的方法呢?Python的开发人员做出了一个错误的决定吗?用拉里·黑斯廷斯的话说,GIL的设计决定是使Python像今天一样流行的原因之一。

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

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

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

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

正如您所见,GIL是一个实用的解决方案,解决了Python开发人员在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中安装一组补丁”。

而且这种情况还没有得到解决通过此后的任何尝试来完成。

为什么在python3中没有删除它?

python3确实有机会从头开始很多特性,在这个过程中,它打破了一些现有的C扩展,这些扩展需要进行更新和移植才能与python3一起使用。这就是为什么Python3的早期版本被社区采用的速度较慢的原因。

但是为什么没有同时移除GIL呢?

删除GIL会使python3在单线程性能上比python2慢,您可以想象这会导致什么结果。您不能否认GIL的单线程性能优势。因此,结果是Python 3仍然具有GIL。

但是Python 3确实给现有的GIL带来了重大改进-

我们讨论了GIL对“仅CPU绑定”和“仅I/O绑定”多线程程序的影响,但是对于一些线程是I/O绑定的程序和一些线程是CPU绑定的程序呢?在这样的程序中,众所周知Python的GIL会使I/O绑定的线程饥饿,因为没有给它们机会从CPU绑定的线程获取GIL同一个线程可以继续使用。

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

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

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