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

简而言之,Python全局解释器锁或GIL是一种互斥锁(或锁),仅允许一个线程持有Python解释器的控制权。

这意味着在任何时间点只有一个线程可以处于执行状态。对于执行单线程程序的开发人员而言,GIL的影响并不明显,但它可能是CPU绑定和多线程代码的性能瓶颈。

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

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

为什么Cpython需要GIL?

Python使用引用计数进行内存管理。

译注:还有标记清除和分代回收

这意味着用Python创建的对象具有引用计数变量,该变量跟踪指向该对象的引用数。当此计数达到零时,将释放对象占用的内存。

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

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

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

回到GIL:

问题在于该引用计数变量需要保护,以防止两个线程同时增加或减少其值的竞争状态。如果发生这种情况,则可能导致从未释放的内存泄漏,或者更糟糕的是,在仍然存在对该对象的引用的情况下,错误地释放了内存。这可能会导致崩溃或Python程序中的其他“怪异”错误。

通过将锁添加到跨线程共享的所有数据结构中,以确保它们不会被不一致地修改,可以保持此引用计数变量的安全。

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

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

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

另一方面,这意味着这些语言通常必须通过添加其他性能提升功能(例如JIT编译器)来弥补GIL的单线程性能优势的损失。

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

那么,为什么在Python中使用了一种看起来如此阻碍的方法呢?Python开发人员是否会做出错误的决定?

好吧,用Larry Hastings的话来说, GIL的设计决定是使Python像今天一样流行的原因之一。

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

现有的C库正在编写许多扩展,这些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


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)

输出结果:Time taken in seconds: 2.232430934906006

现在,我使用两个并行线程对代码进行了一些修改,以实现相同的倒计时:

# 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: 2.353055953979492

如您所见,两个版本花费的时间几乎相同。在多线程版本中,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
>>> sys.getswitchinterval()
0.005

这种机制的问题在于,在大多数情况下,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.time()
    r1 = pool.apply_async(countdown, [COUNT//2])
    r2 = pool.apply_async(countdown, [COUNT//2])
    pool.close()
    pool.join()
    end_time = time.time()
    print('Time taken in second:', end_time-start_time)

输出结果:Time taken in second: 1.3340442180633545
与多线程版本相比,性能提高了,对吗?

时间并没有减少到我们上面看到的一半,因为流程管理有其自己的开销。多个进程比多个线程重,因此请记住,这可能会成为扩展瓶颈。

备选的Python解释器: Python具有多种解释器实现。最受欢迎的分别是用C,Java,C#和Python编写的CPython,Jython,IronPython和PyPy。GIL仅存在于原始Python实现中,即CPython。如果您的程序及其库可用于其他实现之一,则也可以尝试一下。

**稍后:**许多Python用户都利用了GIL的单线程性能优势。多线程程序员不必烦恼,因为Python社区中一些最聪明的人正在努力从CPython中删除GIL。一种这样的尝试被称为“ Gilectomy”。

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

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

原文出处:https://realpython.com/python-gil/

发布了54 篇原创文章 · 获赞 47 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/yuzhou_1shu/article/details/105527026