玩转python中的GIL前世今生与核心用法剖析

1.GIL的前世今生

1.1GIL的是什么?

       python是解释型语言,不用编译,运行时可以直接通过解释器进行解释执行了。类似linux中的bash解释器,所以python中也有很多解释器,如cpython(C语言实现),jpython等,只是默认的解释器Cpython,所以大家一般使用的python环境都是基于Cpython的。

        我们所说的Python GIL是Global Interpreter Lock,翻译过来就是:全局解释器锁,我们从GIL的名字就可看出其是一个解释器锁,针对的主题是解释器。所以GIL并不是Python的特性,它是在实现Python解析器(Cpython)时所引入的一个概念,而同样作为python解释器的Jpython就没有GIL。那么为什么Cpython需要GIL,而Jpython不需要GIL呢?GIL又是干啥的呢?

        查看python官网文档,发现对GIL出现描述如下:

        在Cpython中GIL是一个防止解释器多线程并发执行机器码的一个全局互斥锁。其存在主要是因为在代码执行过程中,CPython的内存管理不是线程安全的。

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple native threads from executing Python bytecodes at once. 
This lock is necessary mainly because CPython’s memory management is not thread-safe. 
(However, since the GIL exists, other features have grown to depend on the guarantees that it enforces.)

1.2GIL解决了python中的什么问题?

      玩过C语言的都知道,C语言需要手动进行内存分配,释放,否则会出现内存泄露的问题。cpython中利用引用计数来进行内存管理,这就意味着在Python中创建的对象都有一个引用计数变量来追踪指向该对象的引用数量。当数量为0时,该对象占用的内存即被释放。如下:

>>> import sys
>>> a = [1,2,3]
>>> b = a
>>> sys.getrefcount(a),sys.getrefcount(b)  #查看列表[1,2,3]的引用次数。
(3, 3)
>>> a.append(4) #对列表追加一个元素
>>> a
[1, 2, 3, 4]
>>> sys.getrefcount(a),sys.getrefcount(b)
(4, 4)
>>> del a  #删除a以后,列表的引用减少了1位。
>>> a
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'a' is not defined
>>> b
[1, 2, 3, 4]
>>> sys.getrefcount(b)
3

       如上,对于同一个变量,如果让两个线程同时操作他,那么问题就来了。这个变量的引用计数不能被同时增加或者减少,也就说任意时刻都必须保证这个变量的引用计数的全局一致性。否则变量的引用计数有可能不准确,这样的结果会导致泄露的内存永远不会被释放,抑或更严重的是当一个对象的引用仍然存在的情况下错误地释放内存。这可能会导致Python程序崩溃或带来各种诡异的bug。

       那么这个时候怎么办呢?可以通过对跨线程分享的数据结构添加锁定以至于数据不会不一致地被修改,这样做可以很好的保证引用计数变量的安全。但是对每一个对象或者对象组添加锁意味着会存在多个锁,这也就导致了另外一个问题——死锁(只有当存在多个锁时才会发生)。而另一个副作用是由于重复获取和释放锁而导致的性能下降。所以看来使用多锁虽然能解决全局变量的一致性,但是对性能也有很大的影响,怎么办呢?

       这个时候GIL就闪亮登场了。GIL是全局解释器锁是一个单一锁,它增加的一条规则要求任何Python字节码的执行都需要获取解释锁。这有效地防止了死锁(因为只存在一个锁)并且不会带来太多的性能开销。

      此外人们针对于C库中那些被Python所需的功能写了许多扩展,为了防止不一致变化,这些C扩展需要线程安全内存管理,而这些正是GIL所提供的。GIL是非常容易实现而且很容易添加到Python中。因为只需要管理一个锁,所以对于单线程任务来说带来了性能提升。非线程安全的C库变得更容易集成,而这些C扩展则成为Python强大的功能之一。

1.3GIL的出生与发展

          虽然说GIL其最早存在主要是因为在代码执行过程中,CPython的内存管理不是线程安全的因为随着时代的发展,计算机硬件开始往多核多线程方向发展了,为了更有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。即使在CPU内部的Cache也不例外,为了有效解决多份缓存之间的数据同步时各厂商花费了不少心思,也不可避免的带来了一定的性能损失。

        Python当然也逃不开,为了利用多核,Python开始支持多线程。而解决多线程之间数据完整性和状态同步的最简单方法自然就是加锁。 同样还是GIL这把超级自动大锁,让python支持的多线程实现了安全。而当越来越多的代码库开发者接受了这种设定后,他们开始大量依赖这种特性(因为默认加了GIL自动锁后,相当于python中是多线程安全的,这样开发者在实际开发中就不需要关心线程安全和锁的问题了,以至于后来尾大不掉,想删除GIL锁已经很难更改了)。

        查看python官网对于GIL在多线程中的使用说明如下:

        Python解释器(Cpython)不是完全线程安全的。为了支持多线程Python程序,有一个全局锁,称为全局解释器锁GIL。当前线程必须持有该锁才能允许其访问Python对象。如果没有锁定,即使最简单的操作也可能导致多线程程序出现问题:例如,当两个线程同时递增同一对象的引用计数时,引用计数最终只能递增一次而不是两次。

        因此规定只有获取GIL的线程可以在Python对象上操作或调用Python / C API函数。为了模拟执行的并发性,解释器会定期尝试切换线程(请参阅参考资料sys.setswitchinterval())。锁也会在读取或写入文件等潜在阻塞I / O操作时释放,以便其他Python线程可以同时运行。

        其实说到底就是一句话,在Cpython解释器的多线程程序中,为了保证线程操作安全,默认使用了一个GIL锁,该锁GIL是一个阻止多线程同时执行的互斥锁,保证任意时刻只有一个线程在正在执行,其余线程处于等待状态,只是不同线程执行时切换的很快,虽然是并发状态,但看上去像是并行。所以说在Cpython中多线程实际来说是“伪多线程”。

