有个数据处理脚本,要对几百万条记录做一轮挺重的计算——纯 CPU 活儿,一堆数值运算和字符串处理。单线程跑下来要十几分钟,老板嫌慢。我心想这还不简单,机器是 8 核的,开个线程池把活儿分到 8 个线程上,理论上能快好几倍。改完信心满满地一跑,结果当场傻眼:不但没快,反而比单线程还慢了一点。我反复确认代码没写错、活儿确实被分下去了,可 CPU 监控显示,8 个核里始终只有一个在忙,其余七个基本在睡觉。
我一度怀疑是不是线程池配置有问题,换了写法、调了参数,结果纹丝不动。直到我想起 Python 那个绕不开的名字——GIL(全局解释器锁),才恍然大悟。原来在 CPython 里,有这么一把全局的大锁:任何时刻,只允许一个线程执行 Python 字节码。我开的 8 个线程,根本没法真正并行地跑计算,它们只是在抢这同一把锁、轮流执行,而抢锁、切换本身还有开销——于是多线程在 CPU 密集场景下,不仅没带来并行,反而因为额外的调度成本,比老老实实单线程还慢。
这就是几乎每个 Python 开发者迟早都会撞上的一堵墙:GIL 让多线程在 CPU 密集任务上彻底失去并行能力。它违反了我们从其它语言带来的直觉——"多线程 = 多核并行 = 更快",而这个直觉在 CPython 里,对 CPU 密集型任务是失效的。这篇文章,就从这次"8 线程比单线程还慢"的事故出发,把 GIL 是什么、它坑在哪、以及到底该怎么正确地在 Python 里压榨多核,一次讲透。
先摆几个关于 Python 并发的想当然
动手复盘前,先把我自己曾经深信、后来被 GIL 狠狠教育的几个念头摆出来。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "多开线程就能用上多核,CPU 活儿会更快" | GIL 下同一时刻只有一个线程跑字节码,CPU 密集多线程无并行 |
| "线程越多越快" | CPU 密集场景线程越多,抢锁和切换开销越大,可能更慢 |
| "GIL 让多线程在 Python 里完全没用" | IO 密集场景多线程依然有效,因为等 IO 时锁会被释放 |
| "换多进程不就是把线程换个名字" | 进程各有独立解释器和 GIL,能真并行,但有内存和通信开销 |
| "GIL 是 Python 语言的硬伤,无解" | 它是 CPython 实现的选择,且有多进程、C 扩展、新版本等多条出路 |
这些念头的共同病根,是把从 Java、C++ 等语言带来的"多线程并行"直觉,原封不动地搬到了 CPython 上,却没意识到 CPython 有一把独一无二的全局锁,彻底改变了多线程在 CPU 密集任务上的行为。要看清这次事故,得先搞明白 GIL 到底是什么、为什么会存在。
第一件事:GIL 到底是什么,为什么会有它
GIL,全称 Global Interpreter Lock(全局解释器锁),是 CPython(也就是我们绝大多数人用的那个官方 Python 解释器)里的一个机制。一句话概括它的作用:它是一把全局的互斥锁,保证任何时刻,一个 Python 进程里只有一个线程在执行 Python 字节码。哪怕你开了 100 个线程、机器有 64 个核,在执行纯 Python 计算时,这 100 个线程也只能排着队、一个接一个地拿到这把锁去跑,无法真正同时运算。
它为什么会存在?根源在于 CPython 的内存管理。CPython 用引用计数来管理对象的生命周期——每个对象都记着"现在有多少个地方在引用我",计数归零就回收。可如果多个线程能真正并行地修改这个计数,就会产生竞态,导致对象被错误地提前回收或永远泄漏。为了简单可靠地解决这个问题,CPython 的设计者选择了最直接的办法:加一把全局大锁,让同一时刻只有一个线程能动 Python 对象,引用计数也就天然安全了。GIL 是用"牺牲多线程并行"换来的"实现简单和单线程高效",这是一笔历史悠久的权衡。
但这里有个极其关键的细节,也是后面所有解法的分水岭:线程在等待 IO(网络、磁盘、sleep)时,会主动释放 GIL。因为等 IO 时线程并不需要执行 Python 字节码,它就把锁让出来,让别的线程去跑。下面这张图,把 CPU 密集和 IO 密集两种场景下 GIL 的行为对比画出来:
看懂这张图,我那次事故的根源和解法方向就都清楚了:我的任务是纯 CPU 计算(图的左路),线程们死死抢着 GIL 谁也并行不了,自然快不起来。而要真正用上多核做计算,就得想办法绕开这把锁。接下来,我们就先把"什么时候多线程有用、什么时候没用"这条最重要的分界线讲透。
第二件事:先分清你的任务是 IO 密集还是 CPU 密集
从那次事故里我提炼出的第一条、也是最重要的一条原则:在 Python 里选并发方案,第一步永远是先判断任务是 IO 密集还是 CPU 密集。这条分界线,直接决定了多线程到底有没有用。
IO 密集型任务,大部分时间花在"等"上:等网络响应、等数据库返回、等磁盘读写。这种场景下,多线程非常有效——因为线程在等 IO 时会释放 GIL,别的线程趁机去发起自己的 IO,多个"等待"被重叠起来,整体吞吐大大提升。而 CPU 密集型任务,大部分时间花在"算"上:数值运算、数据变换、压缩加密。这种场景下,多线程几乎无用甚至有害——因为大家都在抢 GIL 算字节码,根本没有"等待"可供重叠。
import threading, requests
# IO 密集:多线程有效。每个线程等网络时释放 GIL, 等待被重叠
def fetch(url):
return requests.get(url).text # 大部分时间在等响应
threads = [threading.Thread(target=fetch, args=(u,)) for u in urls]
for t in threads: t.start()
for t in threads: t.join()
# 这种场景, 多线程能把几十个请求的等待时间叠在一起, 显著提速
记住这个简单的对应关系:IO 密集 → 多线程(或异步)有效;CPU 密集 → 多线程无效,要换多进程。我那次的错误,本质就是把一个 CPU 密集的任务,错配了多线程这个只对 IO 密集有效的方案。方案和任务类型一旦错配,再怎么调参数都是徒劳。
第三件事:CPU 密集要真并行?用多进程绕开 GIL
那 CPU 密集任务到底怎么用上多核?答案是多进程。GIL 是每个进程一把的——你启动多个 Python 进程,每个进程都有自己独立的解释器和独立的 GIL,它们之间互不干扰,于是就能在多个 CPU 核上真正并行地跑计算。Python 标准库的 multiprocessing(或更现代的 ProcessPoolExecutor)就是干这个的。
from concurrent.futures import ProcessPoolExecutor
def heavy_compute(chunk):
# 纯 CPU 密集的计算, 每个进程在自己的核上真并行地跑
return sum(x * x for x in chunk)
if __name__ == "__main__": # 多进程在 Windows 下必须有这个保护
chunks = split_data(big_data, n=8) # 把数据切成 8 份
with ProcessPoolExecutor(max_workers=8) as pool:
results = list(pool.map(heavy_compute, chunks))
total = sum(results)
# 8 个进程各占一个核真正并行, 这次才是真的快好几倍
我把那段计算从 ThreadPoolExecutor 换成 ProcessPoolExecutor 后,8 个核终于一起忙了起来,耗时从十几分钟降到两分多钟,这才是我最初期待的效果。但多进程不是免费的午餐,它有两个必须心里有数的代价:第一,每个进程是独立的内存空间,不像线程那样共享内存,进程间传数据要经过序列化(pickle),有开销;第二,启动进程本身比启动线程重。
所以多进程有它的适用边界:它适合那种计算量大、而进程间需要传递的数据相对小的任务——计算的收益,要能盖过数据序列化和进程启动的成本。如果你的"计算"很轻、但要在进程间倒腾巨量数据,那序列化的开销可能反而吃掉并行的好处。用多进程前,先掂量一下"计算收益"和"数据搬运成本"哪个大。
第四件事:IO 密集的更优解——asyncio 异步
对 IO 密集任务,多线程虽然有效,但还有一个在高并发下更轻量、更高效的选择:asyncio 异步。多线程靠操作系统调度,每个线程都有不小的内存和切换开销,几千个线程就吃不消了;而 asyncio 用单线程 + 事件循环 + 协程,在一个线程里靠 await 在"等 IO"时主动让出控制权,用极小的开销就能撑起成千上万个并发 IO 操作。
import asyncio, aiohttp
async def fetch(session, url):
async with session.get(url) as resp:
return await resp.text() # await 处让出控制权, 去跑别的协程
async def main(urls):
async with aiohttp.ClientSession() as session:
# 成千上万个请求并发, 单线程内靠事件循环高效调度
tasks = [fetch(session, u) for u in urls]
return await asyncio.gather(*tasks)
asyncio.run(main(urls))
# 同样是 IO 密集, asyncio 比多线程更省资源、并发上限更高
这里有个常见的认知误区要澄清:asyncio 同样受 GIL 约束、同样跑在单线程里,它对 CPU 密集任务一样无能为力。它的高效只体现在 IO 密集场景——把大量"等待"高效地重叠起来。所以选型上:少量 IO 并发,多线程简单够用;海量 IO 并发,asyncio 更优;CPU 密集,这两个都不行,老老实实上多进程。
第五件事:还有一条路——让 C 扩展替你释放 GIL
除了多进程,还有一条对数据计算尤其重要的路:用那些底层用 C/C++ 实现、并且在做密集计算时会主动释放 GIL 的库。最典型的就是 NumPy。当你用 NumPy 做大规模数组运算时,真正的计算发生在它底层的 C 代码里,而这些 C 代码在执行时会释放 GIL——于是其它线程能趁机运行,甚至底层还能用上 SIMD、多核优化。这意味着,把纯 Python 的循环计算改写成 NumPy 的向量化操作,往往比你费劲搞多进程还快、还省事。
import numpy as np
# 反例:纯 Python 循环, 逐元素算, 全程持 GIL, 慢
def slow_square_sum(data):
return sum(x * x for x in data) # 几百万次纯 Python 运算
# 正解:NumPy 向量化, 计算下沉到 C 层, 期间释放 GIL, 快几十倍
def fast_square_sum(data):
arr = np.asarray(data)
return np.sum(arr * arr) # 底层 C 计算, 不受 GIL 拖累
# 很多时候, 向量化改写比上多进程更划算
这条路的启示是:绕开 GIL 不一定要靠多进程,把计算"下沉"到会释放 GIL 的 C 扩展里,常常是更优雅的解法。NumPy、Pandas、以及大量科学计算和机器学习库,底层都是这个思路。所以遇到 CPU 密集的数值计算,先别急着写多进程,想想能不能用向量化、用现成的高性能库把热点计算下沉到 C 层——这往往是性价比最高的一招。
第六件事:GIL 的未来——它正在松动
最后值得一提的是,GIL 并非铁板一块,它正在被撼动。Python 3.12 引入了子解释器(sub-interpreter)的基础设施,让一个进程内可以有多个相对独立的解释器;更重磅的是,Python 3.13 开始提供了实验性的"自由线程"(free-threaded,即 no-GIL)构建版本,在这个版本里,GIL 可以被真正关闭,多线程终于能在多核上并行跑 Python 计算。
这是个激动人心的方向,但现阶段要务实看待:no-GIL 目前仍是实验性的,生态(尤其是大量 C 扩展)还需要时间适配,单线程性能也可能有一定折损。所以对当下的生产代码,前面那几条成熟的路子——分清任务类型、CPU 密集上多进程、IO 密集用线程或 asyncio、计算下沉到 C 扩展——依然是你应该依赖的解法。但知道 GIL 正在松动,能让你对 Python 并发的未来多一分期待,也提醒你在做长期技术选型时把这个趋势纳入考量。到这儿,这次事故的来龙去脉和各条出路都齐了,我把它们收成一张决策图:
把这张图记在心里,Python 并发选型就不会再走我那次的弯路。最后,拧成几条可直接照做的铁律:
- 动手前先分清 IO 密集还是 CPU 密集,这是 Python 并发选型的第一分界线。
- CPU 密集别用多线程,GIL 下它无法并行,甚至比单线程更慢。
- CPU 密集真并行,用多进程,每进程独立 GIL,但要算上数据序列化的成本。
- IO 密集用多线程或 asyncio,等 IO 时 GIL 被释放,等待可被重叠。
- 海量 IO 并发优先 asyncio,单线程事件循环比堆线程更省资源。
- 数值计算优先向量化/C 扩展,NumPy 等会释放 GIL,常比多进程更划算。
- 关注 no-GIL 进展但当下仍按成熟方案来,实验特性别急着上生产。
一张 Python 并发选型速查表
把这几种方案的适用场景、能否用多核、代价汇成一张表,下次提速前对照着选。
| 方案 | 适用任务 | 能用多核? | 代价/注意 |
|---|---|---|---|
| 多线程 threading | IO 密集(中小并发) | 否(受 GIL) | CPU 密集无效甚至更慢 |
| asyncio 异步 | IO 密集(海量并发) | 否(单线程) | 需异步生态, CPU 密集无效 |
| 多进程 multiprocessing | CPU 密集 | 是(各自 GIL) | 进程开销 + 数据序列化成本 |
| NumPy 等向量化 | CPU 密集数值计算 | 是(C 层释放 GIL) | 需改写成向量化操作 |
| C 扩展 / Cython | CPU 热点 | 是(可释放 GIL) | 有开发成本 |
| no-GIL(3.13+) | CPU 密集多线程 | 是(实验性) | 生态未成熟, 暂不宜上生产 |
一个能帮你看清真相的小习惯:量一量
这次事故还教会我一个朴素却极有价值的习惯:别凭感觉判断快慢,动手量一量。当年如果我在改多线程之前,先花两分钟用 time 对单线程、多线程、多进程各跑一遍做个对比,就会立刻发现多线程根本没提速,根本不用绕那么大弯。下面是一个最简单的对比骨架:
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def bench(name, runner):
t0 = time.perf_counter()
runner()
print(f"{name}: {time.perf_counter() - t0:.2f}s")
if __name__ == "__main__":
bench("单线程", lambda: [heavy_compute(c) for c in chunks])
bench("多线程", lambda: list(
ThreadPoolExecutor(8).map(heavy_compute, chunks)))
bench("多进程", lambda: list(
ProcessPoolExecutor(8).map(heavy_compute, chunks)))
# CPU 密集下你会清楚看到: 多线程≈单线程甚至更慢, 多进程才真快
这种"三方对比"的小基准,成本极低却能瞬间戳穿"多线程一定更快"这类想当然。性能优化最忌讳的就是凭直觉拍脑袋,而 GIL 恰恰是直觉最容易出错的地方。量化,是对抗想当然最锋利的武器。
一个反向的误区:有了 GIL 就不用加锁了吗?
聊完"GIL 拖慢了多线程",得反过来澄清另一个同样常见、却方向相反的误解:"既然 GIL 保证同一时刻只有一个线程在跑,那我多线程共享数据是不是就不用自己加锁了?"这个想法很危险,答案是:不行,该加的锁一个都不能省。
原因在于,GIL 的保护粒度是单条字节码,而不是你写的一行 Python 语句。一行看起来"原子"的代码,比如 counter += 1,在底层其实会被拆成好几条字节码:读取 counter、加一、写回 counter。GIL 完全可能在这几条字节码中间把线程切走,让另一个线程也来读、来改——于是经典的竞态条件就发生了,两个线程各加了一次,结果却只加上去一次。
import threading
counter = 0
def worker():
global counter
for _ in range(100000):
counter += 1 # 看似原子, 实则多条字节码, GIL 可能从中切走
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # 期望 800000, 实际常常小于它 —— 竞态丢了更新!
# 正解:共享可变状态依然要自己加锁
lock = threading.Lock()
def safe_worker():
global counter
for _ in range(100000):
with lock: # 显式保护, 把"读-改-写"锁成真正的原子
counter += 1
这里的认知校正很重要:GIL 保护的是"解释器内部状态不被多线程搞乱",它从不替你保证"你的业务逻辑是线程安全的"。这两件事是两回事。所以在 Python 多线程里共享可变状态,该用 Lock、该用线程安全的 queue.Queue,一样都不能少。
把这个误区和前面那个主线放在一起看,会得到一个挺有意思的全貌:GIL 给了你一种"伪安全感"——它既没强到能替你做并发控制,又恰好强到拖垮了你的并行计算。它管了它不该让你依赖的(逐字节码互斥),却挡了你真正想要的(多核并行)。理解了这种"错位",你对 Python 并发的认识才算真正立体起来:既不会天真地以为有 GIL 就不用加锁,也不会执着地用多线程去硬刚 CPU 密集。
写在最后
这次"8 线程比单线程还慢"的事故,给我最深的教训,是它狠狠纠正了一个我以为天经地义的直觉。"多线程能利用多核所以更快",这句话在 Java、C++、Go 里几乎是常识,我便想当然地以为 Python 也一样。可 GIL 的存在,让 CPython 成了一个例外——它用一把全局锁,在换取实现简单的同时,也亲手把多线程挡在了多核并行的门外。跨语言迁移时,最危险的不是那些你知道自己不懂的东西,而是那些你以为放之四海皆准、其实暗藏前提的"常识"。
而把 GIL 这件事真正想透之后,你会发现它逼着你回到一个更本质的问题上:我这个任务,瓶颈到底是卡在"等"还是卡在"算"?想清楚这一点,多线程、asyncio、多进程、向量化各自的位置就一目了然,选型不再是玄学。GIL 与其说是 Python 的一道枷锁,不如说是一面镜子——它照出了我们对并发、对自己代码瓶颈的理解到底有多清楚。愿你我下次再想"加点并发提个速"时,都能先停下来问一句:我的瓶颈,究竟在哪?答案对了,工具自然就对了。
如果你手上也有想提速的 Python 代码,不妨今天就花二十分钟做三件小事。第一,给你的热点任务做一次"画像":它到底是在等 IO,还是在埋头算?最简单的办法是跑的时候瞄一眼 CPU——单核打满多半是 CPU 密集,CPU 闲着却很慢多半是卡在 IO。第二,按前面那张速查表,确认你现在用的并发方案和任务类型对不对路,错配的赶紧换。第三,如果是数值计算,试试能不能用 NumPy 向量化改写热点循环,常常一行顶过去几十行、还快几十倍。这三步走下来,大概率能让你避开我当年那个"开了 8 线程反而更慢"的尴尬。
说到底,GIL 这堂课教给我的,远不止"Python 多线程不能并行计算"这一条事实,而是一种更普适的工程态度:任何工具都有它的设计前提和边界,用它之前,先搞懂它为什么是这样,比记住十条"该怎么用"的口诀更重要。当你真正理解了 GIL 存在的理由、它释放的时机、它保护与不保护的东西,那些关于 Python 并发的困惑就会烟消云散,选型也会从"碰运气"变成"有依据"。愿你我都能带着这份对底层的好奇,把每一次"为什么这样"都问到底——因为答案里,往往藏着让你少走十里弯路的钥匙。
毕竟,真正的提速,从来不是盲目堆并发,而是先看清瓶颈、再对症下药。
—— 别看了 · 2026