08 饱受争议的话题:全局解释器锁 GIL(下篇)
Diego38 22 1

为什么 CPython 中会有 GIL 的存在?

现在看起来在 CPython 中使用 GIL 是一种“有问题”的决策?下面我们需要从多个方面合理的去看待这个问题:

Python 在单核 CPU 时代诞生

Python 诞生以及流行时,还是单核 CPU 盛行的时代。当时各大 CPU 厂商的主要发力点还在通过提高频率来提升 CPU 性能。所以在最初 CPython 解释器的设计开发中,更多考虑的是适合当时主流的单核 CPU 下的使用场景。

CPython 的内存管理是非线程安全的

上面我们了解了 Python 语言发展初期的一些时代背景,接下来我们便走进 Python 中使用 GIL 的核心原因。要理解核心原因,我们需要首先对线程安全和竞态条件进行了解。

线程安全与竞态条件

线程安全指的是计算机程序在多线程环境下运行时,可以按照正确的方式处理多个线程之间的共享数据。我们通过下图来形象的说明线程安全和非线程安全:

image

在咖啡店中只剩最后一杯咖啡时有两位客户同时购买,如果两人同时完成付款,那么会造成咖啡店无法提供足够的咖啡;如果咖啡店在售卖这杯咖啡时使用一个 “互斥锁”,前一客户付款完成之后才能进行下一单的付款,那么咖啡店便能够正确处理这种情况。

这上面的例子中,咖啡便可以看作 “共享数据”,两位客户的购买行为可以看作 “多线程” 执行,那么前一种情况可以类比为非线程安全,后一种情况可以类比为线程安全。

上面讲到的非线程安全,是由于在多线程下竞态条件的产生导致的。竞态条件(也称竞争冒险)指的是一个系统的输出结果取决于不受控制的事件的发生顺序。竞态条件并不是多线程的专有名词,其在除计算机以外的其他领域(比如电子电路系统、网络等领域)中也被使用。

当多个线程同时修改共享数据时,如果不进行任何额外的并发控制,数据的最终结果依赖于线程的执行顺序。如果线程之间发生了并发访问冲突,比如在两个线程之间,第一个线程对数据的处理未完成时,另外一个线程拿到了处理之前的数据(这种情况常被称为脏读),这时两个线程的处理便会造成数据不一致的问题。

引用计数机制是非线程安全的

在之前的小节中,我们了解了 CPython 的垃圾回收机制,我们知道 CPython 中主要使用引用计数来进行垃圾回收。考虑以下场景:

两个线程中引用同一个对象,则对这个对象引用计数的修改过程中会产生竞态条件,则有可能导致非线程安全,即一个线程对引用计数的更新并未体现在另外一个线程对该引用计数的获取上。比如下图所示的场景中,非线程安全导致该对象的引用计数上只是增加为 1,在线程 2 结束后,线程 1 将无法访问该对象。

image

引入 GIL 保证解释器内部在多线程下共享数据的一致性

随着 CPU 的发展,厂商仅靠提高单核 CPU 的频率已经无法带来相应的性能提高,并且还会因此造成 CPU 热量过高等问题,多核 CPU 的出现便成为了历史发展的必然。多核 CPU 的有效利用和多线程编程直接相关,结合当时的历史背景,引入 GIL 这样粒度较大的全局锁,可以有效的避免 CPython 的内存管理机制在多线程环境中的非线程安全,保证多线程下共享数据的一致性,并且在实现上也相对简单(相对于使用粒度更细的锁或者实现无锁解释器)。

