Python的GIL把我坑惨了,原来多线程和多进程的区别这么大

📅 2026/7/22 15:48:25 👁️ 阅读次数 📝 编程学习
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对象都有一个计数器,记录有多少个地方引用了它。当计数器归零时,对象就被回收了。

问题来了:在多线程环境下,如果两个线程同时修改同一个对象的引用计数,就会出现竞争条件——计数可能只增加了一次而不是两次,导致对象永远无法被回收,造成内存泄漏

解决这个问题有两种方案:

  1. 给每个对象加锁——细粒度锁,性能好但实现极其复杂,容易死锁

  2. 给整个解释器加一把大锁——简单粗暴,但限制了并行

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闲着。