< 返回博客

[翻译] 什么是Python的全局解释器锁?


简而言之,Python全局解释器锁,或者说GIL,其实是互斥锁,它保证只有一个线程拥有Python解释器的控制权。

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

由于即使是在拥有超过多个CPU的多线程架构下,GIL还是只允许一个线程同时运行,因此GIL也作为Python中的一个“臭名昭著”的特性而闻名。

在这篇文章中,你将了解到GIL是如何影响到你的Python程序的性能的,以及如何减小它对你的代码造成的影响。

GIL为Python解决了什么问题?

Python在内存管理中使用了引用计数,这就意味着在Python中创建的对象拥有一个引用计数变量,这个变量追踪着指向这个对象得到引用数量。当这个引用计数下降到0时,这个对象所占有的内存就会被释放。

下面这段简单的代码演示了引用计数是如何工作的:

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

在上面这个例子中,空列表对象的引用计数是3,它分别被ab和传递给sys.getrefcount()的参数所引用。

回到GIL。现在的问题在于,这个引用计数变量需要保护,以防止两个线程同时增加或减小其值的竞态条件。如果这个问题出现了,那就可能导致内存泄漏而永不释放(内存),更糟糕的情况下,它会导致在仍然存在对对象的引用的时候,进行错误的内存释放,这可能导致你的Python程序崩溃或者其他“奇怪的”bug。

可以通过为线程间共享的数据结构加锁,以使得他们不会被同时更改,以此保证这个引用计数变量的安全。

但是为每个对象或是一组对象加锁就意味着会同时存在多个锁,这就可能导致另一个问题:死锁(死锁只可能在存在多个锁的情况下出现)。另一个副作用是重复的请求和释放锁会导致性能的降低。

GIL是解释器自身的单一锁,它添加了一个规则,即任何Python字节码的执行都需要获取这个解释器锁。这样子就避免了死锁(因为只有一个锁),并且不会导致太多的性能开销,但是实际上也造成了任何CPU密集型的Python程序都变成了单线程的。

虽然其他语言的解释器也用到了GIL(比如Ruby),但这并非这个问题的唯一解决方案。一些语言使用不同与引用计数的方法(比如垃圾回收),避免了在线程安全的内存管理上使用GIL。

另一方面,这就意味着这些语言往往需要添加其他的性能提升功能(比如JIT编译器)来弥补GIL的单线程性能优势的损失。

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

那为什么Python使用了这种看起来如此困难的方法?这是Python开发人员的错误决定吗?

Larry Hastings的话来说,GIL的设计决定是使得Python今天如此流行的原因之一。

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

Python需要很多现有的C库提供的功能,(所以开发人员)为这些C库写了很多的扩展。为了避免不一致的更改,这些C扩展需要GIL提供的线程安全的内存管理。

GIL实现起来很简单,也很容易被加入到Python之中。因为只需要管理一个锁,所以它会使得单线程程序的性能有所提升。

线程不安全的C库变得很容易迁移,也正是因为这些C扩展,Python才会被不同的社区欣然接受。

如你所见,GIL是CPython开发人员在早期Python中面临的一个难题的务实的解决方案。

对多线程Python程序影响

当你查看一个典型的Python程序或是与此相关任何其他程序时,就会发现CPU密集型程序与IO密集型的性能差异。

所谓CPU密集型的程序是指最大化利用CPU的程序,包括做数学运算(比如矩阵乘法)、搜索、图片处理等等。

而IO密集型的程序是指那些花费时间去等待来自用户、文件、数据库或者网络等的输入或输出的程序。由于在输入输出准备好之前,资源可能需要做一些处理,所以IO密集型程序有时候必须等待很长的时间,直到它们获取到了他们想要的资源。例如,用户思考向输入提示符中输入什么,或者数据库在自己的进程中运行的查询过程。

我们来看看一个执行递减的简单的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密集型线程并行执行。

在IO密集型的程序中,GIL不会造成太大的影响,因为在线程等待IO的时候,它们共享GIL。

但是所有线程都是完全的CPU密集型的程序(比如用多线程处理一张图片的多个部分)不仅会因为GIL而变成单线程的,并且执行时间也会变长,比如上面的例子中,(多线程版本)与完全单线程版本的比较。

执行时间变长的原因是GIL导致的锁的获取与释放。

为什么GIL还没有被移除?

Python的开发者经常收到对此的抱怨,但是像Python这样流行的语言,不可能在引入删除GIL这么重大的改动时还不产生向后兼容性问题。