GIL 存在原因的总结

  • GIL 的存在需要结合当时的历史背景看待,在当时 GIL 的引入是一种合适的设计。同时 GIL 带来的问题(多线程无法利用多核 CPU )也更多的是历史遗留问题。
  • GIL 以简单的实现方式有效的解决了 CPython 在内存管理机制上的非线程安全。
  • 全局锁粒度较大,不需要频繁的获取和释放。相比于其他实现(比如粒度更细的锁),GIL 使得 Python 在单线程下可以保证较高的效率。
  • GIL 的存在有效的保证了 Python 与非线程安全的 C 库的集成,降低了集成 C 库的难度(避免考虑线程安全),促进了 C 库和 Python 的结合,这也是 Python 本身的优势之一。

    在 GIL 的存在下,还需要关注线程安全问题吗?

    答案是肯定的,我们仍要关注线程安全问题,保证线程同步。首先我们先来看一个例子:
    >>> import threading
    >>> a = 0
    >>> lock = threading.Lock()
    >>> def increase(n):
    ...     global a
    ...     for i in range(n):
    ...         a += 1
    ...         
    >>> def increase_with_lock(n):
    ...     global a
    ...     for i in range(n):
    ...         lock.acquire()
    ...         a += 1
    ...         lock.release()
    ...         
    >>> def multithread_increase(func, n):
    ...     threads = [threading.Thread(target=func, args=(n,)) for i in range(50)]  # 创建 50 个线程
    ...     for thread in threads:
    ...         thread.start()
    ...     for thread in threads:
    ...         thread.join()
    ...     print(a)
    ...    
    上面的代码中,我们分别有两个函数 increase 和 increase_with_lock ,但两者之间的差别是前者并没有使用互斥锁,我们编写 multithread_increase 来对两个函数的执行结果进行对比:
    >>> multithread_increase(increase, 500000)
    11754347
    >>> a = 0  # 注意将 a 置为 0,否则下面的输出结果会在 11754347 上递增
    >>> multithread_increase(increase_with_lock, 500000)
    25000000
    可以看到,两者的差别巨大。其实这个实验结果已经告诉了我们答案,在前者无锁的递增函数中,是非线程安全的,其输出结果和预期的 50 * 500000 并不一致,说明在多线程执行过程中,某些线程的修改结果并未体现在其他线程的修改过程中。

原因在于:

  • GIL 保证的是每一条字节码在执行过程中的独占性,即每一条字节码的执行都是原子性的。

  • GIL 具有释放机制,所以 GIL 并不会保证字节码在执行过程中线程不会进行切换,即在多个字节码之间,线程具有切换的可能性。可以看到下面的代码中,对于 a += 1 这个表达式,需要多个字节码去完成,在 LOAD_GLOBAL 和 STORE_GLOBAL 之间,线程具有切换的可能性,所以说它是非线程安全的。

    >>> def func():
    ...     global a
    ...     a += 1
    ...     
    >>> dis.dis(func)
    3           0 LOAD_GLOBAL              0 (a)
                2 LOAD_CONST               1 (1)
                4 INPLACE_ADD
                6 STORE_GLOBAL             0 (a)
                8 LOAD_CONST               0 (None)
               10 RETURN_VALUE
  • GIL 和线程互斥锁的粒度是不同的,GIL 是 Python 解释器级别的互斥,保证的是解释器级别共享资源的一致性,而线程互斥锁则是代码级(或用户级)的互斥,保证的是 Python 程序级别共享数据的一致性,所以我们仍需要线程互斥锁及其他线程同步方式来保证数据一致。

在 GIL 下我们可以使用什么方法来提升性能?

面对 GIL 的存在,我们并不是束手无策的,有多个方法可以在帮助我们来提升性能:

  • 在 I/O 密集型任务下,我们可以使用多线程或者协程来完成。
  • 如果你还在使用过于陈旧的 Python 版本,那么切换到 Python 3.2 以后的版本是一个可选的方法。
  • 可以选择更换 Jython 等没有 GIL 的解释器,但并不推荐更换解释器,因为会错过众多 C 语言模块中的有用特性。
  • 使用多进程来代替多线程。
  • 将计算密集型任务转移到 Python 的 C / C++ 扩展模块中完成。

    使用多进程代替多线程

    在 GIL 的存在下,要利用多核处理器最常用的方法便是使用多进程来完成。每个进程拥有独立的解释器、GIL 以及数据资源,多个进程之间不会再受到 GIL 的限制。Python 标准库提供了multiprocessing 来支持多进程代码的编写 。如下,我们分别使用多线程和多进程来完成以递增函数为基础的计算密集型任务,执行结果的五次均值为分别为 3.3574749312829226 s 和 1.7455148852895945 s,可以看到明显的差别。
    >>> from multiprocessing import Pool
    >>> import timeit
    >>> def loop_add(n):
    ...     i = 0
    ...     while i < n:
    ...         i += 1
    ...       
    >>> def multithread_loop_add():
    ...     t1 = threading.Thread(target=loop_add, args=(50000000,))
    ...     t2 = threading.Thread(target=loop_add, args=(50000000,))
    ...     t1.start()
    ...     t2.start()
    ...     t1.join()
    ...     t2.join()
    ...
    >>> def multiprocess_loop_add():
    ...     pool = Pool(processes=2)
    ...     p1 = pool.apply_async(loop_add, (50000000,))
    ...     p2 = pool.apply_async(loop_add, (50000000,))
    ...     pool.close()
    ...     pool.join()
    ...     
    >>> timeit.repeat(stmt="multithread_loop_add()", setup="from __main__ import multithread_loop_add", number=1)
    [3.381616324884817, 3.3523352828342468, 3.345939421793446, 3.354648763779551, 3.3528348631225526]
    >>> timeit.repeat(stmt="multiprocess_loop_add()", setup="from __main__ import multiprocess_loop_add", number=1)
    [1.7479460190515965, 1.7717540070880204, 1.707774972077459, 1.7687199511565268, 1.7313794770743698]
    但需要注意的是,多进程之间的通信较多线程相对复杂一些,关于多进程编程我们在后面也会有较为详细的讲解。

