• 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: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的官方文档的连接。可以看一看!

  • 相关阅读:
    学习资料
    InstallShield常用工具
    InstallShield中调用API
    系统目录
    abort和exit
    Uninstall Registry Key
    GDI+资料
    VBScript是什么?有什么优缺点?
    DrawImage调查
    KEIL MDK环境下uCOSII在LPC17xx上的移植实例 Chung
  • 原文地址:https://www.cnblogs.com/yinsedeyinse/p/11938976.html
Copyright © 2020-2023  润新知