不过最近有位读者发问:
Python 的多线程真是假的吗?
一下子点到了 Python 长期被人们喜忧参半的个性 —— GIL 上了。
到底是怎么回事呢?明天咱们来聊一聊。
美中不足
咱们晓得 Python 之所以灵便和弱小,是因为它是一个解释性语言,边解释边执行,实现这种个性的规范实现叫作 CPython。
它分两步来运行 Python 程序:
- 首先解析源代码文本,并将其编译为字节码(bytecode)[1]
- 而后采纳基于栈的解释器来运行字节码
- 一直循环这个过程,直到程序完结或者被终止
灵活性有了,然而为了保障程序执行的稳定性,也付出了微小的代价:
引入了 全局解释器锁 GIL(global interpreter lock)[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} 秒')
- 函数
gcd
用于求解最大公约数,用来模仿一个数据操作 NUMBERS
为待求解的数据- 求解形式利用
map
办法,传入处理函数 gcd, 和待求解数据,将返回一个后果数列,最初转化为list
- 将执行过程的耗时计算并打印进去
在笔者的电脑上(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} 秒')
- 这里引入了
concurrent.futures
模块中的线程池,用线程池实现起来比拟不便 - 设置线程池为 4,次要是为了和 CPU 的核数匹配
- 线程池
pool
提供了多线程版的map
,所以参数不变
看看运行成果:
程序执行工夫: 2.045 秒
并发执行工夫: 2.070 秒
what?
并行执行的工夫居然更长了!
间断执行屡次,后果都是一样的,也就是说在 GIL 的限度下,多线程是有效的,而且因为线程调度还多损耗了些工夫。
戴着镣铐跳舞
难道 Python 里的多线程真的没用吗?
其实也并不是,尽管了因为 GIL,无奈实现真正意义上的多线程,但,多线程机制,还是为咱们提供了两个重要的个性。
一:多线程写法能够让某些程序更好写
怎么了解呢?
如果要解决一个须要同时保护多种状态的程序,用单线程是实现是很艰难的。
比方要检索一个文本文件中的数据,为了进步检索效率,能够将文件分成小段的来解决,最先在那段中找到了,就完结处理过程。
用单线程的话,很难实现同时兼顾多个分段的状况,只能程序,或者用二分法执行检索工作。
而采纳多线程,能够将每个分段交给每个线程,会轮流执行,相当于同时举荐检索工作,解决起来,效率会比程序查找大大提高。
二:解决阻塞型 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 的多线程机制就好了,不必思考太多
总结
没用美中不足的解决方案,如果有,也只能是在某个具体的条件之下,就像软件工程中,没用银弹一样。
面对实在的世界,只有咱们本人是能够依附的,咱们通过学习理解更多,通过实际,感触更多,通过总结复盘,播种更多,通过思考反思,解决更多。这就是咱们人类一直倒退前行的原动力。
为了咱们美妙的今天,为了人类美妙的今天,加油!
以上就是本次分享的所有内容,如果你感觉文章还不错,欢送关注公众号:Python 编程学习圈,每日干货分享,发送“J”还可支付大量学习材料。或是返回编程学习网,理解更多编程技术常识。