GIL看似可以被移除,但是这样的尝试开发者和研究人员们过去已经进行了多次,而所有的尝试都破坏了已存在的C扩展,因为这些C扩展都严重依赖GIL所提供的解决方案。

当然,也有其他的一些方法可以解决GIL所解决的问题,但是其中一些方案降低了单线程和多线程下的IO密集型程序的性能,而另一些则太过困难。毕竟你不想让你的Python代码在新版本上跑的反而更慢了,不是吗?

Python之父、“仁慈的终身独裁者”,Guido van Rossum,在它的2007年九月份的文章“[It isn't Easy to remove the GIL]”上给了社区答案:

只有在单线程(以及多线程IO密集型)程序的性能不下降的情况下,我才能欢迎向Py3k打上一系列补丁。

而此后的所有尝试都没有满足这个条件。

为什么不在Python3中移除它?

Python3的确有机会从头开始很多新功能,在此过程中,破坏一些存在的C扩展,让它们更新以适配Python3,这就是为什么Python3的早期版本被社区适配缓慢的原因。

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

移除GIL将会使得Python3在单线程性能上比Python2还要慢,你可以想象一下这会导致什么,GIL的单线程性能优势是无可辩驳的,这就导致了Python3中也有GIL。

但是Python3也的确为已存在的GIL带来了很大的提升:

我们讨论过GIL对于“纯CPU密集型”和“纯IO密集型”多线程程序的影响,但是对于一部分线程是IO密集型而另一部分线程是CPU密集型的程序来说呢?

众所周知,在这样的程序里,Python的GIL会由于不给IO密集型线程从CPU密集型线程中获取锁的机会而饿死IO密集型线程。

这是因为Python内部的一种机制,它会强制线程在连续使用固定的间隔之后释放掉GIL,如果没有人请求GIL则会继续执行当前线程。

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

这个机制的问题是,大多是时间里,CPU密集型的线程将会在其他线程获取之前重新获取到GIL。David Beazley的研究和可视化可以在这里找到。

这个问题在2009年的Python3.2中被Antoine Pitrou修复,他加入了一个机制,检查其他线程的GIL获取请求,不允许当前的线程在其他线程有机会运行之前重新获取到GIL。

如何处理Python的GIL?

如果GIL为你带来了问题,这有一些你可以尝试的办法:

1. 多进程vs多线程

最常见的方法就是使用多进程而不是多线程。每个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

相对于多线程地版本有了像样地性能提升是吧?

运行时间并非我们想象中的下降一半,因为进程管理需要它自己的开销。多进程比多线程要更重一点,所以,记住这有可能是一个不断扩大的瓶颈。

2. 其他的Python解释器

Python有很多的解释器实现,常见的有CPython、Jython、IronPython以及PyPy,分别用C语言、Java、C#和Python写的。GIL只在原生的Python实现CPython中存在。如果你的程序和它所使用的库在其他的实现上可以运行,那你也可以尝试一下。

3. 再等一等

尽管很多用户正在享受着GIL的单线程性能优势,多线程的开发者也不需要沮丧,因为Python的社区中有很多聪明的头脑正在为移除CPython中的GIL而努力,比如Gilectomy

总结

Python的GIL常常被认为是一个疑惑和困难的话题,但是请记住,作为一个Pythonista(译者:指使用Python的人),只有当你写C扩展或者是在程序中使用了CPU密集型的多线程时,你才会受他影响。

在这种情况下,这篇文章应该为你提供了解GIL是什么以及如何在你的项目中处理它所需的一切。如果你想要理解GIL底层的内部过程,我推荐你去观看David Beazley讲的Understanding the Python GIL


原文链接:What is the Python Global Interpreter Lock (GIL)?


译者记

这篇文章从务实的角度出发,讲述了Python的GIL的产生和发展,以及它的意义。

其实在我的理解中,线程概念的出现本就是为了方便将任务抽象成多个子任务,而抽象必不可少的就要付出代价,所以我们需要各种各样的锁,也就是线程管理一定需要开销。因此,大概是不存在一种两全其美的办法,既能方便编写程序,又能让性能毫无损失。从时间复杂度上来说,我们只能够不断地逼近1+1=2,而实际上只会是1+1>2。

哈,又扯远了。有时候我也在想,存不存在一种抽象,它能够完美地概括所有的形式,也就是毫无损失的抽象。大概很难吧,或者说不可能,人的思维的工作方式就决定了它的不可能。