Python이 유연하고 강력한 이유는 해석하는 동안 실행되는 인터프리터 언어이기 때문이며 이 기능을 구현하는 표준 구현을 CPython이라고 합니다.
두 단계로 Python 프로그램을 실행합니다.
- 먼저 소스 코드 텍스트를 구문 분석하고 바이트 코드로 컴파일하십시오.
- 그런 다음 스택 기반 인터프리터를 사용하여 바이트 코드를 실행합니다.
- 프로그램이 종료되거나 종료될 때까지 이 프로세스를 계속 반복합니다.
유연성이 있지만 프로그램 실행의 안정성을 보장하기 위해 엄청난 대가를 치르기도 합니다.
글로벌 인터프리터 잠금 GIL(글로벌 인터프리터 잠금) 도입
동시에 하나의 바이트 코드만 실행되도록 하기 위해 불필요한 사전 컴파일로 인한 리소스 경합 및 상태 혼동을 일으키지 않습니다.
"완벽한" 것처럼 보이지만 이렇게 하면 다중 스레드 실행이 수행될 때 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와 풀이할 데이터를 전달하고 결과 시퀀스를 반환하고 최종적으로 목록으로 변환됩니다.
- 시간이 많이 걸리는 프로세스 실행을 계산하고 인쇄합니다.
내 컴퓨터(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 모듈의 스레드 풀을 소개합니다. 이는 스레드 풀로 구현하는 것이 더 편리합니다.
- 주로 CPU 코어 수와 일치하도록 스레드 풀을 4로 설정합니다.
- 스레드 풀 풀은 맵의 다중 스레드 버전을 제공하므로 매개변수가 변경되지 않은 상태로 유지됩니다.
실행 효과를 살펴보십시오.
순차 실행 시간: 2.045초
동시 실행 시간: 2.070초
병렬 실행은 더 오래 걸립니다 !
연속해서 여러 번 실행해도 결과는 같다. 즉, GIL의 한계하에서 멀티스레딩은 무효가 되고, 쓰레드 스케줄링으로 인해 더 많은 시간이 손실된다.
족쇄에서 춤
파이썬의 멀티스레딩은 정말 쓸모가 없나요?
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 以及 CLIF等工具,将 python 代码转为 C。
有兴趣的读者可以研究一下。
自强不息
了解到 Python 多线程的问题和解决方案,对于钟爱 Python 的我们,何去何从呢?
有句话用在这里很合适:
求人不如求己
哪怕再怎么厉害的工具或者武器,都无法解决所有的问题,而问题之所以能被解决,主要是因为我们的主观能动性。
对情况进行分析判断,选择合适的解决方案,不就是需要我们做的么?
对于 Python 中 多线程的诟病,我们更多的是看到它阳光和美的一面,而对于需要提升速度的地方,采取合适的方式。这里简单总结一下:
- I/O 密集型的任务,采用 Python 的多线程完全没用问题,可以大幅度提高执行效率
- 对于计算密集型任务,要看数据依赖性是否低,如果低,采用 ProcessPoolExecutor 代替多线程处理,可以充分利用硬件资源
- 如果数据依赖性高,可以考虑将关键的地方该用 C 来实现,一方面 C 本身比 Python 更快,另一方面,C 可以之间使用更底层的多线程机制,而完全不用担心受 GIL 的影响
- 大部分情况下,对于只能用多线程处理的任务,不用太多考虑,之间利用 Python 的多线程机制就好了,不用考虑太多
总结
没用十全十美的解决方案,如果有,也只能是在某个具体的条件之下,就像软件工程中,没用银弹一样。
面对真实的世界,只有我们自己是可以依靠的,我们通过学习了解更多,通过实践,感受更多,通过总结复盘,收获更多,通过思考反思,解决更多。这就是我们人类不断发展前行的原动力。
为了我们美好的明天,为了人类美好的明天,加油!
以上就是本次分享的所有内容,想要了解更多 python 知识欢迎前往公众号:Python 编程学习圈,每日干货分享