美文网首页
Python GIL 全局解释器锁

Python GIL 全局解释器锁

作者: vickeex | 来源:发表于2020-02-18 19:28 被阅读0次

    有所收获,特将原文翻译如下。

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

    URL: https://realpython.com/python-gil/

    目录

    • GIL为Python解决了什么问题

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

    • 给Python多线程应用带来的影响

    • 为什么GIL至今未被移除

    • 为什么未从Python3中移除

    • 如何解决Python GIL带来的问题


    简而言之,Python全局解释器锁(Global Interpreter Lock, GIL)是一个互斥信号量(互斥锁),保证仅有一个线程能持有Python解释器的控制权。

    意味着任何时刻都只有一个线程可以处于执行状态。GIL不会对执行单线程应用的开发者带来影响,但可能会成为计算密集型(CPU bound)和多线程型应用的性能瓶颈。

    即使在拥有多CPU内核的多线程架构中,GIL一次也只允许执行一个线程,被认为是Python臭名昭著的特性。

    阅读本文,你能了解到GIL为什么会影响Python程序的性能,以及如何减轻由此带来的对代码的影响。

    GIL为Python解决了什么问题

    Python使用引用计数(reference counting)来进行内存管理。这意味着Python里创建对象会持有一个引用计数变量,用于追溯指向该对象的引用的数量。当引用计数的值减为零时,该对象占有的内存会被释放。

    来看一个简短的代码示例,演示了引用计数的工作原理:

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

    在示例中,指向空列表对象[]的引用计数为3,3个引用分别是:a, b, 传递给sys.getrefcount()的参数。

    回到GIL:

    问题在于引用计数变量需要被保护,以防在竞争情况下的两个线程同时增加或减少它的值。如果这种情况发生了,可能会引起从未释放的内存泄露,更坏的可能是在仍存在对象的引用时错误地释放内存。这些可能导致Python程序的崩溃或其他不知所以然的bug。

    为跨线程共享的所有数据结构加锁,可以保证引用计数变量的安全,防止不一致的修改。

    但为一个或者一组对象加锁,意味着多个锁将同时存在,可能会引起死锁(Deadlocks,只在拥有多个锁时才可能发生)。另一副作用是重复地获取和释放锁会带来性能下降。

    GIL是作用于解释器自身的唯一的锁,并新定一条规则:执行任何Python字节码都需要获取解释器锁。由此防止死锁(现在就只有一个锁啦),也不会引入过多额外的性能开销。但是它强制所有计算密集型的Python应用成为了单线程应用。

    GIL也用于其他语言,比如Ruby,但它并不是这类问题的唯一解决方案。为了避免在线程安全的内存管理中使用GIL,有些语言采用引用计数以外的方法,比如垃圾回收。

    另一方面,这些语言也通常必须在其他地方提升性能(比如使用JIT编译器),以弥补GIL带来的单线程性能损失。

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

    那为什么要在Python中使用这么一种看上去很碍事的方式呢?Python的开发者是否做了一个错误的决定?

    嗯...用Larry Hastings的话来说,GIL的设计是让Python广受欢迎的原因之一。

    操作系统没有线程概念时,Python就已经存在了。当时Python被设计为易于使用以加快开发速度,越来越多的开发者也开始使用它。

    很多现有的C库也在那时进行扩展,Python需要他们的特性。为了防止不一致的更改,这些C扩展需要GIL提供的线程安全的内存管理。

    GIL的实现很简单,也很容易添加到Python中。由于只需要管理一个锁,它提升了单线程应用的性能。

    非线程安全的C库也变得容易集成。这些C扩展也是Python容易被不同社区采用的原因之一。

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

    给Python多线程应用带来的影响

    当你在阅读典型的Python程序(或任何与此相关的计算机程序)时,CPU密集型和I/O密集型应用的性能差异往往很大。

    CPU密集型应用指CPU占用率极高的应用,包括数学计算(比如矩阵乘法),搜索,图像处理等等应用。

    I/O密集型应用是指将大量时间花费等待在I/O读写的应用,I/O可以来自于用户、文件、数据库、网络等。I/O源在准备I/O数据前可能会进行其他操作,导致I/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 - 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密集型线程的并行执行。

    GIL对I/O密集型的多线程应用影响不大,因为线程在I/O等待时共享了这个锁。

    但是完全CPU密集型的应用(比如使用多线程处理图像的应用)不仅会由于锁的存在变成单线程,执行时间也会相对单线程编写的代码程序有所增加(比如上面这个栗子)。这种增加是锁的获取和释放带来的开销。

    为什么GIL至今未被移除

    Python开发人员收到很多对GIL的怨言,但对于一门像Python这样被广泛应用的语言而言,在不引起向后不兼容问题的情况下,删除GIL这种操作实在是太大的改动了。

    GIL当然可以被移除,开发人员和研究人员也尝试过多次,但无一例外地破坏了现有的C扩展,这些扩展在很大程度上都依赖于GIL提供的解决方案。

    当然哈,对于GIL解决的问题也有其他的解决方案,但是其中某些降低了单线程和多线程I/O应用的性能,而另外某些真的太难了。毕竟,没人愿意新版本发布后 现有的Python程序跑得更慢,是吧?

    Python的创建者和“仁慈的独裁者”(Benevolent Dictator for Life,BDFL)Guido van Rossum,在2007年9月的文章中给了社区一个答案:删除GIL并不容易。

    只有当单线程应用(以及多线程的I/O应用)的性能不会降低时,我在愿意在Py3k中发布一系列补丁。
    

    但至那以后的所有尝试都没有满足这个条件。

    为什么未从Python3中移除

    Python3确实有机会从头开始启用一些功能,并在此过程中破坏一些现有的C扩展,这些扩展需要更新并移植到Python3中才能使用。这也是Python3早期版本被社区接纳较慢的原因。

    但为什么不将GIL一起移除呢?

    移除GIL会让Python3的单线程性能比Python2更慢,能想到会带来什么结果。确实无法质疑GIL带来的单线程优势,所以最后结果是Python3 仍然留下了GIL。

    但是Python3也对现有的GIL进行了重大的改进。

    我们讨论了GIL对“仅计算密集(only CPU bound)”和“仅I/O密集(only I/O bound)”的多线程应用带来的影响,但对于有些线程是I/O型、有些线程是CPU型的应用呢?

    众所周知(嘤嘤嘤,我不知道吖),对于这类应用,Python的GIL会导致I/O线程饥饿,因为这类线程不能从CPU型线程中获取到GIL。

    因为Python内置了一种机制,以强制线程在一段固定的连续使用后释放GIL,如果没有其他获取到GIL,则该现场继续使用GIL。

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

    这种机制的问题在于,多数情况下,CPU型现场会在其他线程可以获取GIL之前重新获取到GIL。结论由David Beazley得出,也可以找到相关的可视化解释。

    [ 我的理解:线程会在一个时间段后释放GIL,但在释放的时间点,I/O仍可能处于等待中而不去获取GIL,于是CPU线程则会继续持有该GIL,如此导致IO线程饥饿。 ]

    如何解决Python GIL带来的问题

    如果Python GIL给你带来了困扰,可以尝试以下几种解决方案。

    多进程 vs 多线程(multi-processing vs multi-threading)

    最常用的方式是使用多进程而非多线程。每个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
    

    与多线程相比,性能有所提高。

    但时间没有降低到一半,因为进程管理也带来了其他开销。多进程比多线程开销大,所以这可能会成为扩展的瓶颈。

    备选的Python解释器

    Python实现了多种解释器,最受欢迎的是CPython,Jython,IronPython,PyPy,分别用C,Java,C#和Python实现。GIL只在原始的Python实现(Cpython)中存在。如果你的程序和相关库有其他可用的实现,不妨尝试一下。

    多等一会会儿

    (不是在卖萌。)许多Python用户都利用了GIL带来的单线程优势。多线程码农也不用为此烦恼,因为Python社区中最聪明的一些人正在努力从CPython中移除GIL,“Gilectomy”是其中较闻名的尝试之一。

    Python GIL经常被认为是一个神秘且具难度的话题。但是请记住,作为一个Python高手(Pythonista),通常只有在编写C扩展或者在程序中使用CPU型多线程时才会被影响。

    在这种情况下,本文已为您提供了解GIL及如何处理它的相关内容。如果你想了解GIL底层工作原理,我建议您观看David Beazley的Python GIL演讲。

    相关文章

      网友评论

          本文标题:Python GIL 全局解释器锁

          本文链接:https://www.haomeiwen.com/subject/qornfhtx.html