Python GIL那些事

GIL是什么?

GIL并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念。就好比C++是一套语言(语法)标准,但是可以用不同的编译器来编译成可执行代码。

  • CPython实现中的GIL?

    Global Interpreter Lock

    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.)

    一个防止多线程并发执行机器码的一个Mutex

看起来就像一个BUG

python为什么使用GIL的机制?

由于物理上得限制,各CPU厂商在核心频率上的比赛已经被多核所取代。为了更有效的利用多核处理器的性能,

  • 多核时代的出现对于我们程序员而言意味着什么, 我们如何利用多核的优势?
    可以采用 多进程, 也可以采用 多线程. 二者的最大区别就是, 是否共享资源, 后者是共享资源的,而前者是独立的. 所以你也可能想起了google chrome为什么又开始使用独立的进程 来作为每个tab服务了(不共享数据,意味着有更好的安全性).
    相对于进程的轻型特征,多线程环境有个最大的问题就是 如何保证资源竞争,死锁, 数据修改等.
    于是,便有了 线程安全 (thread safety)的提出.

线程安全

线程安全 是在多线程的环境下, 线程安全能够保证多个线程同时执行时程序依旧运行正确, 而且要保证对于共享的数据,可以由多个线程存取,但是同一时刻只能有一个线程进行存取.
既然,多线程环境下必须存在资源的竞争,那么如何才能保证同一时刻只有一个线程对共享资源进行存取?
加锁, 对, 加锁可以保证存取操作的唯一性, 从而保证同一时刻只有一个线程对共享数据存取.

  • 通常加锁也有2种不同的粒度的锁:

    • fine-grained(所谓的细粒度), 那么程序员需要自行地加,解锁来保证线程安全
    • coarse-grained(所谓的粗粒度), 那么语言层面本身维护着一个全局的锁机制,用来保证线程安全

    前一种方式比较典型的是 java, Jython 等, 后一种方式比较典型的是 CPython (即Python).

Python的GIL

GIL是必要的,因为CPython的内存管理是非线程安全的。你不能简单地创建多个线程,并希望Python能在多核心的机器上运行得更快。这是因为GIL將会防止多个原生线程同时执行Python字节码。换句话说,GIL將序列化您的所有线程。然而,您可以使用线程管理多个派生进程加速程序,这些程序独立的运行于你的Python代码外。

  • 过程
    全局解释器锁(global interpreter lock)如其名运行在解释器主循环中,在多线程环境下,任何一条线程想要执行代码的时候,都必须获取(acquire)到这个锁,运行一定数量字节码,然后释放(release)掉,然后再尝试获取。这样 GIL 就保证了同时只有一条线程在执行。

GIL带来的性能问题

一般来说,GIL 并不会带来麻烦,因为大多数程序的性能瓶颈都在 IO 上(IO-bound)。

  • 顺序执行的单线程(single_thread.py)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    #! /usr/bin/python
    from threading import Thread
    import time
    def my_counter():
    i = 0
    for _ in range(100000000):
    i = i + 1
    return True
    def main():
    thread_array = {}
    start_time = time.time()
    for tid in range(2):
    t = Thread(target=my_counter)
    t.start()
    thread_array[tid] = t
    for i in range(2):
    thread_array[i].join()
    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))
    if __name__ == '__main__':
    main()
  • 同时执行的两个并发线程(multi_thread.py)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    #! /usr/bin/python
    from threading import Thread
    import time
    def my_counter():
    i = 0
    for _ in range(100000000):
    i = i + 1
    return True
    def main():
    thread_array = {}
    start_time = time.time()
    for tid in range(2):
    t = Thread(target=my_counter)
    t.start()
    thread_array[tid] = t
    for i in range(2):
    thread_array[i].join()
    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))
    if __name__ == '__main__':
    main()

GIL设计上带来的缺陷

  • 基于pcode数量的调度方式
    按照Python社区的想法,操作系统本身的线程调度已经非常成熟稳定了,没有必要自己搞一套。所以Python的线程就是C语言的一个pthread,并通过操作系统调度算法进行调度(例如linux是CFS)。为了让各个线程能够平均利用CPU时间,python会计算当前已执行的微代码数量,达到一定阈值后就强制释放GIL。而这时也会触发一次操作系统的线程调度(当然是否真正进行上下文切换由操作系统自主决定)。
    while True:
      acquire GIL
      for i in 1000:
          do something
      release GIL
    
    这种模式在只有一个CPU核心的情况下毫无问题。任何一个线程被唤起时都能成功获得到GIL(因为只有释放了GIL才会引发线程调度)。但当CPU有多个核心的时候,问题就来了。从伪代码可以看到,从 release GIL 到 acquire GIL 之间几乎是没有间隙的。所以当其他在其他核心上的线程被唤醒时,大部分情况下主线程已经又再一次获取到GIL了。这个时候被唤醒执行的线程只能白白的浪费CPU时间,看着另一个线程拿着GIL欢快的执行着。然后达到切换时间后进入待调度状态,再被唤醒,再等待,以此往复恶性循环。

为什么一定要用GIL

参考其他实现的话,你可能会问一个问题,为什么要使用全局锁,而不是一个更细粒度的锁呢?实际上 Linux 的文件系统就是这样做的,进程给目标文件加锁的时候,可以只加一定字节数的锁,只要另一个进程准备加的锁与其没有交集的话,这两个锁就可以共存,这两个进程也可以同时修改这一个文件(的不同部分)。因此对于 Python,也许可以给对象加锁,同时不限制线程的并行执行。但从网上的信息来看,似乎这种思路曾经被尝试实现过,但细粒度的锁会给单线程模式下的性能造成明显影响。

如何避免python GIL带来的性能影响

用multiprocess替代Thread

multiprocess库的出现很大程度上是为了弥补thread库因为GIL而低效的缺陷。它完整的复制了一套thread所提供的接口方便迁移。唯一的不同就是它使用了多进程而不是多线程。每个进程有自己的独立的GIL,因此也不会出现进程之间的GIL争抢。
当然multiprocess也不是万能良药。它的引入会增加程序实现时线程间数据通讯和同步的困难。

如果我们要多个线程累加同一个变量,对于thread来说,申明一个global变量,用thread.Lock的context包裹住三行就搞定了。而multiprocess由于进程之间无法看到对方的数据,只能通过在主线程申明一个Queue,put再get或者用share memory的方法。

用其他解析器

既然GIL只是CPython的产物,那么其他解析器是不是更好呢?没错,像JPython这样的解析器由于实现语言的特性,他们不需要GIL的帮助,程序员自身加锁。但这些解析器太小众,会失去很多第三方的支持。有得必有失,所以看自己的选择。

指定CPU运行

在linux下,也可以用taskset命令来设置进程运行的CPU