Python核心技术与实战——十九|一起看看Python全局解释器锁GIL

  我们在前面的几节课里讲了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:\python\Python核心技术实战\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的官方文档的连接。可以看一看!

猜你喜欢

转载自www.cnblogs.com/yinsedeyinse/p/11938976.html