一段用多线程给 CPU 密集计算"加速"的 Python 代码,开了八个线程却比单线程还慢,我被 GIL 上了一课:一次多线程并行误区的深度复盘
那是一次让我对着代码怀疑人生的"优化":我有一个 CPU 密集的计算任务(对一大批数据做复杂的数值运算),单线程跑要十几秒。我想当然地觉得,服务器有 8 核,那我开 8 个线程并行跑,不就能快个好几倍吗?于是我用 threading 把任务拆成 8 份、起了 8 个线程。可结果让我大跌眼镜:开了 8 个线程,不但没快,反而比单线程还慢了一点!我盯着 CPU 监控更是困惑——明明 8 核的机器,跑这个"8 线程"的程序时,CPU 利用率始终只有一个核的量(约 100%/800%),另外 7 个核几乎在睡觉。我查了大半天,才终于撞上了那个 Python 程序员迟早要面对的名字——GIL(全局解释器锁),后背发凉:CPython(我们最常用的 Python 解释器)有一把全局解释器锁(GIL),它保证同一时刻,只有一个线程能执行 Python 字节码。也就是说,哪怕你开了 8 个线程、机器有 8 个核,这 8 个线程也不能真正同时执行 Python 代码——它们得轮流抢那把唯一的 GIL,本质上还是串行地跑,只是在不停地切换。而线程切换本身还有开销,于是我的"8 线程并行",对 CPU 密集任务来说,不仅没利用上多核,反而因为抢锁和切换的额外开销,比老老实实的单线程还慢。这篇就把这次"多线程对 CPU 密集任务无效、GIL 误区"的坑,从头到尾复盘一遍。
故障现场:用多线程跑 CPU 密集任务
问题代码,是一个想用多线程"并行"加速计算的写法:
# ✗ 出问题的代码: 用 threading 给 CPU 密集任务"加速"
import threading
def heavy_compute(data): # CPU密集: 纯计算, 一直在烧CPU
result = 0
for x in data:
result += complex_math(x) # 大量数值运算
return result
# 想用8个线程并行跑(机器是8核)
def run_with_threads(big_data):
chunks = split(big_data, 8) # 拆成8份
threads = []
for chunk in chunks:
t = threading.Thread(target=heavy_compute, args=(chunk,))
t.start()
threads.append(t)
for t in threads:
t.join()
# 期望: 8核并行, 快8倍; 实际: 不但没快, 还比单线程慢一点!
# 现象:
# - 单线程跑: 12秒;
# - 8线程跑: 13秒(还慢了!);
# - CPU监控: 8核机器, 利用率却只有约1个核的量(~100%而非800%)。
# 为什么:
# - CPython有GIL(全局解释器锁): 同一时刻【只有一个线程】能执行Python字节码;
# - → 8个线程不能真正同时跑Python代码, 它们【轮流抢GIL】, 本质还是串行;
# - → 多核完全没用上(只有一个核在跑Python); 还多了"抢锁+线程切换"的开销;
# - → 所以对【CPU密集】任务, 多线程不仅没加速, 反而因切换开销略慢。
# 关键: CPython的GIL让多线程【无法在CPU密集任务上并行利用多核】;
# 想用多线程给纯计算加速, 是个常见但错误的直觉。
第一次理解 GIL 时,我又震惊又不解:"我开了 8 个线程、机器有 8 个核,它们怎么就不能一起跑?这线程开了有什么用?"这个坑最反直觉的地方在于:它颠覆了"多线程 = 并行 = 更快"这个几乎刻在所有程序员骨子里的常识——在 C++/Java 里,多线程确实能利用多核真正并行;可在 CPython 里,因为 GIL,多线程对 CPU 密集任务几乎没有并行加速。更迷惑人的是:多线程在 Python 里并非完全没用——它对 IO 密集任务(等网络、等磁盘)是有效的(等 IO 时会释放 GIL,别的线程能跑);只是对 CPU 密集任务无效。这种"有时有用、有时没用"让它更难被一眼看穿。下面就来拆解,GIL 到底是什么、该怎么真正并行。
第一件事:搞懂 GIL,以及它对多线程的影响
我认真研究了 CPython 的 GIL,才彻底理解这个"多线程不并行"的根源。
GIL(全局解释器锁)是什么? 它怎么影响多线程?
【核心: CPython有一把全局锁, 同一时刻只允许一个线程执行Python字节码; 故多线程无法在CPU密集任务上并行多核】
1. GIL 是什么:
- GIL = Global Interpreter Lock, CPython解释器里的一把全局互斥锁;
- 规则: 任一时刻, 只有【持有GIL的那一个线程】能执行Python字节码;
- → 即使你有多个线程、多个CPU核, 也只有一个线程在真正跑Python代码, 其余在等GIL。
2. 为什么有GIL:
- 历史原因: 简化CPython的内存管理(引用计数)、让单线程性能好、和C扩展集成简单;
- 代价: 牺牲了多线程的真正并行(在CPU密集场景)。
3. 对不同任务的影响(关键区别):
- CPU密集任务(纯计算): 线程一直在执行字节码、一直占着GIL;
→ 多线程只能轮流跑, 本质串行, 用不上多核 → 多线程【没有加速】(还有切换开销, 可能更慢)。
- IO密集任务(等网络/磁盘): 线程在【等IO时会释放GIL】, 让别的线程趁机执行;
→ 多线程【有效】: 一个线程等IO时, 别的线程在干活 → 整体吞吐提升。
- → 所以: 多线程适合IO密集, 不适合CPU密集。
4. CPU密集任务想真正并行多核, 怎么办:
- 用【多进程(multiprocessing)】: 每个进程有自己独立的解释器和GIL, 进程间真正并行多核;
- 或用C扩展/numpy(在C层面释放GIL做计算)、或用其他无GIL的实现/方案。
类比: GIL像一个只有【一支笔】的会议室, 规定"同一时刻只能有一个人用这支笔写字(执行Python)";
你叫来8个人(8线程), 但只有一支笔, 他们只能轮流写, 多叫人没用(还得花时间传笔);
要8个人同时写, 得给8个人各发一支笔(8个进程, 各有自己的GIL)。
一句话: CPython的GIL让同一时刻只有一个线程执行Python字节码; 多线程对IO密集有效(等IO释放GIL)、
对CPU密集无效(用不上多核还有切换开销); CPU密集要并行多核, 用多进程。
这套机制,是整个坑的根。GIL 是 CPython 解释器里的一把全局互斥锁:任一时刻只有持有 GIL 的那一个线程能执行 Python 字节码,即使有多个线程多个核也只有一个在真正跑 Python。为什么有 GIL?历史原因(简化引用计数内存管理、单线程性能好、C 扩展集成简单),代价是牺牲了多线程的真正并行。对不同任务影响不同:CPU 密集(纯计算)线程一直占 GIL、多线程只能轮流跑本质串行、用不上多核没有加速(还可能更慢);IO 密集(等网络/磁盘)线程等 IO 时释放 GIL让别的线程趁机执行、多线程有效。CPU 密集想真正并行多核?用多进程(multiprocessing)——每个进程有独立的解释器和 GIL、进程间真正并行多核;或用 numpy/C 扩展(C 层释放 GIL)。就像GIL 像只有一支笔的会议室,叫 8 个人也只能轮流写(还得传笔),要 8 人同时写得各发一支笔(8 个进程)。一句话:CPython 的 GIL 让同一时刻只有一个线程执行 Python 字节码;多线程对 IO 密集有效(等 IO 释放 GIL)、对 CPU 密集无效(用不上多核还有切换开销);CPU 密集要并行多核用多进程。
第二件事:正解——CPU 密集用多进程,IO 密集才用多线程/异步
搞懂了原理,正解就清晰了:CPU 密集任务用多进程(multiprocessing)真正并行多核;IO 密集任务才用多线程或 asyncio;按任务类型选对并发模型。
# ====== 正解一: CPU密集任务用 多进程(ProcessPoolExecutor) ======
from concurrent.futures import ProcessPoolExecutor
def heavy_compute(data): # CPU密集的纯计算
return sum(complex_math(x) for x in data)
def run_with_processes(big_data):
chunks = split(big_data, 8)
with ProcessPoolExecutor(max_workers=8) as ex: # ★ 8个进程, 各有独立解释器和GIL
results = list(ex.map(heavy_compute, chunks))
return sum(results)
# → 每个进程独立, 不共享GIL → 8个进程真正并行跑在8个核上 → 接近8倍加速!
# 代价: 进程间数据要序列化传递(有开销)、内存各自独立(比线程重)。
# ====== 正解二: IO密集任务才用 多线程 / asyncio ======
from concurrent.futures import ThreadPoolExecutor
def fetch(url): # IO密集: 大部分时间在等网络
return requests.get(url).text
def run_io_with_threads(urls):
with ThreadPoolExecutor(max_workers=20) as ex: # IO密集用线程有效
return list(ex.map(fetch, urls))
# → 等网络IO时线程会释放GIL, 别的线程趁机发请求 → 多线程对IO密集有效, 吞吐大增。
# 更现代的IO并发: asyncio + aiohttp(单线程协程, 更轻量地处理海量IO并发)。
# ====== 正解三: 用 numpy 等在C层做计算(绕过GIL) ======
import numpy as np
# 把循环里的数值运算, 换成numpy的向量化操作:
# ✗ result = sum(x*x for x in big_list) # 纯Python循环, 受GIL限制、还慢
# ✓ arr = np.array(big_list); result = (arr*arr).sum() # numpy在C层计算, 快且部分释放GIL
# → numpy/pandas/科学计算库的底层是C/Fortran, 计算时会释放GIL、且本身极快。
# ====== 决策口诀 ======
# - CPU密集(纯计算/加密/压缩/图像处理): 用 multiprocessing(多进程) 真正并行;
# - IO密集(网络/磁盘/数据库): 用 threading 或 asyncio(等IO释放GIL/协程);
# - 大量数值计算: 用 numpy 等向量化库(C层计算、释放GIL);
# - 别用多线程去加速纯Python的CPU密集计算(GIL让它白搭还更慢)。
# 核心: 按任务类型选并发模型——CPU密集用多进程(真并行多核)、IO密集用多线程/asyncio(等IO释放GIL)、
# 数值计算用numpy; 别拿多线程硬刚CPU密集任务(GIL下不并行、还有切换开销)。
修复的核心,是"按任务类型(CPU 密集 vs IO 密集)选对并发模型"。正解一:CPU 密集用多进程——ProcessPoolExecutor 起多个进程,每个进程独立的解释器和 GIL、真正并行跑在多核上、接近 N 倍加速;代价是进程间数据要序列化、内存各自独立(比线程重)。正解二:IO 密集才用多线程/asyncio——等网络 IO 时线程释放 GIL、别的线程趁机干活,多线程对 IO 密集有效;更现代的是 asyncio+aiohttp(单线程协程)。正解三:用 numpy 等在 C 层计算(向量化、C 层释放 GIL、本身极快)。决策口诀:CPU 密集→多进程、IO 密集→多线程/asyncio、数值计算→numpy;别用多线程加速纯 Python 的 CPU 密集计算。归根结底:按任务类型选并发模型——CPU 密集用多进程(真并行多核)、IO 密集用多线程/asyncio(等 IO 释放 GIL)、数值计算用 numpy;别拿多线程硬刚 CPU 密集任务。
第三件事:Python 并发相关的其他常见坑
排查后我把 Python 并发相关的其他常见坑也系统梳理了一遍。
Python 并发的其他常见坑
# 1. 多线程加速CPU密集(本文): GIL下不并行还更慢。→ CPU密集用多进程。
# 2. 以为多线程完全没用: 它对IO密集有效(等IO释放GIL)。→ IO密集放心用线程/asyncio。
# 3. 多进程的数据传递开销: 进程间要序列化(pickle), 传大数据慢; 还有些对象不能pickle。
# 4. 多进程共享状态难: 进程内存独立, 共享要用Queue/Manager/共享内存, 比线程复杂。
# 5. 线程安全: 多线程共享可变状态仍要加锁(GIL不保证你的复合操作原子, count+=1也不安全)。
# 6. 死锁: 多个锁的获取顺序不一致 → 死锁(线程/进程都可能)。
# 7. asyncio里调用阻塞代码: 在协程里写同步阻塞IO(requests/time.sleep)会卡住整个事件循环。
# → 用异步库(aiohttp/asyncio.sleep)或run_in_executor。
# 8. 进程/线程池没限制数量: 无限开线程/进程耗尽资源。→ 用池限制并发数。
# 共同根源: 不理解Python并发的特性(GIL的存在、线程/进程/协程各自的适用场景), 想当然地套用
# "多线程=并行加速"的通用直觉; 而Python的并发模型有它特殊的约束和选型逻辑。
# 核心: 理解GIL; 按任务选模型(CPU密集→多进程、IO密集→线程/asyncio、数值→numpy);
# 注意多进程的序列化/共享开销、多线程仍需加锁、asyncio里别阻塞、用池限并发。
排查让我把 Python 并发的其他坑也梳理清了。一、多线程加速 CPU 密集(本文)。二、以为多线程完全没用(对 IO 密集有效)。三、多进程数据传递开销(pickle 序列化)。四、多进程共享状态难。五、线程安全(GIL 不保证复合操作原子,count+=1 也不安全)。六、死锁。七、asyncio 里调阻塞代码(卡住事件循环)。八、池没限数量。它们的共同根源是:不理解 Python 并发的特性(GIL、线程/进程/协程各自的适用场景),想当然套用"多线程=并行加速"的通用直觉;而 Python 的并发模型有它特殊的约束和选型逻辑。核心是:理解 GIL;按任务选模型(CPU 密集→多进程、IO 密集→线程/asyncio、数值→numpy);注意多进程的序列化/共享开销、多线程仍需加锁、asyncio 里别阻塞、用池限并发。下面这张图,是这次 GIL 误区的成因与解法:
第四件事:Python 并发模型选型速查表
这次踩坑后,我把 Python 三种并发模型的适用场景整理成一张表,选型时对照。
| 模型 | 适合 | 为什么 |
|---|---|---|
| 多进程 multiprocessing | CPU密集(计算/加密/压缩) | 各进程独立GIL, 真正并行多核 |
| 多线程 threading | IO密集(网络/磁盘/DB) | 等IO释放GIL, 别的线程能跑 |
| 协程 asyncio | 海量IO并发 | 单线程协程, 比线程更轻量 |
| numpy向量化 | 大量数值计算 | C层计算, 快且释放GIL |
| 纯单线程 | 简单/低并发 | 最简单, 无并发复杂度 |
这张表把 Python 并发选型钉清了。核心是:Python 的并发选型,第一刀就是分清"CPU 密集"还是"IO 密集"——CPU 密集(瓶颈在算)用多进程(绕开 GIL 真并行)、IO 密集(瓶颈在等)用多线程或 asyncio(等待时让出 GIL);选错了方向(拿多线程刚 CPU 密集),再怎么调都白搭。它给我的最大启发是:解决性能问题,第一步永远是"搞清楚瓶颈在哪"——是 CPU 算不过来(CPU bound)、还是在等 IO(IO bound)、还是内存/网络?;不同的瓶颈, 对应完全不同的优化方向;本文我栽就栽在没分清瓶颈类型(我的任务是 CPU 密集),就盲目套用了一个针对"等待"的工具(多线程, 它的价值在于"等的时候让别人干",而我的任务根本不等、一直在算)。这其实是性能优化的第一原则:"先定位瓶颈, 再对症下药"——用 profiler/监控搞清楚"时间/资源到底耗在哪",是 CPU、IO、内存、还是锁竞争;然后针对那个真正的瓶颈去优化;"不分析瓶颈就优化"="蒙着眼睛治病",很可能(像我一样)把药用错了地方、白费力气甚至更糟。先分清瓶颈类型(CPU vs IO)再选并发模型、对症下药——是这个 GIL 坑带给我的性能优化方法论。
第五件事:GIL 暴露的"语言实现细节会颠覆通用直觉"
这次最值得记取的,是一条"通用常识"在 Python 这个具体实现上失效了。我把这类"实现相关"的认知整理成表。
| 通用直觉 | 在Python(CPython)的真相 |
|---|---|
| 多线程=并行=更快 | CPU密集下因GIL不并行, 可能更慢 |
| 整数会溢出 | Python整数任意精度, 不溢出 |
| count+=1是原子的 | 不是, 多线程下仍需加锁 |
| 对象用完就释放 | 引用计数+GC, 循环引用要GC处理 |
| == 比的是值 | 对象默认==比身份(id), 看是否重写 |
这张表道出了一个深刻的认知。核心是:很多我们以为"放之四海皆准"的编程常识(如"多线程能并行加速"),其实依赖于具体语言/运行时的实现;到了某个具体实现(CPython 的 GIL)上,这条通用直觉可能完全失效甚至相反;"通用概念"和"它在某个语言里的具体行为"是两回事。它给我的深刻启发是:掌握一门语言,不能只停留在"通用的编程概念"层面,还要了解"这门语言/运行时的关键实现特性"——CPython 有 GIL、JVM 有 JIT 和 GC、JS 有单线程事件循环、Go 有 goroutine 调度器;这些"实现层面的特性",会深刻地、有时反直觉地影响你写代码的方式(尤其性能、并发、内存);"同一个概念(并发),在不同语言里的'正确做法'可能截然不同",正是因为它们的底层实现不同。这给了我一种学语言的自觉:学一门语言, 要专门去了解它"有什么独特的、会影响我怎么写代码的底层特性/约束"——不被"通用直觉"想当然地误导,而是搞清"在这门语言里, 这件事到底该怎么做、为什么";"了解语言的脾气(它的实现特性和由此带来的约束)",是从"会写另一种语法"到"真正掌握一门语言"的关键一步。认清通用直觉会被语言实现颠覆、专门了解语言的底层特性与约束——是这个 GIL 坑,从学习语言的层面给我的深刻一课。
第六件事:要用并发加速时,我现在的判断习惯
现在每当我想用并发给 Python 程序加速,我都会按这张图先想清楚:
这张图的精髓,是"先定位瓶颈是 CPU 还是 IO,再选对应模型"。先定位瓶颈:CPU 密集(数值用 numpy、通用用多进程)、IO 密集(量大用 asyncio、一般用线程池)。这套习惯,让我从"想加速就开多线程"变成了"先看瓶颈类型再选并发模型"——核心始终是:先分清 CPU 密集还是 IO 密集,CPU 密集用多进程、IO 密集用多线程/asyncio,别拿多线程刚 CPU 密集。
我立下的几条规矩
这场"多线程加速 CPU 密集反而更慢"的事故,换来了我写 Python 并发时,刻进骨子里的几条铁律:
- CPython 有 GIL,同一时刻只有一个线程执行 Python 字节码。多线程不能并行多核。
- 多线程对 CPU 密集任务无效(还更慢)。用不上多核,只有抢锁切换开销。
- CPU 密集用多进程。每进程独立 GIL,真正并行多核。
- IO 密集才用多线程/asyncio。等 IO 时释放 GIL,别的线程能跑。
- 大量数值计算用 numpy。C 层向量化计算、释放 GIL、又快。
- 多线程仍需加锁。GIL 不保证你的复合操作原子,count+=1 也不安全。
- 优化先定位瓶颈(CPU/IO)。对症下药,别凭直觉套用并发。
写在最后
回头看,这场由"用多线程加速 CPU 密集计算"引发的、越加速越慢的事故,真正教给我的,远不止"Python 的 CPU 密集任务要用多进程"这一个技巧。它让我对"把一个领域的'常识',不加验证地搬到另一个具体环境,常常会碰壁",有了一次刻骨的体会。我栽跟头,是因为我把一条在别处成立的常识——"多线程能利用多核并行,从而加速"(这在 C++、Java 里是对的)——想当然地、原封不动地搬到了 Python 这个具体环境里,却没有意识到 Python(CPython)有它自己独特的'规矩'(GIL),使得这条常识在这里恰恰不成立。我用的是"普适的、来自别处的直觉",而忽略了"当前这个具体环境的特殊约束";于是我自以为在"用常识办事",实际是在"用错了地方的常识办错了事"。这让我领悟到一个关于"知识迁移"的深刻认知:知识和经验的"迁移"是把双刃剑——它让我们能举一反三、快速上手新东西(宝贵);但"不加辨别地迁移"也会害人:一条经验在 A 环境成立,搬到 B 环境时,可能因为 B 有 A 所没有的约束/特性而失效甚至相反;"这个做法在我熟悉的地方是对的",不代表"它在这个新地方也对"——每个环境都有它的"上下文",脱离上下文的经验迁移是危险的。这给了我一种迁移知识时的审慎:把一个领域的经验/直觉用到新领域(新语言、新框架、新系统)时,要多一份"这里的规则一样吗?有没有我不知道的特殊约束?"的警惕——既善用迁移带来的效率,又主动去了解新环境的独特之处,验证"老经验在这里还成立吗";"带着对新环境的尊重和好奇去迁移经验",而非"傲慢地假设到处都一样",才能让经验真正成为助力。认清知识迁移需结合新环境的上下文、审慎地验证而非想当然地照搬——这,是我用一次 GIL 误区的事故,换来的、关于 Python、也关于如何正确迁移知识与经验的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想在 Python 里"开多线程加速计算"时,先想起"GIL"这两个字母、转而用多进程,那我对着那个越优化越慢的程序困惑的这段时间,就值了。
—— 别看了 · 2026