一段用多线程给 CPU 密集计算加速的 Python 代码,开了八个线程却比单线程还慢,我被 GIL 实实在在上了一课:一次多线程并行误区的深度复盘

一个 CPU 密集计算单线程跑十几秒,我想当然地以为 8 核机器开 8 个线程能快好几倍,结果不但没快反而更慢,而且 8 核机器 CPU 利用率始终只有一个核的量。根因是 CPython 的 GIL(全局解释器锁):同一时刻只有一个线程能执行 Python 字节码,8 个线程只能轮流抢 GIL、本质串行用不上多核,还多了切换开销。本文讲透 GIL 是什么、为何让多线程对 CPU 密集无效却对 IO 密集有效,给出 CPU 密集用多进程、IO 密集用多线程/asyncio、数值用 numpy 的正解,梳理 Python 并发常见坑,最后落到'先定位瓶颈再对症下药、通用直觉会被语言实现颠覆、知识迁移要结合新环境'的认知。

一段用多线程给 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 并发时,刻进骨子里的几条铁律:

  1. CPython 有 GIL,同一时刻只有一个线程执行 Python 字节码。多线程不能并行多核。
  2. 多线程对 CPU 密集任务无效(还更慢)。用不上多核,只有抢锁切换开销。
  3. CPU 密集用多进程。每进程独立 GIL,真正并行多核。
  4. IO 密集才用多线程/asyncio。等 IO 时释放 GIL,别的线程能跑。
  5. 大量数值计算用 numpy。C 层向量化计算、释放 GIL、又快。
  6. 多线程仍需加锁。GIL 不保证你的复合操作原子,count+=1 也不安全。
  7. 优化先定位瓶颈(CPU/IO)。对症下药,别凭直觉套用并发。

写在最后

回头看,这场由"用多线程加速 CPU 密集计算"引发的、越加速越慢的事故,真正教给我的,远不止"Python 的 CPU 密集任务要用多进程"这一个技巧。它让我对"把一个领域的'常识',不加验证地搬到另一个具体环境,常常会碰壁",有了一次刻骨的体会。我栽跟头,是因为我把一条在别处成立的常识——"多线程能利用多核并行,从而加速"(这在 C++、Java 里是对的)——想当然地、原封不动地搬到了 Python 这个具体环境里,却没有意识到 Python(CPython)有它自己独特的'规矩'(GIL),使得这条常识在这里恰恰不成立我用的是"普适的、来自别处的直觉",而忽略了"当前这个具体环境的特殊约束";于是我自以为在"用常识办事",实际是在"用错了地方的常识办错了事"这让我领悟到一个关于"知识迁移"的深刻认知:知识和经验的"迁移"是把双刃剑——它让我们能举一反三、快速上手新东西(宝贵);但"不加辨别地迁移"也会害人:一条经验在 A 环境成立,搬到 B 环境时,可能因为 B 有 A 所没有的约束/特性而失效甚至相反;"这个做法在我熟悉的地方是对的",不代表"它在这个新地方也对"——每个环境都有它的"上下文",脱离上下文的经验迁移是危险的这给了我一种迁移知识时的审慎:把一个领域的经验/直觉用到新领域(新语言、新框架、新系统)时,要多一份"这里的规则一样吗?有没有我不知道的特殊约束?"的警惕——既善用迁移带来的效率,又主动去了解新环境的独特之处,验证"老经验在这里还成立吗";"带着对新环境的尊重和好奇去迁移经验",而非"傲慢地假设到处都一样",才能让经验真正成为助力认清知识迁移需结合新环境的上下文、审慎地验证而非想当然地照搬——这,是我用一次 GIL 误区的事故,换来的、关于 Python、也关于如何正确迁移知识与经验的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次想在 Python 里"开多线程加速计算"时,先想起"GIL"这两个字母、转而用多进程,那我对着那个越优化越慢的程序困惑的这段时间,就值了。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一个把每一步的工具结果都原样堆进上下文的 AI Agent,跑到几十步后要么报 token 超限、要么忘了最初的任务:一次 Agent 上下文管理的深度复盘

2026-6-2 16:14:27

技术教程

一个在 forEach 回调里写 await 的批处理脚本,以为会一个个等着处理完,结果还没处理完就往下走、还吞掉了异常:一次 JavaScript 异步遍历的深度复盘

2026-6-2 16:25:43

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索