1.4GIL锁的释放机制

        Python解释器进程内的多线程是合作多任务方式执行。当一个线程遇到I/O任务时,将释放GIL。计算密集型(CPU-bound)的线程在执行大约100次解释器的计步(ticks)时,将释放GIL。计步(ticks)可粗略看作Python虚拟机的指令。计步实际上与时间片长度无关。可以通过sys.setcheckinterval()设置计步长度。

        Python 3.2开始使用新的GIL。在新的GIL实现中,用一个固定的超时时间来指示当前的线程放弃全局锁。在当前线程保持这个锁,且其他线程请求这个锁的时候,当前线程就会在5ms后被强制释放掉这个锁。

2.GIL在多线程中使用与注意事项

2.1python中使用多线程和单线程执行效率分析

    一般来说,比如Java中多线程程序的执行效率一般要比单线程的高,但是在在Cpython中多线程实际上是“伪多线程”,那么其同样一个程序用多线程和单线程执行的结果又如何呢?

 A1.单线程执行同一个程序调用,耗时84.12s

import time

def counter1():
    for i in range(100000000):
        i = i + 1
    print("this is i:",i+5)

def counter2():
    for j in range(100000000):
        j = j + 1
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        counter2()
        counter1()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))


if __name__ == '__main__':
    main()

'''
this is j: 100000010
this is i: 100000005
this is j: 100000010
this is i: 100000005
Total time: 84.12183594703674
'''

  A2.多线程执行同一个程序,耗时89.27s。

from threading import Thread
import time

def counter1():
    for i in range(100000000):
        i = i + 1
    print("this is i:",i+5)

def counter2():
    for j in range(100000000):
        j = j + 1
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        t1 = Thread(target = counter2)
        t2 = Thread(target=counter1)
        t1.start()
        t2.start()
        t2.join()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))

if __name__ == '__main__':
    main()

'''
this is i: 100000005
this is j: 100000010
this is i: 100000005
Total time: 89.27375364303589
this is j: 100000010
'''

尖叫提示1:显然上面两个案例看出同一个程序,在python中 (Cpthon)单线程反而要比多线程执行的快,因为GIL锁的缘故,多线程实际上需要频繁切换进行并发操作,尤其对于多核CPU来说,存在严重的线程颠簸(thrashing)。​​​​尽管如此,那么是不是说python中单线程就一定比多线程效率高呢?请看下面案例。

B1.同样使用单线程执行同一个程序,注意同样是上面的程序,这里在代码中增加了sleep(0.01)耗时操作。结果这个时候单线程 执行完程序耗时:42.91s.

import time

def counter1():
    for i in range(1000):
        i = i + 1
        time.sleep(0.01)
    print("this is i:",i+5)

def counter2():
    for j in range(1000):
        j = j + 1
        time.sleep(0.01)
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        counter2()
        counter1()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))


if __name__ == '__main__':
    main()

'''
this is j: 1010
this is i: 1005
this is j: 1010
this is i: 1005
Total time: 42.90534329414368

'''

B2.同样使用多线程执行同一个程序,注意同样是上面的程序,这类在代码中增加了sleep(0.01)耗时操作。结果这个时候多线程 执行完程序耗时:21.78s。

from threading import Thread
import time

def counter1():
    for i in range(1000):
        i = i + 1
        time.sleep(0.01)
    print("this is i:",i+5)

def counter2():
    for j in range(1000):
        j = j + 1
        time.sleep(0.01)
    print("this is j:", j+10)

def main():

    start_time = time.time()
    for x  in range(2):
        t1 = Thread(target = counter1)
        t2 = Thread(target=counter2)
        t1.start()
        t2.start()
        t2.join()

    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))


if __name__ == '__main__':
    main()

'''
this is i: 1005
this is j: 1010
this is j: 1010
Total time: 21.78059458732605
this is i: 1005

'''

尖叫提示2:为什么同样一个程序,增加了sleep耗时操作以后在python中多线程的操作又比单线程执行的更快了呢?这不就和上面的结果矛盾了吗?这其实说到底就是GIL锁的释放机制了。如上:当一个线程遇到I/O任务时,将释放GIL。计算密集型(CPU-bound)的线程在执行大约100次解释器的计步(ticks)时,将释放GIL。所以说我们增加了sleep耗时操作,相当于将计算型的程序变成了耗时等待的I/O程序,这个时候GIL锁遇到I/O任务时,不会继续等待耗时操作,而是立马释放锁,给其他线程去执行,这样的话效率会比单线程高很多(因为单线程需要等待耗时结束才能继续执行)。

2.2python中GIL与多线程的使用总结

很显然通过上满A1,A2,B1,B24个案例的结果我们得出如下结论:

  1. python多线程适合做io密集型程序,因为有延时,可以GIL自动解阻塞,所以效率更高。相反,如果是计算密集型程序,python中单线程因为没有线程切换的延时,效率更高。
  2. 实际开发中,如果是计算密集型程序,一般使用多进程,多进程可以并行适合计算密集型,发挥多核cpu。计算密集型程序来说,进程效率>单线程>多线程。
  3. GIL在较长一段时间内将会继续存在,但是会不断对其进行改进,所以干脆还是使用multiprocessing替代Thread或者使用协程吧。
  4. 协程适合IO密集型,只用单核。效率要比单线程高。
  5. IO密集型:涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。当然我们在Python中可以利用sleep达到IO密集型任务的目的。
  6. 计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。
     
发布了248 篇原创文章 · 获赞 1600 · 访问量 267万+

猜你喜欢

转载自blog.csdn.net/qq_26442553/article/details/96312467