什么是Python全局解释器锁

什么是 Python 全局解释器锁

简单来说,Python 全局解释器锁–GIL是一个互斥锁,在某个时刻只允许一个线程控制Python解释器。

这意味着任何时间点只有一个线程可以出于执行状态,执行单线程程序的开发人员可能看不到 GIL 的影响,但它可能是多线程程序和 CPU 密集型程序中的性能瓶颈。

因为即使在具有多核心,多线程的 CPU 架构中,GIL 也只允许一个线程同时运行,所以这也成为 Python 最臭名昭著的特点了。

GIL 存在的意义

Python 使用引用计数进行内存管理,这意味着在 Python 中创建的对象具有引用计数变量,该变量用于跟踪指向该对象的引用数。当此计数达到零时,释放对象占用的内存。

让我们看一个简单的例子来演示引用计数如何工作:

1
2
3
4
import sys
a = []
b = a
sys.getrefcount(a)

上面的例子中,空列表对象 [] 的引用计数是3, 这个列表对象被 a, b 以及被传入 sys.getrefcount() 的参数引用。

回到 GIL:

问题是这个引用计数变量需要保护竞态条件,其中两个线程同时增加或减少其值。如果发生这种情况,可能导致未释放的内存泄漏,或者更糟糕的是在该对象任然存在时错误地释放内存。这也会导致Python程序奔溃或者其他怪异的错误。

通过向跨线程共享的所有数据结构添加锁,可以保持此引用计数变量的安全性,从而不会对它们进行不一致的修改。

但是为每个对象或对象组添加一个锁意味着将存在多个锁,这可能导致另一个问题 - 死锁(死锁只有在有多个锁时才会发生)。另一个副作用是由于重复获取和释放锁而导致性能下降。

GIL是解释器本身的单个锁,它增加了一条规则,即执行任何 Python 字节码都需要获取解释器锁。这可以防止死锁(因为只有一个锁)并且不会引入太多的性能开销。但它有效地使任何受 CPU 限制的 Python 程序都是单线程的。

GIL虽然被解释器用于其他语言(如Ruby),但并不是解决此问题的唯一方法。有些语言通过使用除引用计数之外的方法(例如垃圾收集)来避免GIL对线程安全内存管理的要求。

另一方面,这意味着这些语言通常需要通过添加其他性能提升功能(如JIT编译器)来弥补GIL单线程性能优势的损失。

为什么 Python 使用 GIL 作为解决方案

按照 Larry Hastings 的话来说,GIL 锁的选择是 Python 在今天变得流行的一个重要原因。

在操作系统没有线程概念的时候Python就已经存在,Python 的设计易于使用,以便更快地开发,因而有越来越多的开发人员使用它。

有很多 C 扩展正在添加到 Python 中,为 Python 提供功能支持。为了防止不一致的更改,这些 C 扩展需要 GIL 提供安全的内存管理。

GIL 易于实现,很容易添加到 Python 中,它为单线程程序提供了性能提升,因为它只需要管理一个锁。

非线程安全的C库变得很容易集成,这些 C 扩展成为不同社区用 Python 的原因之一。

正如你所看到的,GIL 是一个很实用的解决方案,可以解决 CPython 开发人员在 Python 生命中早起面临的一个难题。

GIL 对多线程 Python 程序的影响

当你查看典型的Python程序或者任何计算机程序时,性能上,CPU 密集型程序和 IO 密集型程序之间是有所不同的。

CPU 密集型程序是那些将 CPU 推向极限的程序,这包括进行数学计算的程序,如矩阵乘法,搜索,图像处理等。

IO 密集型程序是将时间花费在等待 I/O 的程序,它可以来自用户,数据库,文件以及网络等。IO 密集型程序有时需要花费很长时间才能完成从源获取他们需要的数据,因为源可能要在输入输出准备好之前进行自己的处理,例如一个用户可能需要思考根据提示输入什么或者一个数据库正在进程中查询数据。

我们来演示一个 CPU 密集型程序,它递减一个数字直到0:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import os
import time

count = 50000000

def countdown(n):

while n > 0:
n -= 1

start = time.time()
countdown(count)
end = time.time()

print(f'CPU: {os.cpu_count()}, Time taken in seconds: {end - start}')
▶ python3 single_thread.py
CPU: 8, Time taken in seconds: 3.316988229751587

现在我们来稍微改变一下程序,使用两个线程并行操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import os
import time
from threading import Thread

count = 50000000

def countdown(n):

