这篇文章详细的解释一下 Python 里面的全局解释器锁(Global Interpreter Lock,GIL)。
一、GIL 是什么?
Global Interpreter Lock (以下简称 GIL),简单来说,就是一把锁。这把锁在任意时刻只允许一个 Python 进程使用 Python 解释器。
也就是任意时刻,Python 只有一个线程在运行。
这种 GIL 锁的设计对于只写单线程运行的程序员来说其实没有什么影响(如果你还不知道什么是多线程,那你写的就是单线程)。但是对于计算密集型的程序(CPU-bound)和基于多线程的程序来说,Python 的 GIL 设计很有可能会造成性能瓶颈。
因为在任意时刻 GIL 都只允许一个线程运行,即使你的代码跑在多核 CPU 的电脑上也是如此。因此 GIL 在某些人眼里成为了 Python 里一个臭名昭著的特性。
这篇文章主要说一下 GIL 是如何影响我们写的代码的,以及我么可以怎么样尽量减少 GIL 给我们造成的影响。
二、为什么 Python 要引入 GIL?
凡事都是有原因的,如果 GIL 只有坏处没有好处,那么咱们的 Python 之父肯定不会闲的没事干引入一个没啥用的东西的。
GIL 解决了什么问题?在回答这个问题之前,我们先来聊聊 Python 里面的垃圾回收机制(引用计数)。
引用计数
Python 使用引用计数来进行内存管理(垃圾回收)。引用计数就是说 Python 里创建的所有对象,Python 都有一个变量(reference count)记录着当前有多少个引用指向了这个对象,当引用数变成 0 的时候,Python 就会回收这个对象所占用的内存。
来看一个简单的例子:
>>> import sys >>> a = [] >>> b = a >>> sys.getrefcount(a) 3
在上面这个简单的例子中,指向这个空 list 对象的引用计数是 3。a,b 和 传入sys.getrefcount 的参数(传参的时候会拷贝一次地址)这 3 个对象指向了空 list。
关于 Python 如何解决循环引用下的内存回收问题比较复杂,这里就不展开了。
如果你感兴趣的话也可以看一下 None 这个对象有多少个对象指向了它。
好,我们说回到 GIL。
Python 的引用计数需要避免资源竞争的问题,我们需要在有两个或多个线程同时增加或减少引用计数的情况下,依然保证引用计数的结果是正确的。
当有多个线程同时改一个对象的引用计数的时候,有可能导致内存泄漏(对象的引用计数永远没有归零的机会),还有可能导致对象提起释放,触发莫名奇妙的 bug,程序崩溃(一个对象存在引用的情况下引用计数变成了 0,导致此对象提前释放)。
通过对不同线程访问、修改引用计数增加锁,我们就可以保证引用计数总是被正确的修改(可以联想一下数据库的锁机制)。
但是,如果我们对每一个对象或者每一组对象都增加锁,这就意味在在你的 Python 程序中有很多个锁同时存在。多个锁同时存在会有其他的风险--死锁(死锁只会在有多个锁存在的情况下发生,参考数据的的死锁)。
除此之外,性能下降也是多个锁存在的一大弊端。因为申请锁和释放锁都是一笔不小的开销。
GIL 是一个锁,或者一把锁(这里强调单个),这把锁加载了 Python 的解释器上,它要求任何 Python 代码在执行的时候需要先申请这把锁,否则就别想执行。
只有一把锁,带来的好处就是 1、不会有死锁,2、对因为引入锁而导致的性能下降影响不大,然而坏处就是 GIL 这把锁让计算密集型的代码也只能使用单线程执行。
GIL 并不是解决资源竞争的唯一解法,一些其他的语言不使用引用计数,使用别的方法也能达到线程安全地内存管理。
当然,鱼和熊掌不可兼得,其他语言不使用 GIL,就享受不到使用 GIL 带来的单线程性能提升,就得像个其他办法提升单个线程的性能了,比如 JIT compilers。
三、Python 为什么选择了 GIL ?
那么,Python 引入 GIL 是不是一个聪明的决定?
根据 Python 大牛 Larry Hastings 的说法: Python 引入 GIL 是使其如此流行的主要原因之一。
在操作系统还没有线程(threads)这个概念的时候,Python 就已经有了。Python 设计的理念就是易于使用,方便程序员快速地构建自己的程序。
GIL 实现简单,引入 Python 也很简单。因为引入 GIL 只是引入了的一把而不是多把锁,所以 GIL 提供了单线程程序的高性能。
有了 GIL,即使不是线程安全的 C 语言库,也能很容易的引入到 Python 代码中。能够引入这些 C 语言库也是 Python 如今这样火的一个重要原因(C 语言能提供更高的性能)。
正如上面所提到的那样,GIL 通过提供一种程序上的方法解决了 CPython 开发者以前遇到的线程不安全的开发问题,这是引入 GIL 的一大优点。
四、GIL 对 Python 多线程开发者的影响
在提到开发性能瓶颈的时候,我们经常把对资源的限制分为两类,一类是计算密集型(CPU-bound),一类是 I/O 密集型(I/O-bound)。
计算密集型的程序是指的是把 CPU 资源耗尽的程序,也就是说想要提高性能速度,就需要提供更多更强的 CPU,比如矩阵运算,图片处理这类程序。
I/O 密集型的程序只的是那些花费大量时间在等待 I/O 运行结束的程序,比如从用户指定的文件中读取数据,从数据库或者从网络中读取数据,/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 - 3.4321422576904297
上面的示例代码是一个简单的单线程程序,下面稍加改造,把上面的代码改成使用两个线程的版本。
# 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 - 3.9487345218658447
可以看到,这两个版本的代码用到的时间基本上是一样的。因为在多线程的版本中,GIL 提供了线程锁,导致在任意时刻只有一个线程在运行。所以最后运行的时间差不多。
但是 GIL 对 I/O 密集型的程序没有什么性能影响,因为当一个线程等待 I/O 结束的时候,这个线程的 GIL 锁会释放,其他的线程可以运行。
但是对于一个完全是计算密集型的程序来说,GIL 锁就会严重的影响性能了。
五、为什么 GIL 锁至今都没有被移除?
就 GIL 这一特性而言,Python 的开发者已经收了无数的抱怨。但是作为一门如此广泛使用的语言,做出变更一定是慎重考虑的,而且对于向后不兼容变更,一般都会避免。
前面说到,GIL 实现简单,添加简单。那么其实对应的,如果只是单纯的移除 GIL,也很容易。以前也有很多开发者尝试把 GIL 从 Python 里面移除,然而一旦移除之后很多 Python 依赖的 C 语言库就没法用了(因为这些 C 语言库严重依赖 GIL 特性)。
当然,想要解决 GIL 所解决的问题,不止有 GIL 一种办法。但是其他的方法要么实现起来过于复杂,要么严重降低单线程代码的性能。毕竟,你肯定不希望在升级了 Python 版本之后,你原来的代码运行效率降低吧!
Python 的创造者在它的一篇文章(It isn’t Easy to remove the GIL)中回复了有关移除 GIL 的问题,原话是:
I’d welcome a set of patches into Py3k only ifthe performance for a single-threaded program (and for a multi-threaded but I/O-bound program) does not decrease.
然而不降低性能的移除 GIL ,通过提供其他途径完成 GIL 所完成的事,至今都还有完全实现。
六、为什么不利用发布 Python3 这个版本的机会移除 GIL?
对于 Python 这门语言而言,Python3 称得上是 Python 的一次从头开始。因此 Python3 确实有机会除掉 GIL,移除掉那些对 GIL 有依赖的 C 语言库,或者重写这些 C 语言库。这也是为什么 Python3 的早期版本被 Python 社区采用的速度较慢的原因。
那 Python3 为啥还是把 GIL 给留下来了呢?
因为移除 GIL 会导致单线程性能 Python3 比不过 Python2(你可以想像一下这对推广 Python3 有什么影响)。GIL 对单线程性能的好处这是谁都无法否认的,因此 Python3 保留了 GIL 的设计。
但是 Python3 还是针对 GIL 做了很多优化。
上面我们说性能瓶颈的时候,谈到了代码有计算密集型和 I/O 密集型,那么如果一个程序既是计算密集型又是 I/O 密集型呢?
在上面这种情况下,Python3 的 GIL 知道他更应该把 GIL 分给计算密集型的线程,而不是所有线程一视同仁的对待(也就是饿死 I/O 密集线程)。
这种机制的实现原理是 Python 有一种机制强制获取到 GIL 锁的线程交在规定的时间段长之后交出 GIL 锁,如果此时没有其他线程申请锁,那么远来的线程可以继续持有锁,继续运行。
import sys sys.getswitchinterval() 0.005 # 线程可以持有锁的时间,单位秒 # python3.8 # python3.7 使用sys.getcheckinterval()
然而这种机制带来新的问题是大部分的 CPU-bound 线程都会在其他线程申请 GIL 锁之前再次申请 GIL 锁,这会导致有些线程永远拿不到 GIL 锁(我们虽然希望 CPU 密集型的线程能相比 I/O 密集型的线程能更长时间的获得锁,但是并不希望 CPU 密集型拿着锁永远不释放)。
这个问题在 Python3.2 版本通过引入记录没有获取到 GIL 锁线程线程请求 GIL 锁的次数的机制解决了,详情可以参考这篇文章。
七、Python 开发者如何与 GIL 和谐的共存?
如果 GIL 真的对你的代码性能有很大的影响,那么可以尝试下面这些方法。
1、多进程
解决 GIL 锁最通用的方法是使用多进程。因为每个 Python 进程都有自己的 Python 解释器,有自己的内存空间,有自己的 GIL 锁,相互之间没有影响。自然也就没有问题了。
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 - 1.9049170017242432
可以看到运行时间明显的下降了。
这里要说明一下的是时间并没有降到原来的一半,这是因为进程管理,切换也是要花费时间的。多进程程序相比单线程程序需要的资源开销更多,资源管理和回收的时间也需要的更多。
2、换 Python 解释器
Python 的解释器不止一个,而是有很多个。包括 CPython, Jython, IronPython and PyPy,分别是用 C,Java,C#,Python 实现的。GIL 锁只存在于 CPython 解释器中。如果你的代码和依赖库不依赖 CPython 的话,那么换其他的解释器也是可行的方案。
还有个称不上方法的方法,那就是等一等。因为 Python 社区有很多开发者正在致力于移除 CPython 中的 GIL 锁。
有任何问题欢迎评论留言,如果本文有用,也欢迎点个赞~~~