
讲个真实的事。
去年我接到一个任务:优化一个数据处理程序。输入是100万个JSON文件,需要对每个文件做解析、特征提取,然后汇总结果。单核跑要40多分钟,太慢了。
我的电脑是8核的,心想:这不刚好用多线程吗?Python的ThreadPoolExecutor用起来多顺手,几行代码搞定。
于是我把代码改成了这样:
with ThreadPoolExecutor(max_workers=8) as executor:
results = executor.map(process_file, files)开开心心跑起来,看了眼CPU占用率——**只有25%**。
8个核心,只用了2个。跑完一看时间,35分钟。就快了5分钟?跟预想的8倍加速差了十万八千里。
我一度以为是代码写错了,查了半天也没发现bug。后来才知道,是GIL这个拦路虎在搞鬼。如果你也遇到过「多线程反而跑不动」的怪事,这篇就是为你写的。
GIL全称叫全局解释器锁,是CPython(就是咱们平常用的那个Python)的一个机制。
用大白话说:GIL就是一扇门,任何时候只有一个线程能通过它去执行Python代码。
不管你的CPU有多少个核,GIL保证同一时刻只有一个线程在跑Python代码。其他线程就算准备好了,也得等在门外。
这就有意思了:你开了8个线程,但同一时刻只有1个在工作。这就是为什么我的CPU占用只有25%(8核里只有1个满负荷,加上调度开销,整体大概25%)。
GIL不是Python的设计缺陷,而是一个历史选择。Python刚诞生的时候,电脑还是单核的,压根没人考虑多核并发的问题。GIL让内存管理变得简单——不用搞复杂的锁机制来保护对象,垃圾回收也好做。这个设计让CPython的实现特别轻量,也就一路走到了今天。等大家开始用多核CPU的时候,GIL已经根深蒂固了,去掉它会导致大量现有的C扩展库崩溃。
理论归理论,咱们动手验证一下。
先写一个计算密集型的任务——算斐波那契数列,专门消耗CPU:
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def fib(n):
if n <= 2:
return 1
return fib(n-1) + fib(n-2)
def cpu_bound_task(n=35):
return fib(n)分别用单线程、多线程、多进程跑,看效果:
# 单线程
start = time.time()
for _ in range(8):
cpu_bound_task(35)
print(f"单线程: {time.time() - start:.2f}s")
# 多线程(8个线程)
start = time.time()
with ThreadPoolExecutor(max_workers=8) as executor:
list(executor.map(cpu_bound_task, [35]*8))
print(f"多线程: {time.time() - start:.2f}s")
# 多进程(8个进程)
start = time.time()
with ProcessPoolExecutor(max_workers=8) as executor:
list(executor.map(cpu_bound_task, [35]*8))
print(f"多进程: {time.time() - start:.2f}s")结果会让你大跌眼镜:
单线程: 4.82s
多线程: 5.01s
多进程: 1.06s看到了吗?多线程比单线程还慢! 多出的开销来自线程切换和GIL的争抢。而多进程直接飙到了接近8倍的加速。
这组数据说明一件事:对于计算密集型任务,Python的多线程是白费力气。
但如果任务是IO密集型的——比如网络请求、文件读写、数据库查询——结果又不同了。
再做个实验:
import time
import requests
from concurrent.futures import ThreadPoolExecutor
def io_bound_task(url):
response = requests.get(url)
return response.status_code
urls = ["https://www.baidu.com"] * 20
# 单线程
start = time.time()
for url in urls:
io_bound_task(url)
print(f"单线程: {time.time() - start:.2f}s")
# 多线程
start = time.time()
with ThreadPoolExecutor(max_workers=10) as executor:
list(executor.map(io_bound_task, urls))
print(f"多线程: {time.time() - start:.2f}s")结果:
单线程: 4.53s
多线程: 0.61s多线程快了7倍多!
为什么同样是多线程,一个完全没用,一个快了好几倍?
关键区别在于:IO操作会主动释放GIL。
当线程在做网络请求、读写磁盘、sleep等操作时,底层会暂时释放GIL,让其他线程有机会执行。所以在IO密集型场景下,GIL形同虚设,多线程能真正并行工作。
而计算任务不会主动释放GIL,线程一直占着锁,其他线程只能干等着。
你可能好奇:GIL到底锁的是什么东西?是CPU吗?是线程吗?
都不是。GIL锁的是Python解释器本身。
当你执行a = 1 + 1这行代码时,Python解释器要做的事情:
这一系列操作,必须持有GIL才能进行。没有GIL,别的线程就不能执行任何Python代码。
这意味着什么?意味着无论你开多少线程,Python代码的执行速度不会因为核数增加而变快。计算总量是固定的,GIL只是让这些计算只能在一个核上排队执行。
你可能要问:既然GIL这么坑,Python为什么不把它去掉?
这是个好问题。实际上,Python社区讨论这个问题讨论了十几年。但GIL是CPython的根基,牵一发动全身。
去掉GIL会带来三个大麻烦:
1. 现有的C扩展库全崩。 像NumPy、Pandas、科学计算库这些,很多是用C写的,都依赖于GIL来保证内存安全。去掉GIL,这些库要重写,工作量以百万行计。
2. 单线程性能下降。 去掉GIL后,为了处理多核并发,要在每一个对象上加细粒度的锁,导致单线程程序变慢(据说会慢30%-50%)。Python的生态里有大量单线程程序,为了少数人的多核需求牺牲多数人的性能,社区不会同意。
3. 实现复杂度爆炸。 CPython的内存管理目前依赖GIL来保证引用计数的线程安全。去掉它,垃圾回收的复杂度会指数级上升,bug也会成倍增加。
所以不要指望GIL被去掉。Python官方明确表示过:GIL暂时不会消失。与其等GIL被移除,不如学会与它共存。
既然GIL绕不开,怎么写出真正利用多核的程序?
多进程是最直接的解决方案。每个进程有自己独立的Python解释器,自然也就有自己的GIL。多进程可以真正利用多核。
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=8) as executor:
results = executor.map(process_file, files)但多进程也有代价:进程间数据不共享,需要序列化传递,开销比线程大。而且创建进程比创建线程慢得多。
如果你用的是NumPy、Pandas这些库,它们内部是C/C++写的,GIL在这些计算过程中会被释放。你只管用就行,多线程调用NumPy运算,GIL基本不碍事。
import numpy as np
import threading
# NumPy的多线程运算是有效的
# 因为计算发生在C层,GIL被释放了
def compute():
a = np.random.rand(1000, 1000)
b = np.random.rand(1000, 1000)
return np.dot(a, b)Jython(Python的Java实现)和IronPython(Python的.NET实现)没有GIL,但它们不能运行C扩展库。PyPy也有GIL,但性能通常比CPython好。不太主流的项目,生产环境用的人极少。
给你一张速查表,照着选就行:
场景 | CPU密集型 | IO密集型 | 混合型 |
|---|---|---|---|
计算为主 | 多进程 | 多线程 | 多进程+异步IO |
频繁读写 | 多进程 | 多线程 | 多线程 |
大量数据共享 | 多线程(但有GIL限制) | 多线程 | 多进程+共享内存 |
再精简一点:
还有一种情况容易被忽视:函数里既有IO又有计算。
比如读取一个大文件,然后做复杂解析。读取部分是IO操作,会释放GIL;解析部分是CPU操作,又会被GIL限制住。这种情况下,多线程的收益只体现在IO部分,计算部分还是单核跑。
要解决这个问题,可以把IO和计算拆分到不同的阶段,IO部分用多线程批量读,计算部分用多进程并行处理。有点像是把流水线上的瓶颈工序单独拉出来加速。
那次优化任务,我最后用了多进程方案。但Python的multiprocessing模块序列化开销也不小,100万个文件切分成多个批次,每个批次由一个进程处理,整体时间缩短到了8分钟。虽然没达到8倍理论加速,但也够用了。
回过头看那个「多线程完全没加速」的坑,其实是自己对GIL不够了解。多线程这东西,用对了地方是神兵利器,用错了地方是南辕北辙。
最后送你三句话,记在心里:
知道了这些,再写并发代码的时候,心里就有底了。至少不会像我当初那样,傻傻地以为8个线程就能跑满8个核。
下次有人跟你说Python多线程没用,你可以告诉他:看场景。写写爬虫、做做Web请求,Python的多线程可香了。但要是跑计算,确实是没用。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。