在Python学习:Python并发编程之Futures中解释为什么多线程每次只能有一个线程执行?是由于GIL(全局解释器锁)的存在,导致无论你启多少个线程,你有多少个cpu, Python在执行的时候会淡定的在同一时刻只允许一个线程运行。
通过例子发现了多线程比单线程没什么效率提高,甚至有时候效率更差,多核比单核也没有提高。难道Python的线程是假线程?
Python 的线程,的的确确封装了底层的操作系统线程,在 Linux 系统里是 Pthread(全称为 POSIX Thread),而在 Windows 系统里是 Windows Thread。另外,Python 的线程,也完全受操作系统管理,比如协调何时执行、管理内存资源、管理中断等等。所以,虽然 Python 的线程和 C++ 的线程本质上是不同的抽象,但它们的底层并没有什么不同。
原因就是GIL,导致了 Python 线程的性能并不像我们期望的那样。
一、为什么有 GIL?
GIL,是最流行的 Python 解释器 CPython 中的一个技术术语。它的意思是全局解释器锁,本质上是类似操作系统的 Mutex。每一个 Python 线程,在 CPython 解释器中执行时,都会先锁住自己的线程,阻止别的线程执行。
当然,CPython 会做一些小把戏,轮流执行 Python 线程。这样一来,用户看到的就是“伪并行”——Python 线程在交错执行,来模拟真正并行的线程。
那么,为什么 CPython 需要 GIL 呢?这其实和 CPython 的实现有关。
CPython 使用引用计数来管理内存,所有 Python 脚本中创建的实例,都会有一个引用计数,来记录有多少个指针指向它。当引用计数只有 0 时,则会自动释放内存。
>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3
这个例子中,a 的引用计数是 3,因为有 a、b 和作为参数传递的 getrefcount 这三个地方,都引用了一个空列表。
这样一来,如果有两个 Python 线程同时引用了 a,就会造成引用计数的 race condition,引用计数可能最终只增加 1,这样就会造成内存被污染。因为第一个线程结束时,会把引用计数减少 1,这时可能达到条件释放内存,当第二个线程再试图访问 a 时,就找不到有效的内存了。
所以说,CPython 引进 GIL 其实主要就是这么两个原因:
- 一是设计者为了规避类似于内存管理这样的复杂的竞争风险问题(race condition);
- 二是因为 CPython 大量使用 C 语言库,但大部分 C 语言库都不是原生线程安全的(线程安全会降低性能和增加复杂度)。
二、GIL是如何工作的?
下面这张图,就是一个 GIL 在 Python 程序的工作示例。其中,Thread 1、2、3 轮流执行,每一个线程在开始执行时,都会锁住 GIL,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放 GIL,以允许别的线程开始利用资源。
为什么 Python 线程会去主动释放 GIL 呢?毕竟,如果仅仅是要求 Python 线程在开始执行时锁住 GIL,而永远不去释放 GIL,那别的线程就都没有了运行的机会。
CPython 中还有另一个机制,叫做 check_interval,意思是 CPython 解释器会去轮询检查线程 GIL 的锁住情况。每隔一段时间,Python 解释器就会强制当前线程去释放 GIL,这样别的线程才能有执行的机会。
不同版本的 Python 中,check interval 的实现方式并不一样。早期的 Python 是 100 个 ticks,大致对应了 1000 个 bytecodes;而 Python 3 以后,interval 是 15 毫秒。
整体来说,每一个 Python 线程都是类似这样循环的封装,看下面这段代码:
for (;;) {
if (--ticker < 0) {
ticker = check_interval;
/* Give another thread a chance */
PyThread_release_lock(interpreter_lock);
/* Other threads may run now */
PyThread_acquire_lock(interpreter_lock, 1);
}
bytecode = *next_instr++;
switch (bytecode) {
/* execute the next instruction ... */
}
}
从这段代码中,可以看到,每个 Python 线程都会先检查 ticker 计数。只有在 ticker 大于 0 的情况下,线程才会去执行自己的 bytecode。
程序中只有一个线程的时候还需要GIL吗?
python中内存管理依赖于GC(一段用于回收内存的代码)也需要一个线程。
除了你自己开的线程,系统还有一些内置线程,就算你的代码不会去竞争解释器,内置线程也可能会竞争所以必须加上锁。
例如:
GC发现变量x引用计数为0,正准备清扫,CPU突然切换到了另一个线程a。
a拿着x进行使用,在使用的过程中,又切换到了GC,GC接着把X指向的空间进行释放。
这样一来a中的x就无法使用了,GIL将分配内存回收内存相关的操作加了锁。
GIL无法避免自定义的线程中的数据竞争问题
当一个线程遇到了IO,同时解释器也会自动解锁,去执行其他线程,CPU会切换到其他程序。
三、Python 的线程安全
不过,有了 GIL,并不意味着 Python 编程者就不用去考虑线程安全了。即使知道,GIL 仅允许一个 Python 线程执行,但Python 还有 check interval 这样的抢占机制。考虑这样一段代码:
import threading
n = 0
def foo():
global n
n += 1
threads = []
for i in range(100):
t = threading.Thread(target=foo)
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
print(n)
执行发现,尽管大部分时候它能够打印 100,但有时侯也会打印 99 或者 98。
这其实就是因为,n+=1这一句代码让线程并不安全。如果翻译 foo 这个函数的 bytecode,就会发现,它实际上由下面四行 bytecode 组成:
>>> import dis
>>> dis.dis(foo)
LOAD_GLOBAL 0 (n)
LOAD_CONST 1 (1)
INPLACE_ADD
STORE_GLOBAL 0 (n)
而这四行 bytecode 中间都是有可能被打断的!
GIL 的设计,主要是为了方便 CPython 解释器层面的编写者,而不是 Python 应用层面的程序员。作为 Python 的使用者,还是需要 lock 等工具,来确保线程安全。比如下面的这个例子:
n = 0
lock = threading.Lock()
def foo():
global n
with lock:
n += 1
四、如何绕过 GIL?
Python 的 GIL,是通过 CPython 的解释器加的限制。如果你的代码并不需要 CPython 解释器来执行,就不再受 GIL 的限制。事实上,很多高性能应用场景都已经有大量的 C 实现的 Python 库,例如 NumPy 的矩阵运算,就都是通过 C 来实现的,并不受 GIL 影响。
所以,大部分应用情况下,并不需要过多考虑 GIL。因为如果多线程计算成为性能瓶颈,往往已经有 Python 库来解决这个问题了。
比如在深度学习应用里,大部分代码就都是 Python 的。在实际工作中,如果我们想实现一个自定义的微分算子,或者是一个特定硬件的加速器,那我们就不得不把这些关键性能(performance-critical)代码在 C++ 中实现(不再受 GIL 所限),然后再提供 Python 的调用接口。
总的来说,绕过 GIL 的大致思路有这么两种就够了:
- 绕过 CPython,使用 JPython(Java 实现的 Python 解释器)等别的实现;
- 把关键性能代码,放到别的语言(一般是 C++)中实现。
参考
《Python核心技术与实战》
《GIL(全局解释器锁)》