引言
有这样一个话题,它对于在 Python 中使用过多线程的开发者来说一定不会陌生。当你遇到它的时候,如果你不了解它的话,很可能你会质疑自己的代码是否具有一些隐藏的问题。在各种社区或论坛中,你也会经常看到关于它的讨论和分析。它便是在当前的 Python 中起到重要作用的全局解释器锁 GIL。
全局解释器锁 GIL
什么是 GIL?
全局解释器锁 GIL,英文名称为 Global Interpreter Lock,它是解释器中一种线程同步的方式。对于每一个解释器进程都具有一个 GIL ,它的直接作用是限制单个解释器进程中多线程的并行执行,使得即使在多核处理器上对于单个解释器进程来说,在同一时刻运行的线程仅限一个。
Python 中的 GIL
Python 代码被编译后的字节码会在解释器中执行,在执行过程中,存在于 CPython 解释器中的 GIL 会致使在同一时刻只有一个线程可以执行字节码。
对于 Python 来讲,GIL 并不是它语言本身的特性,而是 CPython 解释器的实现特性。在Python 目前众多的实现中,其中 PyPy 也是具有 GIL 的,Jython、IronPython 中没有 GIL,但这些解释器由于其他原因并未得到广泛使用。同时,其他的编程语言,比如 Ruby 的 C 语言实现中也是具有 GIL 的。
CPython 中的 GIL 导致的问题
GIL 的存在引起的最直接的问题便是:在一个解释器进程中通过多线程的方式无法利用多核处理器来实现真正的并行。对应到真实的业务场景中,在完成一些计算密集型任务时,我们无法通过多线程编程的方式来提高代码的执行效率。
不同类型任务下 GIL 对 Python 多线程的影响
接下来我们就用一些代码实例来验证对 GIL 的描述,上面我们提到了计算密集型任务,这里我简要对比计算密集型任务和 I/O 密集型任务。
-
计算密集型( CPU-bound ):也称为 CPU 密集型,大部分时间都用于进行计算、逻辑验证等 CPU 处理的程序,比如矩阵计算、视频编解码等,CPU 占用率高。
-
I/O 密集型( I/O-bound ):大部分时间都用于等待 I/O (比如网络 I/O,磁盘 I/O )处理完成的程序,比如大多数 Web 应用等,CPU 占用率低。
计算密集型任务
首先我们以下面的递增函数为基础对计算密集型任务在 Python 多线程下的表现进行简单的测试。
>>> def loop_add(n):
... i = 0
... while i < n:
... i += 1
...
在单线程(即下面运行的主线程)下执行五次( repeat 方法会默认执行 5 次),执行时间平均值为 3.797136794799735 s。
>>> import timeit
>>> timeit.repeat(stmt="loop_add(100000000)", setup="from __main__ import loop_add", number=1)
[3.824948476998543, 3.784897646999525, 3.7902461680005217, 3.793153800001164, 3.7924378819989215]
在下面的函数中开启两个线程分别进行上面单线程中一半数量的递增,在这里注意需要在开启线程后调用 join() 方法来等待线程终止。
>>> import threading
>>> 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()
...
上面的示例在 4 核心 CPU 机器上运行,按照我们预期内的多线程并行执行,两个线程分别执行一半数量递增任务的时间应该为上面单线程执行递增任务时间的 1/2。
>>> timeit.repeat(stmt="multithread_loop_add()", setup="from __main__ import multithread_loop_add", number=1)
[3.9824146000009932, 3.9702273379989492, 3.918540844000745, 3.9172540660001687, 3.8126948980007]
但在多线程下执行五次后的执行时间平均值为 3.9202263492003113 s,并不符合我们的预期执行时间。就上面的现象,我们可以从下面两个问题的解答中理解:
- 为什么执行时间并没有减半?
这正是上面提到的 GIL 导致的结果。由于在同一时刻,即使在多核 CPU 上,也仅有一个线程在获得该全局锁后才可以执行字节码,其他的线程想要执行字节码就需要等待该全局锁被释放,所以未能实现真正的并行执行,而是一种多线程交替执行的串行执行。
- 为什么在没有减半的基础上,还比单线程执行慢?
因为在多个线程执行过程中也涉及到了全局锁的获取和释放,上下文环境的切换等。并且相较于单核 CPU,这种效率降低的情况在多核 CPU 上在可能会更加显著。
I/O 密集型任务
首先我们以下面的循环网络请求函数为基础对 I/O 密集型任务在 Python 多线程下的表现进行简单的测试。
>>> from urllib import request
>>> def loop_get(n):
... for i in range(n):
... request.urlopen("https://www.imooc.com")
...
在单线程下执行五次,执行时间平均值为 4.795266318800714 s。
>>> timeit.repeat(stmt="loop_get(100)", setup="from __main__ import loop_get", number=1)
[4.631347359998472, 4.591072030001669, 4.871105291000276, 4.901600085002428, 4.9812068280007225]
如下面的代码所示,在多线程下执行五次后的执行时间平均值为 2.408049941000354 s。我们可以看到,在 I/O 密集型任务下,Python 多线程的表现是符合我们预期的。
>>> def multithread_loop_get():
... t1 = threading.Thread(target=loop_get, args=(50,))
... t2 = threading.Thread(target=loop_get, args=(50,))
... t1.start()
... t2.start()
... t1.join()
... t2.join()
...
>>> timeit.repeat(stmt="multithread_loop_get()", setup="from __main__ import multithread_loop_get", number=1)
[2.389618977002101, 2.4772080140028265, 2.405433809999522, 2.6798762809994514, 2.088112622997869]
那么为什么在 I/O 密集型任务下会有这样的效果呢?要回答这个问题,我们需要再对 GIL 更多的细节进行探究。
GIL 全局锁的实现细节
Python 中的线程
Python 使用的是特定于操作系统的线程实现,而并未在解释器中模拟线程。比如在 Linux 系统中,Python 的一个线程对应 Linux 系统中的一个 pthread,其线程调度是交由操作系统本身的调度来完成的。
CPython 中有关 GIL 的一些重要源码
我们在当前版本的 CPython 代码库 中可以找到 ceval_gil.h,它是 GIL 的接口。其中 _gil_runtime_state 可以在 pycore_gil.h 中找到相关定义。
/*
* Implementation of the Global Interpreter Lock (GIL).
*/static int gil_created(struct _gil_runtime_state *gil)
{// 省略部分源码
}static void create_gil(struct _gil_runtime_state *gil)
{// 省略部分源码
}
ceval.c 的主要作用是执行 Python 代码编译后的字节码,在这个文件中我们可以看到在初始化等过程中关于 GIL 的获取和释放:
// 省略部分源码
void
PyEval_InitThreads(void)
{// 省略部分源码if (gil_created(gil)) {return;}PyThread_init_thread();create_gil(gil);// 省略部分源码
}
// 省略部分源码
I/O 密集型任务下的线程切换
在 I/O 密集型任务中,多线程在 GIL 下通常是一种协作式多任务形式。如下图多线程执行中,GIL 会在遇到 I/O 操作时被释放并交由其他线程继续执行,比如网络 I/O 操作、文件读写等。
我们继续在源码的基础上理解这种形式,下面的源码来自于 socketmodule.c ,是网络 I/O 中的相关代码,我们可以看到源码中的 Py_BEGIN_ALLOW_THREADS 和 Py_END_ALLOW_THREADS ,这两处便是在网络连接过程中 Python 主动释放和获取 GIL 。这样便回答了上面关于多线程在 I/O 密集型任务下的表现问题,虽然这种形式仍是只有一个线程在执行字节码,但由于等待 I/O 的时间远远大于 CPU 执行时间,仍可通过 GIL 的释放和线程的切换执行来实现并发处理,可以有效的提高执行效率。
static int
internal_connect(PySocketSockObject *s, struct sockaddr *addr, int addrlen, int raise)
{int res, err, wait_connect;
Py_BEGIN_ALLOW_THREADS
res = connect(s->sock_fd, addr, addrlen);
Py_END_ALLOW_THREADS
if (!res) {/* connect() succeeded, the socket is connected */return 0;}// 省略部分源码
}
计算密集型任务下的线程切换
Python 3.2 之前的 ”基于 opcode 计数“ 机制
那么对于计算密集型任务呢?在计算密集型任务中,如果没有 I/O 相关操作时,还需要一种执行机制来保证 GIL 的释放。在 Python 3.2 之前,CPython 是基于 opcode 执行数量进行周期性间隔检查( Periodic Check )的方式来进行 GIL 的释放和线程切换( opcode,即操作码或操作的数字代码)。如下图所示:
具体地说,当线程在执行时,每经过 “100 ticks” 便主动释放 GIL,此时其他线程便可以获取 GIL 后进行执行。其中 ”tick“ 和时间没有直接关系,它是一个计数器,对应当前线程在两次释放 GIL 之间执行的 opcode 数量,注意这种对应关系并不是每个 opcode 对应一个 ”tick“,有些执行速度快的 opcode 并不会被计入其中,即有可能是多个 opcode 的执行对应一个 “tick”。如下图所示:
这种检查机制时可以通过 sys.setcheckinterval() 和 sys.getcheckinterval() 来设置和查看检查间隔:
>>> sys.getcheckinterval()
100
当然,我们之前提到的在 I/O 操作中主动释放 GIL 和间隔检查的机制是结合在一起的,即当遇到 I/O 操作时,即使 tick 计数没有到100 也会主动释放 GIL 。
”基于 opcode 计数“ 机制存在的问题
-
在 opcode 的执行过程中,“100 ticks” 对应的执行时间是不确定的,有些 opcode 的执行可能相当耗时,这样便导致一些线程的执行时间总是多于其他线程。尤其在多核 CPU 下,运行在不同核心上的线程,一个核心上正在运行执行时间较长的线程时,另外一个核心上的线程可能会多次的抢占 GIL ,导致 CPU 性能没有必要的损耗。
-
不管是在 I/O 操作时释放GIL,还是在执行了一定 opcode 后释放 GIL,Python 决定的是 GIL 的释放,至于哪个线程会获取到 GIL 并执行是交由操作系统来进行控制的。这样也导致了(尤其在多核 CPU 下),可能存在某些线程刚释放 GIL,又重新获取到 GIL,导致其余 CPU 上的线程反复的从唤醒到等待,造成线程颠簸(thrashing)。
Python 3.2 后 ”基于时间片计数“ 机制
在 Python 3.2 中,GIL 的实现机制发生了一些改变,新的实现机制使用固定的时间间隔来进行线程的切换,在其他线程请求获取 GIL 时,当前运行的线程会以 5 毫秒(默认时间)为间隔尝试释放 GIL。具体的细节可以大体总结为以下三点:
-
基于固定时间而不是一定数量的 opcode 来进行线程切换。
-
当只存在单线程运行时,无需进行相关检查和 GIL 释放。
-
在某个线程释放 GIL 后,需要在其他的线程获取到 GIL 后才能再次进行 GIL 的获取。
新的实现方式解决了之前 GIL 中某线程长时间执行以及 GIL 被某线程释放后又重新获取的问题。并且可以看到在 Python 3.2 后,之前的 sys.setcheckinterval() 和 sys.getcheckinterval()已经不再使用,换为 sys.getswitchinterval() 来查看线程切换时间。可以使用help(sys.getswitchinterval) 来查看对应的文档字符串。
>>> sys.getcheckinterval()
<input>:1: DeprecationWarning: sys.getcheckinterval() and sys.setcheckinterval() are deprecated. Use sys.getswitchinterval() instead.
100
>>> sys.getswitchinterval()
0.005
但新的 GIL 实现机制中,线程对 GIL 的获取及执行还是由操作系统完成的,这样仍存在一些效率问题,比如需要被优先执行的某些 I/O 操作的线程可能无法及时获取到 GIL。同时,GIL 对多线程执行的限制这个根本问题还是存在的。
为什么 CPython 中会有 GIL 的存在?
现在看起来在 CPython 中使用 GIL 是一种“有问题”的决策?下面我们需要从多个方面合理的去看待这个问题:
Python 在单核 CPU 时代诞生
Python 诞生以及流行时,还是单核 CPU 盛行的时代。当时各大 CPU 厂商的主要发力点还在通过提高频率来提升 CPU 性能。所以在最初 CPython 解释器的设计开发中,更多考虑的是适合当时主流的单核 CPU 下的使用场景。
CPython 的内存管理是非线程安全的
上面我们了解了 Python 语言发展初期的一些时代背景,接下来我们便走进 Python 中使用 GIL 的核心原因。要理解核心原因,我们需要首先对线程安全和竞态条件进行了解。
线程安全与竞态条件
线程安全指的是计算机程序在多线程环境下运行时,可以按照正确的方式处理多个线程之间的共享数据。我们通过下图来形象的说明线程安全和非线程安全:
在咖啡店中只剩最后一杯咖啡时有两位客户同时购买,如果两人同时完成付款,那么会造成咖啡店无法提供足够的咖啡;如果咖啡店在售卖这杯咖啡时使用一个 “互斥锁”,前一客户付款完成之后才能进行下一单的付款,那么咖啡店便能够正确处理这种情况。
这上面的例子中,咖啡便可以看作 “共享数据”,两位客户的购买行为可以看作 “多线程” 执行,那么前一种情况可以类比为非线程安全,后一种情况可以类比为线程安全。
上面讲到的非线程安全,是由于在多线程下竞态条件的产生导致的。竞态条件(也称竞争冒险)指的是一个系统的输出结果取决于不受控制的事件的发生顺序。竞态条件并不是多线程的专有名词,其在除计算机以外的其他领域(比如电子电路系统、网络等领域)中也被使用。
当多个线程同时修改共享数据时,如果不进行任何额外的并发控制,数据的最终结果依赖于线程的执行顺序。如果线程之间发生了并发访问冲突,比如在两个线程之间,第一个线程对数据的处理未完成时,另外一个线程拿到了处理之前的数据(这种情况常被称为脏读),这时两个线程的处理便会造成数据不一致的问题。
引用计数机制是非线程安全的
我们知道 CPython 中主要使用引用计数来进行垃圾回收。考虑以下场景:两个线程中引用同一个对象,则对这个对象引用计数的修改过程中会产生竞态条件,则有可能导致非线程安全,即一个线程对引用计数的更新并未体现在另外一个线程对该引用计数的获取上。比如下图所示的场景中,非线程安全导致该对象的引用计数上只是增加为 1,在线程 2 结束后,线程 1 将无法访问该对象。
**引入 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
...
}
同时也可以使用比如 Cython 、cffi 、SWIG 和 Numba 等第三方工具来构建 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 的概念和基本实现方式的基础上,更重要的是需要以合理的角度去看待它,不能只一味的认为 GIL 是设计的缺陷,这种观点是站不住脚的。另外,也需要了解如何在 GIL 下利用多核处理器。可以预见的是,GIL 会在一段时间内长期陪伴我们,我们需要学会如何和它更好的相处。