8 个线程比单线程还慢:Python GIL 并发避坑

有个数据处理脚本要对几百万条记录做一轮挺重的纯 CPU 计算,单线程跑十几分钟,老板嫌慢。我心想机器是 8 核的,开个线程池把活儿分到 8 个线程上理论上能快好几倍,改完信心满满一跑却当场傻眼:不但没快,反而比单线程还慢了一点,而 CPU 监控显示 8 个核里始终只有一个在忙、其余七个基本在睡觉。我一度怀疑线程池配置有问题,换写法调参数纹丝不动,直到想起 Python 那个绕不开的名字——GIL 全局解释器锁,才恍然大悟:CPython 里有这么一把全局大锁,任何时刻只允许一个线程执行 Python 字节码,我开的 8 个线程根本没法真正并行跑计算,只是在抢同一把锁轮流执行,而抢锁切换本身还有开销,于是 CPU 密集多线程不仅没并行反而更慢。这篇文章从这次 8 线程比单线程还慢的事故出发,讲透 GIL:它是什么、为何因引用计数而存在、等 IO 时会释放锁这条分水岭、先分清 IO 密集还是 CPU 密集、CPU 密集用多进程真并行、IO 密集用多线程或 asyncio、数值计算优先 NumPy 向量化下沉到 C 层、no-GIL 的未来,以及反向误区——有了 GIL 共享状态依然必须加锁。

有个数据处理脚本,要对几百万条记录做一轮挺重的计算——纯 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 并发选型就不会再走我那次的弯路。最后,拧成几条可直接照做的铁律:

  1. 动手前先分清 IO 密集还是 CPU 密集,这是 Python 并发选型的第一分界线。
  2. CPU 密集别用多线程,GIL 下它无法并行,甚至比单线程更慢。
  3. CPU 密集真并行,用多进程,每进程独立 GIL,但要算上数据序列化的成本。
  4. IO 密集用多线程或 asyncio,等 IO 时 GIL 被释放,等待可被重叠。
  5. 海量 IO 并发优先 asyncio,单线程事件循环比堆线程更省资源。
  6. 数值计算优先向量化/C 扩展,NumPy 等会释放 GIL,常比多进程更划算。
  7. 关注 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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Agent 烧穿账单、死循环狂奔:工具调用避坑复盘

2026-5-30 1:51:04

技术教程

HyperLogLog 基数估计 · 面试 10 问 完全指南:速查、踩坑与最佳实践

2026-5-19 0:27:44

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