Python多线程与GIL全局解释器锁的真相解析:为何被称为假多线程?
不过,最近有读者问:

Python的多线程是真是假?
突然我点击了Python的一个让人百感交集的特性——GIL。
这是怎么回事?今天我们来谈谈吧。
完美的
我们知道Python灵活而强大,因为它是一种解释型语言,可以同时解释和执行。实现此功能的标准实现称为 CPython。
它分两步运行Python程序:
灵活性是可以的,但是为了保证程序执行的稳定性,付出了巨大的代价:
引入全局解释器锁GIL(全局解释器锁)[2]
这样可以保证同一时间只有一个字节码在运行,这样就不会因为没有事先编译而导致资源争用和状态混乱的问题。
看似“完美”,但这样做意味着多线程执行时会被GIL变成单线程,硬件资源无法充分利用。
我们看一下代码:
import time
def gcd(pair):
'''
求解最大公约数
'''
a, b = pair
low = min(a, b)
for i in range(low, 0, -1):
if a % i == 0 and b % i == 0:
return i
assert False, "Not reachable"
# 待求解的数据
NUMBERS = [
(1963309, 2265973), (5948475, 2734765),
(1876435, 4765849), (7654637, 3458496),
(1823712, 1924928), (2387454, 5873948),
(1239876, 2987473), (3487248, 2098437),
(1963309, 2265973), (5948475, 2734765),
(1876435, 4765849), (7654637, 3458496),
(1823712, 1924928), (2387454, 5873948),
(1239876, 2987473), (3487248, 2098437),
(3498747, 4563758), (1298737, 2129874)
]
## 顺序求解
start = time.time()
results = list(map(gcd, NUMBERS))
end = time.time()
delta = end - start
print(f'顺序执行时间: {delta:.3f} 秒')
在笔者的电脑(4核,16G)上执行时间为2.043秒。
如何切换到多线程?
...
from concurrent.futures import ThreadPoolExecutor
...
## 多线程求解
start = time.time()
pool = ThreadPoolExecutor(max_workers=4)
results = list(pool.map(gcd, NUMBERS))
end = time.time()
delta = end - start
print(f'执行时间: {delta:.3f} 秒')
看一下运行效果:
顺序执行时间: 2.045 秒
并发执行时间: 2.070 秒
什么?
并行执行需要更长的时间!
如果连续执行多次,结果将是相同的,这意味着在GIL的限制下,多线程是无效的,并且由于线程调度而损失了更多的时间。
带着枷锁跳舞
Python中的多线程真的没用吗?
事实上,事实并非如此。虽然因为GIL而无法实现真正意义上的多线程,但是多线程机制仍然为我们提供了两个重要的特性。
1:多线程编写可以让某些程序更容易编写
怎么理解呢?
如果要解决一个需要同时维护多个状态的程序,用单线程来实现是非常困难的。
例如,如果要检索文本文件中的数据,为了提高检索效率,可以将文件分成小段进行处理。如果首先在该段中找到该文件,则处理将结束。
使用单个线程,很难同时考虑多个段。检索任务只能按顺序执行或使用二分法执行。
通过多线程,每个段都可以交给每个线程,依次执行,相当于同时推荐检索任务。处理时,相比顺序查找,效率会大大提高。
2:更高效地处理阻塞I/O任务
阻塞I/O是指当系统需要与文件系统交互时(包括网络和终端显示),由于文件系统处理速度比CPU慢很多,程序会被设置为阻塞状态,即,不不再分配计算资源。
直到文件系统的结果返回后才会被激活,有机会再次分配计算资源。
换句话说,处于阻塞状态的程序将永远等待。
所以如果一个程序需要不断地从文件系统读取数据,处理完后再写入,如果是单线程的话,需要等待读取再处理,等待处理完成再写入,这样处理过程就变成了逐个。的等待。
有了多线程,当一个处理进程被阻塞时,会立即被GIL切掉,将计算资源分配给其他可执行进程,从而提高执行效率。
有了这两个特性,就说明Python的多线程并不是一无是处。如果能根据情况来写,效率会大大提高。然而,对于计算密集型任务,多线程功能却无济于事。
曲线救国
那么有没有一种方法可以真正利用计算资源而不受GIL的束缚呢?
当然有,而且不止一个。
我们先介绍一个简单易用的方法。
回顾之前计算最大公约数的程序,我们使用了线程池来处理,但是并没有什么效果,比不使用还糟糕。
这是因为这个程序是计算密集型的,严重依赖CPU,并且明显受GIL限制。
现在我们稍微修改一下程序:
...
from concurrent.futures import ProcessPoolExecutor
...
## 并行程求解
start = time.time()
pool = ProcessPoolExecutor(max_workers=4)
results = list(pool.map(gcd, NUMBERS))
end = time.time()
delta = end - start
print(f'并行执行时间: {delta:.3f} 秒')
看看效果:
顺序执行时间: 2.018 秒
并发执行时间: 2.032 秒
并行执行时间: 0.789 秒
并行执行提升了近3倍!这是怎么回事?
仔细看的话可以发现,主要就是把多线程中的ThreadPoolExecutor替换成了ProcessPoolExecutor,也就是进程池执行器。
同一进程中的Python程序会受到GIL的限制,但不同进程中则不会,因为每个进程中的GIL是独立的。
是不是很神奇呢?在这里,感谢concurrent.futures模块,封装了实现进程池的复杂性,给我们留下了一个简单而优雅的界面。
这里需要注意的是,ProcessPoolExecutor并不是万能的。更适合数据相关性不高、计算密集的场景。
如果数据相关性很强,进程之间就会发生“通信”,这可能会抵消来之不易的性能提升。
还有哪些方法可以处理进程池呢?那是:
用C语言重写需要提高性能的部分
不要感到惊讶,Python 已经有了用于 C 扩展的 API。
但这样做需要付出更多的成本。为此,您还可以使用 SWIG[3] 和 CLIF[4] 等工具将 python 代码转换为 C 代码。
有兴趣的读者可以研究一下。
不断自我完善
了解了Python多线程的问题和解决方案后,对于我们这些热爱Python的人来说,我们该何去何从呢?
有一句话用在这里非常合适:
求自己不如求别人
无论工具或武器多么强大,都不能解决所有问题,而问题之所以能够解决,主要是因为我们的主观能动性。
这不就是我们需要分析判断情况,选择合适的解决方案吗?
对于Python中多线程的批评,我们大多看到它阳光明媚的一面,在需要提高速度的地方采取适当的方法。以下是一个简短的总结:
对于I/O密集型任务,使用Python的多线程完全没有用处,并且可以大大提高执行效率。对于计算密集型任务,就看数据依赖性是否低。如果是的话,使用ProcessPoolExecutor代替多线程完全可以。如果使用硬件资源时数据依赖性较高,可以考虑使用C来实现关键区域。一方面,C 本身比 Python 更快。另一方面,C 可以使用较低级别的多线程机制,而完全不用担心。受制于GIL 大多数情况下,对于只能通过多线程处理的任务,不需要考虑太多。只需利用Python的多线程机制即可。你不需要想太多。概括
没有完美的解决方案。即使有,也只能是在某些特定条件下。就像软件工程中没有灵丹妙药一样。
面对现实的世界,我们能依靠的只有我们自己。我们通过学习了解更多,通过实践感受更多,通过总结和回顾收获更多,通过思考和反思解决更多。这是我们人类不断发展的动力。