while n > 0:
n -= 1

start = time.time()

t1 = Thread(target=countdown, args=(count // 2, ))
t2 = Thread(target=countdown, args=(count // 2, ))
t1.start()
t2.start()
t1.join()
t2.join()

end = time.time()

print(f'CPU: {os.cpu_count()}, Time taken in seconds: {end - start}')
▶ python3 multi_thread.py
CPU: 8, Time taken in seconds: 3.3987009525299072

真如你所看到的,两个版本的时间几乎相同,多线程版本中,GIL 阻止了 CPU 密集型程序并行执行。但是 GIL 并不会对 IO 密集型程序有太多影响,因为线程在等待 IO 时共享锁。但是完全的 CPU 密集型多线程程序,不仅会因为锁而成为单线程,而且也会看到执行时间的增加,这种增加是由于锁获取和释放的开销导致。

为什么 GIL 到如今也没有被移除?

Python 的开发人员对此有很大的抱怨,但是像 Python 这样流行的语言不能因删除 GIL,而带来巨大变化,而导致向后不兼容。虽然可以删除 GIL,过去的开发人员和研究人员做过很多的尝试,但所有的尝试都破坏了现有的C扩展,这些扩展很大程度上依赖于 GIL 提供的解决方案。

当然也有其他的解决方案,但是有些解决方案会降低单线程和多线程IO密集型程序的性能。

为什么 Python3 中没有移除 GIL?

Python3 确实有机会从头开始并在此过程中开始许多新功能,打破了一些现有的C扩展,然后需要更新并移植以使用 Python3.这就是为什么早期版本的 Python3 看到社区采用较慢。

但为什么并没有被删除?

删除 GIL 会使 Python3 在单线程性能方面比 Python2 慢,你可以想象会有社么后果,你不能争论 Python3 的单线程性能优势,因此结果是 Python3 仍然具有 GIL。

但是 Python3 确实为现有的 GIL 带来了重大改进。

我们之前仅仅讨论了 GIL 对仅 CPU密集型成需和仅 IO 密集型程序的影响,但是对于有些程序,一些线程是 IO密集型的,一些是 CPU 密集型的,这又会怎么样呢?在这样的程序中,IO 密集型线程无法得到 Python 解释器的宠幸,因为它没有机会从 CPU 密集型的线程中抢到 GIL锁。这是因为 Python 内置了一种机制,强制 Python 在连续使用固定的间隔后释放 GIL,并且如果没有其它的线程获得 GIL,那么相同的线程可以恶意继续使用它。

1
2
3
import sys
# The interval is set to 100 instructions:
sys.getcheckinterval() # python 3.2 之前是按照执行指令书进行线程切换

这种机制的问题在于,大多数情况下,CPU 密集型程序会在其他线程获取 GIL 之前重新获取 GIL,这是由 David Beazley 研究的,你可以在这里 看到研究结果。

问题在 Python3.2 中被修复了,Antoine Pitrou 添加了一种机制 来查看其他线程获取 GIL 的请求数,在其他线程有机会运行之前不允许当前线程获取。

如何处理 Python 的 GIL

如果 GIL 造成了性能问题,这里有集中机制可以尝试一下:

多进程 vs 多线程:最流行的方式是使用多进程而不是多线程,每个进程会有自己的解释器和内存空间,因此 GIL 不会造成问题,Python 有一个多进程处理模块,可以轻松创建多进程程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import os
import time
from multiprocessing import Pool

count = 50000000

def countdown(n):

while n > 0:
n -= 1

if __name__ == "__main__":
pool = Pool(processes=2)
start = time.time()
r1 = pool.apply_async(countdown, [count // 2])
r2 = pool.apply_async(countdown, [count // 2])
pool.close()
pool.join()
end = time.time()
print(f'CPU: {os.cpu_count()} Time taken in seconds: {end - start}')
▶ python3 multi_process.py
CPU: 8 Time taken in seconds: 1.9496397972106934

与多线程程序相比,性能确实有了很不错的提升,但是性能并没有提高 50%,是因为进程切换有自己的开销,多进程是比多线程更重的,这有可能会成为新的扩展瓶颈。

可选的 Python 解释器:由于 GIL 仅存在 CPython 的原始实现中,你可以选择其他的 Python 解释器实现,JPython,IronPython,PyPy等。

再等等吧,期待未来有大神解决。

参考原文

  1. What is the Python Global Interpreter Lock (GIL)?
  2. python之GIL官方文档 global interpreter lock 全局解释器锁