我们在前面的几节课里讲了Python的并发编程的特性,也了解了多线程编程。事实上,Python的多线程有一个非常重要的话题——GIL(Global Interpreter Lock)。我们今天就来讲一讲这个GIL。
一个不解之谜
我们先来看一看这个例子:
def CountDown(n): while n>0: n -= 1
现在,我们假设有个很大的数字n=100000000,我们来试试单线程的情况下 执行这个函数,然后看看怎么执行的
import time def main(): start_time = time.perf_counter() n = 100000000 CountDown(n) end_time = time.perf_counter() print('take {} seconds'.format(end_time-start_time)) if __name__ == '__main__': main() ##########运行结论########## take 8.2776216 seconds
这还是在我的第八代i7的笔记本上运行的结论,这时候我们想要用多线程的方式来加速,比如下面的操作
from threading import Thread n = 100000000 t1 = Thread(target=CountDown,args=[n//2]) t2 = Thread(target=CountDown,args=[n//2]) t1.start() t2.start() t1.join() t2.join()
运行一下,发现时间变成了13.39秒,可以再加两个线程试一下,发现和两个线程的结论基本一样。是怎么回事呢?是机器出问题了么?
我们可以找一个单核CPU的机器来跑一下上面的代码,可以发现在单核CPU的电脑上,单线程的运行时间和多线程的时间基本一致,虽然不像前面的那个,多线程反而比单线程更慢,但这两次的结论几乎是一样的啊!
这么看来就不是电脑出问题了,那就是Python的线程失效了,并没有起到并行计算的作用。那就可以在推一下:Python的线程是不是假的线程呢?
Python的线程,的确封装了底层的操作系统线程,在Linux系统里是Pthread(全称为POSIX Thread),而在Windows里是Windows Thread。另外,Python的线程,也完全受操作系统的管理,比如协调合适执行,管理内存资源,管理中断等待。
所以,虽然Python的线程和C++的线程本质上是不同的抽象,但是他们的底层并没什么不同。
为什么有GIL
看来并不是电脑出了问题或者是线程失效或者Python线程失效两个问题,那么谁才是“罪魁祸首”呢?这就引出了今天的主角——GIL,导致了Python线程并不想我们希望的那样。
GIL是最流行的Python解释器CPython中的一个技术用语,他的意思是全局解释器锁,本质上类似于操作系统的Mutex,每一个Python线程,在CPython解释器中执行时,都会先锁住自己的线程,组织别的线程执行。
当然,CPython会做一些小把戏,轮流执行Python线程,这样一来,用户就看到伪并行的效果——Python程序在交错的执行,来模拟出来并行的线程。
那么,为什么CPython需要GIL呢?其实这和CPython的实现有关,我们下一节会将Python的内存管理机制,今天就先点一下。
CPython使用引用计数器来管理内存,所有的Python脚本中创建的实例,都会有一个引用计数,来记录有多少个指针指向它。当引用计数只有0时,则会自动释放内存。
我们可以看看下面的例子
>>> a = [] >>> b = a >>> c = a >>> >>> import sys >>> sys.getrefcount(a) 4
在上面的例子中,a的引用计数是4,因为有a,b,c和作为参数传递的getrefcount这几个地方,都引用了一个空裂波。
这样一来,如果有两个Python线程同时引用了a,就会造成引用计数的race condition,引用计数可能最终只增加1,这样就会造成内存污染。因为第一个线程结束的时候,会把引用结束减少1,这时候可能达到条件释放内存,当第二个线程再试图访问a时,就找不到有效的内存了。
所以说,CPython引入GIL其实就是这么两个原因:
一是设计者为了规避类似内存管理这样的复杂的竞争风险问题(race condition)
二是因为CPython会大量使用C语言库,但大部分C语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)。
GIL是如何工作的?
可以看一看下面这张图,就是一个GIL在Python程序中的工作示例。其中,线程1、2、3轮流执行,每一个线程在执行是,都会锁住GIL,以阻止别的线程执行;同样的,每一个线程执行一段后,会释放GIL,以允许别的线程开始利用资源。
仔细看一下就可能发现一个问题:为什么Python线程会主动释放GIL呢?毕竟,如果仅仅是要求Python线程在开始的时候锁住GIL而不去释放GIL,那别的线程就都没有运行的机会了。
所以CPython中还有另外一个机制:check_interval,意思是CPython解释器会去轮询检查线程GIL的锁住情况,每隔一段时间,Python解释器就会强制当前线程去释放GIL,这样别的线程才能有执行的机会。
不同版本的Python中,check_interval的实现方式是不一样的,早起的Python是100个ticks,大概对应了1000个bytecodes;而Python3以后,interval是15ms。当然,我们不必喜酒具体多久会强制释放GIL,这不该成为我们设计程序的依赖条件,我们只需明白,CPython解释器会在一个合理的时候释放GIL就可以了。
整体来说,每一个Python的线程都是类似这样循环的封装,我们可以看看下面的代码
for(;;){ if(--ticker < 0) /*Give another thread a chance*/ PyThread_release_lock(interpreter_lock); /*Other threads may run now*/ PyThread_acquire_lock(interpreter_lock,1); } bytecode = *next_instr++ switch (bytecode){ /*execute the next instruction...*/ }
从上面的代码可以看出来,每个Python线程都会先检查ticker计数。只有ticker大于0的时候,线程才会去执行自己的bytecode。
Python的线程安全
不过,即便是有了GIL也不意味着我们Python编程这就不用去考虑线程安全了,计时我们知道,GIL仅允许一个Python线程执行,但前面我们也讲到了,Python还有check interval这样的抢占机制。我们看一段下面的代码
import threading n = 0 def foo(): global n n += 1 threads = [] for i in range(100): t = threading.Thread(target=foo) threads.append(t) for t in threads: t.start() for t in threads: t.join() print(n)
我们执行一下,会发现大部分打印结论都是100,但偶尔还是能输出一个98或99的。
这是因为n+=1这句代码让线程并不安全,如果我们去查foo这个函数的bytecode的话,就会发现他是由下面四行bytecode组成
import dis dis.dis(foo) ##########输出########## [Running] python -u "d:pythonPython核心技术实战GIL.py" 47 0 LOAD_GLOBAL 0 (n) 2 LOAD_CONST 1 (1) 4 INPLACE_ADD 6 STORE_GLOBAL 0 (n) 8 LOAD_CONST 0 (None) 10 RETURN_VALUE
而这四行bytecode中间是有可能被打断的。
所以,不要想着有了GIL以后程序就可以高枕无忧了,我们仍然需要注意线程安全。正如开头说的,GIL的设计,主要是为了方便CPython解释器层面的编写者,而不是Python应用层面的程序员。作为Python的作者,我们还是需要lock等工具,来确保线程安全。就像下面的例子
n = 0 lock = threading.Lock() def foo(): global n with lock n += 1
如何绕过GIL?
看到这里,可能很多的Python使用者就会觉得自己好像被废掉了武功一样,其实大可不必,Python的GIL,是通过CPython的解释器加的限制,如果我们的代码不需要通过CPython解释器来执行,就不再受GIL的限制。
事实上,许多高性能的应用场景都已经拥有了C实现的Python库,例如NumPy的矩阵运算,就都是通过C来实现的,并不受到GIL的影响。所以,大多数情况下,我们是不用过多的考虑GIL的。因为如果多线程的计算成为性能瓶颈,往往已经有别的Python来解决这个问题了。
换句话说,如果我们的应用对性能有着超级严格的要求,比如100μs就对应用有着非常大的影响,那只能说明Python已经不是一个最优的选择了。
但是,我们可以理解的是,我们难以避免有些时候需要临时的摆脱GIL,例如在深度学习的应用里,大部分代码都是Python的,这时候如果我们想自己定义一个微分算子,或者一个特定的硬件加速器,那我们就不得不把这些关键性能(Performance-critical)的代码在C++中实现,然后在提供一个Python调用的接口。
总得来说,若图哦想绕过GIL就是这两种思路:
1.绕过CPython,使用JPython等别的解释器实现
2.把关键性能的代码放在别的语言中(一般常用的都是C++)中实现
总结
今天我们通过一个实际的例子,了解了GIL对应用的影响,之后我们有剖析的GIL的实现原理,我们不用深究原理上的一些细节,只要明白主要机制和存在的隐患就可以了。
最后还提出了两种绕过GIL的思路,不过还是正如前面讲的,大多数时候我们都不必过多纠结GIL的影响。
思考题
最后还是留个思考题:
1.为什么在处理第一个例子中类似cpu-bound任务的时候,为什么使用多线程反而比单线程还要买一些呢?
2.GIL到底是一个好的设计么?事实上,在Python3之后,有很多关于GIL改进或是取消的讨论,我们的看法是什么?
由于cpu-bound属于计算密集型操作,用多线程运行时,每个线程在开始执行的时候都会锁住GIL,执行完毕后会释放GIL,并且在进行线程切换的时候都会对上下文进行保存和读取,这都是占用CPU资源的操作。相比而言单线程就没有这些资源损耗,所以能更快的执行程序。
返回到Python诞生的时代,由于那个时代的CPU都是单核单线程的,GIL就是合理而且有效率的。并且为多线程的程序提供了性能上的提升。至于具体的作用还可以参照以前写的博客——Python之线程与进程,里面还给出了GIL的官方文档的连接。可以看一看!