目录
Python GIL
在多处理器时代,程序要想充分的利用计算平台的性能,就必须按照并发方式进行设计。但是很遗憾,对于 Python 程序而言,不管你的服务器拥有多少个处理器,任何时候总是有且只能有一个线程在运行。这就是 GIL(Global Interpreter Lock,全局解释器锁)为 Python 带来的最困难的问题。并且目前看来短时间内这个问题是难以得到解决的,以至于 Python 专家们通常会建议你 “不要使用多线程,请使用多进程”。
Python 的垃圾回收主要是采用了简单明了的 “引用计数法”,为每个对象都创建一个 “引用计数” 字段,每次有新的变量指向了该对象,该对象的引用计数就会 +1,当变量指向了别的对象,则 -1,当对象的 “引用计数” 为 0,就意味着对象可以被回收了。
看似简单,实际上一点都不简单,每次遇到变量的赋值操作时,都得增加新对象的引用计数,还得减少老对象的引用计数,更要命的是循环引用问题。所以,Python 还使用了 “标记-清除”、“分代回收” 等算法作为辅助。
但最核心的问题是,Python 要怎么处理多个线程同时修改一个对象的引用计数问题?如果引用计数被错误地修改,就很可能会导致一个对象一直不被回收,或者回收了一个不能被回收对象。
对此,通常的做法是通过 “加锁” 来保证多线程场景中的 “引用计数” 数据一致性。即:给每个对象上都加了一把锁,只让一个线程进入修改。而 Python 的做法是使用了一把超级大锁,即 GIL,这把超级大锁只允许同一时刻只有一个线程可以获得 Python 解释器的控制权, 即同一时刻,只有一个线程能够运行。
Python GIL 对线程并发性能的影响
Python GIL 的好处是避免了在每个对象上都加锁,每次访问都加锁/解锁,开销太大。而坏处就是在多核硬件平台上,只有一个线程执行,造成 “一核工作,多核围观” 的浪费,而且线程切换的时候还得释放 GIL,竞争 GIL,多线程可能跑得比单线程还慢。
你可能会感到奇怪:即便 Python 多线程没有完成真正的并行,那也应该和串行的单线程差不太多才是啊?实际情况可以比你想象的更加糟糕,Python 的多线程在多核平台场景中会比单线程的效率下降 45%。这是由于 GIL 的设计缺陷导致的。
Python 社区认为操作系统的调度器已经非常成熟,可以直接使用,所以 Python 的线程实际上是 C 语言的一个 pthread,并交由系统调度器根据调度算法和策略进行调度。同时,为了让各线程能够平均的获得 CPU 时间片,Python 会自己维护一个微代码(字节码指令)执行计数器(Python2:1000 字节码指令,Python3:15 毫秒),达到一定的计数阈值后就会强制当前线程释放 GIL,让其他线程得到进入 CPU 的机会,这意味着 GIL 的释放与获取是伴随着操作系统线程切换一起进行的。
这样的模式在单处理器计算平台中是没有问题的,每触发一次线程切换,当前线程都能够如愿获取 GIL 并执行字节码指令,所以单个处理器始终是忙碌的。
但在多处理器计算平台中这样的模式会发生什么呢?GIL 只有一个,给了在 CPU1 的当前线程,就不能给 CPU2 的当前线程,所以 CPU2 的当前线程只能白白浪费 CPU 执行时间(线程只有获取了 GIL 才能执行字节码指令)。而且在多处理器计算平台中还平添了线程切换甚至是进程切换的各种开销,赔了夫人又折兵。
- 绿色:CPU 的有效执行时间
- 红色:线程因为没拿到 GIL 白白浪费的 CPU 时间
另外,在多核情况下,被分配到其他核的线程由于需要等待信号,唤醒以后才能竞争。那么,就有可能存在一个线程 A 会经常抢先,以至于 “打压” 其他线程,让它们难以运行。
所以,对于 Python 程序而言,如果真想利用多核的特性,还想避开 GIL,建议还是用多进程吧。
保留 GIL 的历史原因
为了兼顾解释型语言的简单
Python 是解释型语言,程序代码被编译成二进制格式的字节码,然后再由 Python 解释器的主回路 pyeval_evalframeex()
边读取字节码,边逐一执行其中的指令。显然,解释器在程序运行之前对程序本身并不是完全了解的,解释器只知道 Python 既定的规则以及在执行过程中怎样动态的去遵守这些规则。
Python 解释器无法像 C/C++ 编译器那般在程序进入到处理器运行之前就已经对程序代码拥有了全局的语义分析和理解能力。作为解释型语言,Python 解释器无法在程序真正运行之前就告诉你,你的多线程代码实现到底有多糟糕(隐含的逻辑错误要到真正运行时才会触发)。
为了兼顾 C 程序库的安全
从上文中我们了解到,同一进程中的多个线程间存在数据共享,为了避免内存可见性导致的并发安全问题,编程语言大多会提供用户可控的数据的保护机制,也就是线程同步功能。使用线程同步功能,可以控制程序流以及安全访问共享数据,从而并发执行多个线程。常见的同步模型大致有以下四种:
- 互斥锁:仅允许每次使用一个线程来执行特定代码块或者访问特定的共享数据。
- 读写锁:允许对受保护的共享数据进行并发读取和独占写入(多读单写)。要修改共享数据,线程必须首先获取互斥写锁。只有释放所有的读锁之后,才允许使用互斥写锁。
- 条件变量:一直阻塞线程,直到特定的条件为真。
- 计数信号量:通常用来协调对共享数据的访问。使用计数,可以限制访问某个信号的线程数量。达到计数阈值时,信号被阻塞,直至线程执行接收,计数减少
而 Python 诞生于上世纪 90 年代初,基于很多 C 语言的扩展库来进行开发。当初引入 GIL 的主要原因是为了让 这些 C 扩展库都能够不必考虑线程的安全问题(thread-safe),使其很容易地被集成进来。而 C 扩展库也极大地丰富了 Python 的功能,促进了 Python 的发展。GIL 解决的问题本质就是 Python 多线程的线程安全问题。
static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */
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 primarily 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.)
在 CPython(最常用的 Python 解释器实现)中,全局解释器锁(GIL)是一个全局的互斥锁,它可以防止多线程同时执行 Python 程序的字节码。这种锁是必要的,主要因为 CPython 的内存管理不是线程安全的。
Python 的多线程编程要点
那么,Python 的多线程到底还能不能用?
就结果而言,如果业务系统中存在任意一个 CPU 密集型的任务,那么我会告诉你 “多进程或者协程都是不错的选择”。如果业务系统中全都是 I/O 密集型任务,那么恭喜你,多线程将会起到积极的作用。
在 I/O 密集型场景中,程序的性能瓶颈通常不在 CPU,而在于 I/O,例如:用户输入,数据库查询,网络访问等。当执行线程触发 I/O 操作时,可以考虑立即放弃 GIL 这个超级大锁,转而让其他线程执行。
所以,Python 多线程在 I/O 密集型场景中可以实现真正的并发,是因为一个等待 I/O 的当前线程会在长的或者不确定的一段时间内,可能并没有任何 Python 代码会被执行,那么该线程就会将 GIL 让出给其他处理器上的当前线程使用(一个在等 I/O,一个在执行 Python 代码)。这种礼貌行为称为 “协同式多任务处理”,它允许并发。不同的线程在等待不同的事件。
综上,对于复杂的 Python 业务系统而言,分布式架构(解耦 CPU 密集型业务和 I/O 密集型业务并分别部署到不同的服务器上进行调优)是一个不错选择。
值的注意的是,当我们在 I/O 密集型场景中使用多线程时,依旧要严格遵守多线程的线程安全问题,Python 提供了下列 2 种常见的实现:
- 原子性操作
- 线程库锁(e.g. threading.Lock)
Python 提供的原子性操作
Python 提供的许多内置函数都是具有原子性的,例如排序函数 sort()
。
>>> lst = [4, 1, 3, 2]
>>> def foo():
... lst.sort()
...
>>> import dis
>>> dis.dis(foo)
2 0 LOAD_GLOBAL 0 (lst)
3 LOAD_ATTR 1 (sort)
6 CALL_FUNCTION 0
9 POP_TOP
10 LOAD_CONST 0 (None)
13 RETURN_VALUE
LOAD_GLOBAL
:将全局变量 lst 的数据 load 到堆栈LOAD_ATTR
:将 sort 的实现 load 到堆栈CALL_FUNCTION
:调用 sort 对 lst 的数据进行排序
我们使用 dis 模块来编译出上述代码的字节码,最关键的字节码指令为:
真正执行排序的只有 CALL_FUNCTION
一条指令,所以说该操作具有原子性。
Python 的线程库锁
我们再举个例子看看非原子操作下,怎么保证线程安全。
>>> n = 0
>>> def foo():
... global n
... n += 1
...
>>> import dis
>>> dis.dis(foo)
3 0 LOAD_GLOBAL 0 (n)
3 LOAD_CONST 1 (1)
6 INPLACE_ADD
7 STORE_GLOBAL 0 (n)
10 LOAD_CONST 0 (None)
13 RETURN_VALUE
- 13
代码编译后的字节码指令:
- 将全局变量 n 的值 load 到堆栈
- 将常数 1 的值 load 到堆栈
- 在堆栈顶部将两个数值相加
- 将相加结果存储回全局变量 n 的地址
- 将常数 0(None) 的值 load 到堆栈
- 从堆栈顶部返回常数 0 给函数调用者
语句 n += 1
被编译成了前 4 个字节码,后两个字节码是 foo 函数的 return 操作,解释器自动添加。
我们在上文提到,Python2 的线程每执行 1000 个字节码就会被动的让出 GIL。现在假如字节码指令 INPLACE_ADD
就是那第 1000 条指令,这时本应该继续执行 STORE_GLOBAL 0 (n)
存储到 n 地址的数据就被驻留在了堆栈中。如果同一时刻,变量 n 被别的处理器当前线程中的代码调用了。那么请问现在的 n 还是 +=1 之后的 n 吗?答案是此时的 n 发生了更新丢失,在两个当前线程中的 n 已经不是同一个 “n” 了。这就是上面我们提到过的内存可见性数据安全问题的又一个佐证。
下面的代码正确输出为 100,但在 Python 多线程多处理器场景中,可能会得到 99 或 98 的结果。
import threading
n = 0
threads = []
def foo():
global n
n += 1
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)
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
此时,Python 程序员应该要想到使用 Python 线程库的锁来解决为。
import threading
n = 0
lock = threading.Lock()
threads = []
def foo():
global n
with lock:
n += 1
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)
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
显然,即便 Python 已经存在了 GIL,但依旧要求程序员坚持 “始终为共享可变状态的读写上锁”。
GIL 的后续
当然也有人尝试过将 GIL 改废,Greg Stein 在 1999 年提出的 “Free Threading” patch 中移除了 GIL。但结果就是单线程执行性能下降了 40%,同时多线程的性能提升也未能达到线性增长标准。
至今为止有许多乐于挑战的开发者们在尝试解决这一难题,甚至发布了多种没有 GIL 的 Python 解释器实现(e.g. JPython、IronPython)。不过很可惜的是,由于这些 “特殊” 解释器不属于 C 语言生态圈,所以没能享受到社区众多优秀 C 语言模块的福利,也就注定无法成为主流,只能在特定的场景中发挥着属于自己的特长。
无论如何,GIL 作为 Python 的文化基因,深远的影响了每一位 Pythoner,但却并不完全是正面的影响。例如:Python 程序员对多线程安全问题的理解与任何 C 或 Java 程序员都是大相径庭的。GIL 和 Python 原子性操作的 “溺爱” 让大多数 Python 程序员产生了 “Python 是原生线程安全的编程语言” 的幻觉,并最终在大规模并发应用场景中屡屡受挫。或许真是应了那一句 “Python 的门很好进,但进了门之后才发现 Python 的殿堂在天上”。
那么 GIL 是万恶之源吗?也不尽然,编程的世界永远是「时间和空间」的权衡,简单优雅或许才是真正的 Python 之美。