编写 Python 的 C / C++ 扩展模块

我们可以像众多基础库和第三方库的做法一样为 Python 编写扩展程序来扩展 Python 解释器的功能 。把计算密集型任务放在 C / C++ 中实现,在 C / C++ 中释放 GIL,比如像下面的代码所展示形式:

static PyObject *modulename_func(PyObject *self, PyObject *args)
{
   ...
   Py_BEGIN_ALLOW_THREADS
   // C code
   ...
   Py_END_ALLOW_THREADS
   ...
}

同时也可以使用比如 CythoncffiSWIGNumba 等第三方工具来构建 C / C++ 扩展模块。

GIL 会消失吗?

合理的看待 GIL 的存在

在讨论 GIL 的去除之前,我们应该对 GIL 有更合理的认识,需要明确的是 GIL 是否真的影响到了你编写的程序。

  • 通常 GIL 只会影响到大量依赖 CPU 的字节码执行。
  • 在 I/O 处理时,GIL 会被释放。如果你的程序涉及到网络访问、磁盘读写等场景,你可以使用多线程。
  • 大量的标准库和第三方库模块中,在需要执行计算密集型任务以及对性能有较高要求的场景下,都已经使用 C / C++ 实现,不会被 GIL 影响,比如 Numpy 矩阵运算、压缩、图形处理等。
  • 如果你需要进行一些密集的计算,上面我们介绍了多种可以提升性能的方法。除此之外还有一些常用的方法:借助现成的库,比如处理数据时可以使用 Numpy 等库;合理的分析并优化相关的算法等。

    GIL 漫长的去除之路

    由于多核 CPU 不断发展并成为时代主流,Python 核心开发者开始着手去除 GIL,但这条路并不好走,大量库的设计和开发都已经非常依赖于 GIL 带来的 Python 内部对象线程安全这一先决条件。

早在 Python 1.5 时,有相关开发者完成了去除 GIL 的补丁包,使用粒度更细的锁来代替,但由于大量的锁释放和获取等过程降低了 Python 在单线程下的运行效率而没有被采纳。 另外,在最近的 PEP 554 中,提出了子解释器的概念,在单个进程中可以生成多个解释器,通过共享内存或其他方式进行通信。这样的子解释器开销会比进程要小。

最后,关于 GIL 的讨论和攻克可能会长期存在,Python 社区在不断优化 GIL,同时也在尝试去除它。

总结

我们在这个小节中,使用了上下两篇来讲解 Python 中的 GIL,基本涵盖了 GIL 的相关技术点。在 Python 版本迭代过程中,关于 GIL 的相关实现也在发生着变动,请大家保持对新版本改动的不断跟进。在理解 GIL 的概念和基本实现方式的基础上,更重要的是需要以合理的角度去看待它,不能只一味的认为 GIL 是设计的缺陷,这种观点是站不住脚的。另外,也需要对如何在 GIL 下利用多核处理器的相关方式进行了解。可以预见的是,GIL 会在一段时间内长期陪伴我们,我们需要学会如何和它更好的相处。

预览图
评论区

索引目录