Python的GIL把我坑惨了,原来多线程和多进程的区别这么大
一个让我怀疑人生的性能优化
前年接了一个图像批处理项目。每天有上万张商品图片需要做缩放、水印和格式转换。我心想:“这还不简单?多线程并行处理,分分钟搞定。”
代码写得行云流水:
import threading from PIL import Image def process_image(image_path): img = Image.open(image_path) img = img.resize((800, 800)) # 加水印、转格式... img.save(f"processed_{image_path}") images = get_all_images() # 一万张图 threads = [] for path in images: t = threading.Thread(target=process_image, args=(path,)) t.start() threads.append(t) for t in threads: t.join()开了20个线程,信心满满地跑起来。结果一看CPU监控——8核的服务器,CPU使用率只有25%左右,跟单线程跑没啥区别。处理速度也没快多少,一万张图还是跑了好几个小时。
同事路过看了一眼:“你用多线程跑CPU密集型任务?不知道GIL吗?”
我:“GIL是什么?”
那天下午,我把GIL、多线程和多进程的区别彻底研究了一遍。今天把这些东西讲清楚,希望你下次遇到类似问题别像我一样白忙活。
第一步:GIL到底是什么?
GIL的全称是Global Interpreter Lock,全局解释器锁。它是CPython解释器(就是咱们平时用的那个Python)里的一个互斥锁机制。
它的作用简单粗暴:同一时刻,只有一个线程能执行Python字节码。
也就是说,即使你的电脑有8核16核,跑Python多线程程序的时候,同一时间只有一个核心在工作,其他核心都在旁边看着。
听到这儿你可能想骂人了:“那Python的多线程不就是个摆设吗?”
别急,事情没那么简单。
第二步:GIL为什么存在?
GIL不是Python设计者脑子进水搞出来的东西。恰恰相反,它是一个有意为之的设计决策。
Python的内存管理用的是引用计数——每个Python对象都有一个计数器,记录有多少个地方引用了它。当计数器归零时,对象就被回收了。
问题来了:在多线程环境下,如果两个线程同时修改同一个对象的引用计数,就会出现竞争条件——计数可能只增加了一次而不是两次,导致对象永远无法被回收,造成内存泄漏。
解决这个问题有两种方案:
给每个对象加锁——细粒度锁,性能好但实现极其复杂,容易死锁
给整个解释器加一把大锁——简单粗暴,但限制了并行
Python选择了方案二。GIL就像是一个门卫,牢牢掌控着Python字节码的执行权,确保同一时刻只有一个线程能进门。
在单核CPU时代,这个设计完全没问题——反正同一时间本来就只有一个线程能用CPU。但多核普及之后,GIL就成了Python多线程的“紧箍咒”。
第三步:多线程到底有没有用?
有用,但要看场景。
GIL只限制Python字节码的执行。当线程执行I/O操作(比如网络请求、文件读写、数据库查询)时,它会主动释放GIL。其他等待的线程就可以趁机获取GIL并执行。
这就好比一个食堂只有一个打饭窗口(GIL)。如果你要做的只是“站在窗口前等饭”(I/O等待),那多几个人排队也没问题——反正大家都在等,谁先谁后差别不大。但如果你要做的是一人霸占窗口做复杂操作(CPU计算),那其他人就只能干等着。
I/O密集型任务(网络爬虫、文件读写、数据库操作):多线程很有效
线程大部分时间在等待外部响应,不占用CPU
等待时释放GIL,其他线程可以运行
实测:20个网络请求,4线程比单线程快约4倍
CPU密集型任务(图像处理、数值计算、加密解密):多线程基本没用
线程一直在执行计算,不释放GIL
多个线程轮流抢一把锁,加上切换开销,可能比单线程还慢
实测:计算质数,4线程比单线程还慢0.5秒
有测试数据显示:在4核CPU上跑CPU密集型任务,单线程耗时10.2秒,4线程耗时10.0秒——几乎没有提升。多出来的线程除了抢锁和切换上下文,什么都没干。
第四步:多进程——绕过GIL的真正并行
既然多线程被GIL卡死了,那怎么办?
答案是多进程。
每个Python进程都有自己独立的解释器和独立的内存空间。每个进程都有自己的GIL,互不干扰。所以在多核CPU上,多个进程可以真正做到同时运行——一个进程跑在一个核心上。
用多进程改造一下我的图像处理代码:
from multiprocessing import Pool def process_image(image_path): # 同样的处理逻辑 pass if __name__ == "__main__": images = get_all_images() with Pool(8) as p: # 8核CPU开8个进程 p.map(process_image, images)改完之后,CPU使用率直接飙到95%以上,处理时间从几个小时缩短到了几十分钟。
实测数据更能说明问题:在4核CPU上计算100万以内质数,单线程3.2秒,4线程反而要6.1秒(线程切换开销+GIL竞争),但4进程只用了1.8秒,接近4倍加速。另一个测试显示,4进程方案在4核CPU上能达到3.66倍的加速比。
多进程的好处:
真正利用多核CPU
没有GIL干扰
每个进程内存隔离,不会相互影响
多进程的代价:
进程创建开销大(约100微秒到1毫秒,线程只需1-5微秒)
进程间通信需要序列化(pickle),比较麻烦
内存占用大——100个线程增加约15MB内存,100个进程增加约800MB
数据共享不如线程方便
第五步:到底该选哪个?
一张图说清楚:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 网络爬虫、API调用 | 多线程 | I/O等待时释放GIL,轻量高效 |
| 文件读写、数据库操作 | 多线程 | 同上 |
| 图像/视频处理 | 多进程 | CPU密集型,需要真并行 |
| 数值计算、机器学习 | 多进程 | 充分利用多核 |
| 加密解密、压缩解压 | 多进程 | CPU密集型 |
有个真实的案例:某图像处理项目,用多进程处理1080P视频转码,速度从单线程的45分钟缩短到了12分钟。还有个爬虫项目,用多线程后日抓取量从10万页提升到了80万页。
简单粗暴的判断标准:如果代码大部分时间在等(等网络、等磁盘、等数据库),用多线程;如果代码大部分时间在算(循环、计算、处理),用多进程。
第六步:还有几个坑
坑一:别在Windows上瞎用多进程
Windows创建进程的开销比Linux大得多,而且multiprocessing在Windows上需要用if __name__ == "__main__"保护,不然会无限递归创建进程。
坑二:进程间传数据要序列化
多线程共享内存,传递数据直接传引用就行。多进程不行——每个进程内存独立,传递数据需要pickle序列化。如果数据太大,序列化本身就很耗时。
坑三:不是所有第三方库都释放GIL
有些C扩展库(比如某些旧版本的加密库)在执行时不释放GIL,即使在做I/O操作也不放。这就导致多线程在这些库上完全没用。用之前最好确认一下。
坑四:进程数不是越多越好
进程数一般等于CPU核心数就够了。开太多进程,上下文切换的开销反而会拖慢速度。
顺便说一句:GIL快没了
Python 3.13已经推出了实验性的自由线程(Free-Threading)模式,可以禁用GIL。PEP 703正式提出了让GIL变成可选项的方案。
不过目前还是实验阶段,性能差距还有待优化(早期版本自由线程比非自由线程慢约40%,现在已经降到10%以内了)。而且就算GIL最终被移除,多线程和多进程的适用场景也不会完全重叠——内存模型、通信方式这些本质区别依然存在。
所以,理解GIL、多线程和多进程的区别,在今天依然很有必要。
总结
一句话记住区别:多线程是“一个人干多个活,但一次只能干一个”;多进程是“多个人各干各的,互不干扰”。
用我那个图像处理的例子来总结:
多线程:一个人同时操作8台机器,但每次只能操作一台——看起来忙,实际上效率没提高
多进程:8个人各操作一台机器——真正的同时干活,效率翻倍
现在我写代码但凡涉及到并发,第一反应就是先判断:“这任务是等还是算?”等就用线程,算就用进程。想清楚再动手,少让CPU闲着。
