You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

216 lines
11 KiB
Markdown

2 years ago
# 23 | 你真的懂Python GIL全局解释器锁
你好,我是景霄。
前面几节课我们学习了Python的并发编程特性也了解了多线程编程。事实上Python多线程另一个很重要的话题——GILGlobal Interpreter Lock即全局解释器锁却鲜有人知甚至连很多Python“老司机”都觉得GIL就是一个谜。今天我就来为你解谜带你一起来看GIL。
## 一个不解之谜
耳听为虚眼见为实。我们不妨先来看一个例子让你感受下GIL为什么会让人不明所以。
比如下面这段很简单的cpu-bound代码
```
def CountDown(n):
while n > 0:
n -= 1
```
现在假设一个很大的数字n = 100000000我们先来试试单线程的情况下执行CountDown(n)。在我手上这台号称8核的MacBook上执行后我发现它的耗时为5.4s。
这时,我们想要用多线程来加速,比如下面这几行操作:
```
from threading import Thread
n = 100000000
t1 = Thread(target=CountDown, args=[n // 2])
t2 = Thread(target=CountDown, args=[n // 2])
t1.start()
t2.start()
t1.join()
t2.join()
```
我又在同一台机器上跑了一下结果发现这不仅没有得到速度的提升反而让运行变慢总共花了9.6s。
我还是不死心决定使用四个线程再试一次结果发现运行时间还是9.8s和2个线程的结果几乎一样。
这是怎么回事呢难道是我买了假的MacBook吗你可以先自己思考一下这个问题也可以在自己电脑上测试一下。我当然也要自我反思一下并且提出了下面两个猜想。
第一个怀疑:我的机器出问题了吗?
这不得不说也是一个合理的猜想。因此我又找了一个单核CPU的台式机跑了一下上面的实验。这次我发现在单核CPU电脑上单线程运行需要11s时间2个线程运行也是11s时间。虽然不像第一台机器那样多线程反而比单线程更慢但是这两次整体效果几乎一样呀
看起来这不像是电脑的问题而是Python的线程失效了没有起到并行计算的作用。
顺理成章我又有了第二个怀疑Python的线程是不是假的线程
Python的线程的的确确封装了底层的操作系统线程在Linux系统里是Pthread全称为POSIX Thread而在Windows系统里是Windows Thread。另外Python的线程也完全受操作系统管理比如协调何时执行、管理内存资源、管理中断等等。
所以虽然Python的线程和C++的线程本质上是不同的抽象,但它们的底层并没有什么不同。
## 为什么有GIL
看来我的两个猜想都不能解释开头的这个未解之谜。那究竟谁才是“罪魁祸首”呢事实上正是我们今天的主角也就是GIL导致了Python线程的性能并不像我们期望的那样。
GIL是最流行的Python解释器CPython中的一个技术术语。它的意思是全局解释器锁本质上是类似操作系统的Mutex。每一个Python线程在CPython解释器中执行时都会先锁住自己的线程阻止别的线程执行。
当然CPython会做一些小把戏轮流执行Python线程。这样一来用户看到的就是“伪并行”——Python线程在交错执行来模拟真正并行的线程。
那么为什么CPython需要GIL呢这其实和CPython的实现有关。下一节我们会讲Python的内存管理机制今天先稍微提一下。
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以允许别的线程开始利用资源。
![](https://static001.geekbang.org/resource/image/db/8d/dba8e4a107829d0b72ea513be34fe18d.png)
细心的你可能会发现一个问题为什么Python线程会去主动释放GIL呢毕竟如果仅仅是要求Python线程在开始执行时锁住GIL而永远不去释放GIL那别的线程就都没有了运行的机会。
没错CPython中还有另一个机制叫做check\_interval意思是CPython解释器会去轮询检查线程GIL的锁住情况。每隔一段时间Python解释器就会强制当前线程去释放GIL这样别的线程才能有执行的机会。
不同版本的Python中check interval的实现方式并不一样。早期的Python是100个ticks大致对应了1000个bytecodes而 Python 3以后interval是15毫秒。当然我们不必细究具体多久会强制释放GIL这不应该成为我们程序设计的依赖条件我们只需明白CPython解释器会在一个“合理”的时间范围内释放GIL就可以了。
![](https://static001.geekbang.org/resource/image/42/88/42791f4cf34c0a784f466be22efeb388.png)
整体来说每一个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。
## 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你的程序就可以高枕无忧了我们仍然需要去注意线程安全。正如我开头所说**GIL的设计主要是为了方便CPython解释器层面的编写者而不是Python应用层面的程序员**。作为Python的使用者我们还是需要lock等工具来确保线程安全。比如我下面的这个例子
```
n = 0
lock = threading.Lock()
def foo():
global n
with lock:
n += 1
```
## 如何绕过GIL
学到这里估计有的Python使用者感觉自己像被废了武功一样觉得降龙十八掌只剩下了一掌。其实大可不必你并不需要太沮丧。Python的GIL是通过CPython的解释器加的限制。如果你的代码并不需要CPython解释器来执行就不再受GIL的限制。
事实上很多高性能应用场景都已经有大量的C实现的Python库例如NumPy的矩阵运算就都是通过C来实现的并不受GIL影响。
所以大部分应用情况下你并不需要过多考虑GIL。因为如果多线程计算成为性能瓶颈往往已经有Python库来解决这个问题了。
换句话说如果你的应用真的对性能有超级严格的要求比如100us就对你的应用有很大影响那我必须要说Python可能不是你的最优选择。
当然可以理解的是我们难以避免的有时候就是想临时给自己松松绑摆脱GIL比如在深度学习应用里大部分代码就都是Python的。在实际工作中如果我们想实现一个自定义的微分算子或者是一个特定硬件的加速器那我们就不得不把这些关键性能performance-critical代码在C++中实现不再受GIL所限然后再提供Python的调用接口。
总的来说你只需要重点记住绕过GIL的大致思路有这么两种就够了
1. 绕过CPython使用JPythonJava实现的Python解释器等别的实现
2. 把关键性能代码放到别的语言一般是C++)中实现。
## 总结
今天这节课我们先通过一个实际的例子了解了GIL对于应用的影响之后我们适度剖析了GIL的实现原理你不必深究一些原理的细节明白其主要机制和存在的隐患即可。
自然我也为你提供了绕过GIL的两种思路。不过还是那句话很多时候我们并不需要过多纠结GIL的影响。
## 思考题
最后,我给你留下两道思考题。
第一问在我们处理cpu-bound的任务文中第一个例子为什么有时候使用多线程会比单线程还要慢些
第二问你觉得GIL是一个好的设计吗事实上在Python 3之后确实有很多关于GIL改进甚至是取消的讨论你的看法是什么呢你在平常工作中有被GIL困扰过的场景吗
欢迎在留言区写下你的想法,也欢迎你把今天的内容分享给你的同事朋友,我们一起交流、一起进步。