先说个技术群里经常出现的画面:有人贴出一段Python多线程代码,开了8个线程做纯计算,然后发截图问“为什么CPU占用率只有12%,其他核都在睡觉?是不是GIL把这程序废了?”底下评论立刻分成两派,一派说“Python多线程就是个笑话”,另一派说“你们根本不会用多线程”。作为两种方案都长期用过的人,我一直觉得这个争论有点跑偏——GIL确实是Python多线程绕不开的限制,但它锁死的场景没有网上传的那么宽。选多线程还是多进程,本质不是态度问题,也不是“哪个更高级”的问题,而是任务类型问题。这篇文章就把GIL的机制、多线程和多进程各自适用的场景、以及我自己的选型判断路径一次说清楚,适合刚接触并发编程的初学者,也适合写过并发但总在调优时折返的老手。
1. 先给结论:别再被GIL吓倒,选型只看任务类型
1.1 30秒速查表
很多文章聊到GIL就收不住了,从CPython源码讲到操作系统调度,读者看完依然不知道明天写代码时该用哪个。我这里直接给一张速查表,你先拿去用,后面再细讲原理。
| 任务类型 | 推荐方案 | 核心原因 |
|---|---|---|
| CPU密集(大量纯Python计算、循环、递归) | 多进程(ProcessPoolExecutor或multiprocessing) | 每个进程独立解释器、各持一把GIL,真正利用多核 |
| I/O密集(网络请求、文件读写、数据库查询) | 多线程(ThreadPoolExecutor或threading) | 等待I/O时线程会释放GIL,线程开销远小于进程 |
| 计算发生在C扩展内部(numpy、pandas、opencv) | 多线程(甚至更优) | 底层C库在运行时会显式释放GIL,多线程可并行 |
| 混合型(计算+I/O同时存在) | 按阶段拆分 | 计算段用多进程,I/O段用多线程,或者用进程内再开线程池的混合模型 |
这张表看起来很简单,但它是我在实际项目中反复验证过的结论。接下来要解决的唯一问题是:为什么结论偏偏是这样?这就要从GIL这个“历史包袱”说起。
1.2 简单说,“多线程没用”是一种误读
我见过不少从Java转过来的开发,第一反应是:Java里线程能跑满多核,怎么Python就多线程就不行了?然后得出“Python并发很弱”的结论。这个结论错就错在范围感。GIL限制的是“同一个进程内、多个线程同时执行Python字节码”,它没有限制你开多个进程,也没有限制你在等待外部资源时让线程切换。
换个生活化的说法:多线程就像一间厨房里只有一个灶台,你可以安排十个帮厨,但同一时刻只有一个帮厨能站在灶台前做菜,其他帮厨等得再急也插不上手。多进程就好比你直接多租了几间厨房,每间厨房都有自己的灶台,几个厨师能同时开工。但代价是——多租厨房要花钱,多起进程要耗内存,厨房之间递菜还要经过一个传菜口(进程间通信)。
这个类比能解释绝大多数问题:为什么CPU密集任务用多线程没用?因为只有一个灶台,帮厨再多也轮不上炒菜,反而站在那里挤来挤去。为什么网络请求用多线程有用?因为帮厨把菜炖上之后干等着(等网络响应的这段时间),灶台就空出来了,下一个帮厨可以上去起火开炒。为什么numpy用多线程有时更快?因为numpy的C扩展在炒菜时主动把灶台让开,让其他人也能用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. GIL到底锁住了什么:理解它才能理解整个选择
2.1 锁住的是“Python字节码的执行权”,不是全局死锁
GIL全称Global Interpreter Lock,很多人误以为它会把整个程序锁死,或者认为Python程序一次只能做一件事。准确地说,GIL是CPython解释器里的一个互斥锁,它保证同一个进程内,任意时刻只有一个线程在执行Python字节码。这个“字节码”指的是解释器执行的那层指令,比如 LOAD_FAST、BINARY_ADD 这些。
那Python为什么要给自己套这么一层枷锁?核心原因在内存管理。CPython对对象采用引用计数:每个对象内部有一个计数器,记录有多少个变量引用它,计数归零就立即回收内存。如果两个线程同时去修改同一个对象的引用计数,计数的加减不是原子的,就可能导致一个对象的计数器错过一次减一,永远不被回收,这就是内存泄露;更严重的是计数器先于对象真实销毁变成0,对象被提前回收,另一个线程还在用,直接崩溃。
解决方案有两种:一种是给每个对象或者每个数据结构都加细粒度锁,写起来安全但性能成本高,而且锁的粒度越细,死锁的可能性越复杂;另一种就是GIL,给整个解释器加一把大锁,用极简的思路换线程安全。在上个世纪90年代,Python的核心场景是单核机器,GIL这种方式简单、稳定、性能可接受,所以一直沿用下来。
2.2 GIL的运行规则:什么时候释放,什么时候持有
GIL机制在实践里最重要的就三条规则:
- 线程执行Python字节码时需要持有GIL。纯Python的CPU密集计算,基本是全程持有。
- 每执行一段时间(默认间隔可调,通常是5毫秒左右),当前线程会主动让出GIL,操作系统再选另一个线程继续执行。这就是线程切换的来源。
- 遇到阻塞式I/O操作(比如socket读数据、文件读写、sleep),线程会提前释放GIL,不再占着这个锁干等。
所以,一个网络请求场景中,线程A在等待服务器返回数据时就自动放开了GIL,线程B立刻拿到GIL继续发起自己的网络请求。加上线程A、B本身就在不同的操作系统线程上等待,宏观上就形成了“多个请求同时在进行”的效果。这就是为什么多线程的I/O密集应用收益巨大,而CPU密集应用几乎死路一条。
2.3 一个容易忽略的细节:C扩展可以在运算中释放GIL
这里有个让很多人惊讶的点:如果你用numpy做数组乘法、用pandas做分组聚合、用opencv处理图像,这些底层计算大部分发生在C语言层,而C扩展代码可以显式地释放GIL,让其他Python线程有机会运行。也就是说,这些“计算型”任务在多线程下反而可能并行起来。
所以“CPU密集用多进程”这个结论要再加一个前提:所谓的CPU密集,是指纯Python代码在算,而不是C扩展底层在算。如果你只是调用numpy的大型矩阵运算,CPU沾满了,但Python字节码层并没有一直占着GIL。这时候用多进程反而会付出大量序列化和内存复制代价,不见得划算。
2.4 未来走向:无GIL的Python正在路上
近期的Python新版本开始提供实验性的“无GIL”构建(free-threaded build),尝试从根上移除GIL。这个方向对纯Python多线程并行是有力的,但生态里大量C扩展库和第三方模块还在适配,短时间内不会成为默认选项。所以现阶段写生产代码,依然应该按照“默认CPython有GIL”来做设计。选型逻辑不会因为一个实验特性马上就改变,但值得保持关注。
3. I/O密集场景:多线程用对了是真香
3.1 典型场景长什么样
I/O密集任务的判断标准非常朴素:程序的瓶颈不在CPU计算,而在等待外部资源。最典型的是爬虫和批量API调用,一个请求发出后,大部分时间都花在等待服务器响应上;还有读大量小文件、批量执行SQL查询、调用第三方服务等。这些任务的共同点是:CPU占用不高,但整体耗时很长。
假设你要抓取100个网页,每个请求网络往返需要200毫秒。单线程顺序跑,100次就是20秒。如果开10个线程并发跑,理想情况下这20秒的等待时间被10个请求重叠,总耗时可能压缩到2秒多。这里CPU几乎没怎么干活,多线程真正优化的是“等待时间的重叠”。
3.2 用ThreadPoolExecutor落地
Python标准库自带的concurrent.futures模块已经封装好了线程池,推荐优先使用。下面是批量请求接口的典型写法:
python复制import time
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
def fetch_one(url):
resp = requests.get(url, timeout=5)
return len(resp.text)
urls = [f"https://your-api.example.com/page/{i}" for i in range(30)]
start_time = time.time()
with ThreadPoolExecutor(max_workers=10) as pool:
futures = [pool.submit(fetch_one, url) for url in urls]
for future in as_completed(futures):
try:
result = future.result()
print("页面大小:", result)
except Exception as e:
print("任务失败:", e)
print("total:", round(time.time() - start_time, 2))
这段代码有几个值得注意的细节。一是用with块管理线程池,退出时自动调用shutdown并等待所有任务完成,避免主线程提前结束。二是用as_completed而不是直接遍历futures,这样某个任务完成就可以立刻处理结果,不需要等前面的任务先跑完。三是每个future.result()外面包了异常处理,因为线程里抛出的异常不会直接冒到主线程,而是要等你调用result()时才重新抛出,不处理就容易漏掉。
关于max_workers的取值,我自己的经验是:纯网络请求可以开得比较激进,20到50都是常见范围,但还要看下游服务能不能扛住;如果任务涉及数据库连接,连接池上限、数据库最大连接数都要一起考虑,不是线程数越大越好。曾经踩过的坑就是开30个线程同时写同一个MySQL连接池,连接池只有10个,大量线程排队等连接,整体性能不升反降。
3.3 为什么阻塞等待时GIL会自动“放水”
这段是理解多线程有效的关键。当线程执行到requests.get底层时,实际上是在做socket操作。操作系统发现这个socket没有数据可读时,会把线程挂起等待。CPython感知到线程要进入阻塞状态,就会先释放GIL,让其他就绪线程获得执行权。等网络数据到达,内核唤醒这个线程,它再重新抢回GIL继续后面的代码。
所以整个过程里,GIL并没有成为瓶颈,因为真正卡住的是外部网络延迟,而不是解释器。CPU没有满负荷,GIL也没有被谁死死攥住,线程池里的每个线程都能轮流获得执行机会去“播下”自己的网络请求。宏观上,这100个请求是同时在等待的,时间自然就被压缩了。
3.4 别忽视线程安全:全局计数器会悄悄出错
多线程写共享变量是经典翻车点。很多人以为Python里count += 1是一行代码,那应该不会被穿插。实际上它对应好几步机器操作:读取当前值、加一、写回。GIL的切换可能恰好发生在这三步之间。两个线程同时读到count=10,然后都加一写回,结果还是11,而不是12。
正确做法的第一选择是“尽量避免共享可变状态”。比如上面抓取的例子,每个fetch_one返回自己的结果,最后由主线程统一汇总,这样每个线程只负责本任务的局部变量,根本不需要锁。
确实需要共享状态时,就用threading.Lock包住临界区:
python复制from threading import Lock
counter = 0
lock = Lock()
def safe_update():
global counter
with lock:
counter += 1
还有一个小技巧:在线程和进程场景下,尽量用queue.Queue来做生产者-消费者之间的数据流转,它本身就是线程安全的,接口也简单,能省掉不少自己加锁的心力。
4. CPU密集场景:多进程才能榨干CPU
4.1 纯Python计算的尴尬处境
判断一个任务是不是CPU密集,就看你把任务规模放大十倍后,CPU使用率会不会冲上去并且长时间维持在很高的水平。典型的例子包括大量字符串处理、循环里做数学计算、递归求解斐波那契数列、蒙特卡洛模拟等。这一类纯Python计算在执行时,线程会一直持有GIL,别的线程根本没有机会执行指令。
我这边做过一次简单的对比实验,任务是用递归计算斐波那契数列第35项,跑4次。单线程顺序执行大约12秒,开4个线程去跑同样是12秒甚至更慢一点(多出线程切换的开销),但用4个进程去跑,大约4秒完成。原因是4个进程中各自有一套解释器、各自有一把GIL,四个任务才能真正地“同时”跑在四个CPU核上。
4.2 ProcessPoolExecutor的基本用法
多进程的标准做法一样推荐concurrent.futures.ProcessPoolExecutor:
python复制from concurrent.futures import ProcessPoolExecutor
import time
def fib(n):
return n if n < 2 else fib(n - 1) + fib(n - 2)
if __name__ == "__main__":
start_time = time.time()
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(fib, [35, 35, 35, 35]))
print(results)
print("cost:", round(time.time() - start_time, 2))
这里有个强制要求,就是在Windows下或者某些macOS+Python新版本的环境里,if __name__ == "__main__"是必须的。因为多进程默认采用spawn方式启动子进程,子进程会重新导入主模块;如果不加这个守卫,脚本顶层的创建进程池代码会在子进程里再次执行,导致无限递归创建进程。这个坑后文还会详细说。
pool.map会保持输入顺序返回结果,但它也有一个缺点:如果某个任务提前抛出异常,整个结果的获取可能会被影响。想要“谁先完成先处理谁”,依然可以像线程池那样submit配合as_completed。
4.3 进程间通信:Queue、Pipe、Manager怎么选
多进程之间没有共享内存的默认概念,数据传递需要走IPC(进程间通信)。选型可以参考下面这张对照表:
| 通信方式 | 典型场景 | 容易出现的问题 |
|---|---|---|
| multiprocessing.Queue | 生产者-消费者模式,任务分发、结果回收 | 队列满时会阻塞;子进程退出时要处理好队列状态 |
| Pipe | 父子进程之间简单的双向数据交换 | 两端同时读写时容易死锁,适合一对一通信 |
| Manager | 需要共享dict、list等复杂结构 | 每次读写都有IPC开销,速度最慢,复合操作需加锁 |
实际项目里90%的场景用Queue或者直接通过ProcessPoolExecutor.submit返回结果就够了。Manager看起来方便,但性能是个坑,因为每次修改Manager管理的对象都要跨进程传输同步,高频读写时会非常慢。共享大量数据时,更合理的方式是用第三方库或者把数据提前切片,每个进程只拿自己需要的那一段。
还有一个大坑:通过进程池或Queue传对象时,对象必须能被pickle序列化。模块级的def函数、类、基本类型都没问题,但lambda匿名函数、局部定义的嵌套函数、某些生成器对象,统统序列化不了,运行时会报PickleError。
4.4 混合模型:进程外面再套线程
当你遇到一个任务同时包含“大量计算”和“大量I/O”时,要敢于把它拆成两段。举个我项目里的例子:批量回测几十个交易标的,每个标的历史数据都从数据库拉取,然后套策略公式计算。回测计算本身是CPU密集型,拉取历史数据又是I/O密集型。
我当时的设计是:用ProcessPoolExecutor开4个进程(按机器核数),每个进程内部再用ThreadPoolExecutor开8个线程去并发拉取该组标的数据并做预处理,然后进程内做计算。这样进程解决CPU并行,线程解决I/O等待。整体吞吐比单纯全进程或全线程都快很多,前提是机器内存足够容纳多个进程的解释器和数据副本。
5. 决策路径:照着三步走,几乎不会选错
5.1 第一步:先分清任务是CPU密集还是I/O密集
这是选型最核心的一步。一个快速经验是:把任务规模放大十到一百倍,然后打开系统监控观察CPU使用率。如果CPU在跑满和排队之间抖动,说明计算在扛;如果CPU长期个位数,时间都耗在progress bar上,说明瓶颈在等待外部资源。
最常见的反例是:有人写的“数据处理”程序慢,他下意识开多线程来提速,结果发现CPU使用率没变,耗时也没怎么变,就开始骂Python并发能力不行。其实程序慢是因为过程中有密集的字符串解析和循环计算,这属于CPU密集,该用多进程而不是多线程。先判断类型,后面的选择基本就顺了。
5.2 第二步:关注计算发生的层
如果任务表面看是“计算”,但底层是numpy、pandas这种C扩展在实现核心运算,情况就不一样了。C扩展层可以做两件事:在耗时计算时释放GIL,或者就算不释放,它也根本不走Python字节码执行流程。此时多线程依然是可选项,甚至比多进程更轻。
一个判断技巧:去库的文档看看它能否释放GIL,或者直接做个快速压测——单线程跑一次你的核心计算,再用4个线程跑四份,如果耗时接近原来的四分之一,说明GIL没在中间挡路;反过来,纯Python的for循环做同样测试,多线程耗时只会更多。
5.3 第三步:把环境约束和传输代价算进去
即便确定了任务类型,多进程也未必总是首选。多进程的内存开销比较大,每个子进程都有一份解释器、加载过的模块和数据副本,64位机器上一个Python进程二三十MB内存非常正常。如果你有2GB的大DataFrame,想用4个进程共享,光是复制4份就能把机器内存耗尽。
此时方案要重新考虑:要么数据切片后用文件或共享内存传递,要么上Ray、Dask这类分布式并行框架,让调度层处理数据分布。另一个约束是任务粒度。如果你的单个任务本身只需要0.1秒,但每个进程的启动和序列化都要花0.5秒,用多进程就是纯亏。线程池在这种“小任务、海量数量”的场景里更划算。
5.4 三个典型的项目选型案例
我拿遇到过的三类项目做例子,你遇到相似场景可以直接套:
- 爬虫项目:任务全是HTTP请求和少量HTML解析。几乎无脑用ThreadPoolExecutor,线程数根据目标网站限速来定。解析HTML时偶尔有点CPU开销,但占比不大,不会形成瓶颈。
- 量化策略回测:大量K线数据计算、指标公式、策略模拟,CPU是绝对大头。用ProcessPoolExecutor按标的分片,每核心分配一组标的,回测结果用Queue汇总到主进程。当时4核机器从串行8小时压到2小时。
- FastAPI/Flask Web服务:Web服务器本身就是多线程的,普通I/O请求直接被并发处理。如果某个API内部要跑重计算,不要直接在线程里算,可以把任务提交给后台进程池,接口返回“计算任务已接收”,由另一个进程池消费。
这三步走下来,绝大多数并发方案都不会选错。剩下的就是到实际代码里去处理那些细节和坑。
6. 并发改造踩坑实录:四个真实问题复盘
6.1 Windows下进程池不断重启,或者程序越来越慢
第一次在Windows上跑ProcessPoolExecutor时,代码是直接在脚本顶层写进程池的。结果是程序一运行就卡死,任务管理器里看到好几个Python进程来回出现,一会儿增加一会儿退出,像开了一场闹剧。原因就是spawn模式启动子进程时会重新执行顶层代码,顶层又去创建进程池,子进程又去创建孙进程,无限套娃。
解法非常简单:把主逻辑整体包进if __name__ == "__main__"。这不是Python风格建议,而是多进程编程的强制要求。后来我养成了一个习惯,只要代码里出现ProcessPoolExecutor或multiprocessing相关API,不管在什么系统上,默认先写上这个守卫,反正不影响主逻辑,还能降低跨平台踩坑概率。
6.2 ProcessPoolExecutor提交Lambda,直接报PickleError
当时写数据清洗流程,图省事在pool.submit(lambda row: process(row), data),结果抛了AttributeError: Can't pickle local object。查了很久才明白,进程池要把待执行的callable和参数序列化后发给子进程,这个过程用pickle。而lambda是匿名函数,没有全局名字,pickle协议找不到它,自然序列化不了。
修复也很简单:把它定义成模块级函数,参数只传基本类型、元组、字典和可pickle的类实例。线程池没有这个限制,因为线程和主进程共享内存,不需要序列化。但这个差异经常被人忽略,尤其在写跨平台代码时。
6.3 多线程统计结果总是少一截
一个数据采集任务,20个线程并发抓取接口,每个线程抓完把结果计到共享的全局计数器里。跑完一看,明明抓了1000条,计数器只有963。这就是前面提到的counter += 1不是原子操作的问题。虽然每个线程都执行了一次加一,但读取和写回之间被切换打断,最后两次加一覆盖了同一个基础值。
后来我改成每个线程返回自己抓到的数量,主线程再对结果列表求和,全局计数器彻底移除,问题消失。如果确实需要共享状态,就把更新逻辑放进Lock保护的临界区。优先用“任务内聚合+外层汇总”的模式,这是并发代码里最安全的写法。
6.4 用map想边跑边收,却被一个慢任务卡死
当时用ProcessPoolExecutor.map处理大量计算任务,以为map返回迭代器就能边跑边获取结果。实际却是map严格保持传入顺序,某个输入参数对应的任务特别慢时,即使后面的任务已经完成了,结果迭代器也停在那个慢任务的索引上,无法取出来。特殊情况下某个任务抛异常,还会影响整批结果的获取。
换成submit+as_completed之后,已经完成的任务立刻就能被处理,快慢任务互不拖累。另一个经验是,对于量大的CPU密集任务,给pool.map设置合理的chunksize可以降低分发开销,但前提是你不需要实时的结果顺序。
6.5 并发程序调试技巧:先看栈,再分层定障
并发程序出问题,最烦的是不知道崩在哪。这里分享两个常用的工具经验。第一个是faulthandler.enable(),在程序最前面加上这行,运行崩溃时会把每个线程的Python栈打出来,很多“莫名其妙卡死”的问题立刻就能定位到某一行。第二个是给每个任务打印结构化日志,日志带上进程或线程标识、任务ID、开始时间和结束时间。看起来土,但排错效率极高——你是能快速确定是哪个worker在哪个阶段卡住的。
跑大量并发任务前,还有一个必经步骤:先取一小批数据(比如10条)跑通流程,确认结果正确、边界没问题,再放到全量数据上。很多并发异常只在数据分布特殊时出现,小批量验证能帮你过滤掉大部分低级错误。
最后再分享一点个人经验:如果时间倒流,让我给所有并发改造项目写一条铁律,我会把“先判断任务是计算还是等待”这句话贴在显示器上。GIL本身并不可怕,可怕的是在错误的方向上拼命优化。你只需要把一个任务按CPU密集和I/O密集拆开,然后往表格里填答案,多线程和多进程就能各归其位,代码也不会再跟机器较劲。
