这是一次"信心满满地优化,结果越优化越慢"的尴尬经历。我有一个 Python 写的数据处理脚本,要对几百万条数据做一通密集的计算,单线程跑下来要好几分钟,慢。我看了看服务器,8 核 CPU,跑这脚本时却只有一个核在忙、其余 7 个核在睡大觉——这不浪费吗?于是我信心满满地动手优化:把任务拆成 8 份,开 8 个线程并行处理,想着"8 个核一起干,这下总该快 8 倍了吧"。改完一跑,我傻眼了:不仅没快,反而比单线程还慢了一点!
我对着代码百思不得其解:线程明明开起来了,任务也确实拆成 8 份分下去了,逻辑没毛病啊,怎么会越多线程越慢?难道是我的并行写法有 bug?直到我去补了 Python 并发的底层知识,才被一个词点醒——GIL(全局解释器锁,Global Interpreter Lock)。原来,CPython(我们最常用的 Python 解释器)有一把全局的大锁:无论你开多少个线程,同一时刻,也只允许有一个线程在执行 Python 字节码。这意味着,我那 8 个线程,根本不是在"并行计算",而是在抢这把唯一的锁、轮流执行——它们本质上还是串行的,而且还额外增加了线程之间来回切换、抢锁的开销,所以非但没快,反而更慢了。我那 7 个睡大觉的核,根本没被这 8 个线程用起来。这篇文章,就从这次"开了多线程反而更慢"的事故讲起,把 Python 并发里这个最反直觉、也最坑新手的 GIL,以及"到底该用多线程还是多进程"这个关键抉择,讲清楚。
故障现场:8 个线程,却只有 1 个核在干活
先把那段"越优化越慢"的代码还原一下:
import threading
def heavy_compute(data_chunk):
# 一个 CPU 密集的计算: 大量纯计算, 没有 IO
result = 0
for x in data_chunk:
result += complex_math(x) # 纯 CPU 运算
return result
# 我的"优化": 拆成 8 份, 开 8 个线程, 以为能 8 核并行
chunks = split(data, 8)
threads = [threading.Thread(target=heavy_compute, args=(c,)) for c in chunks]
for t in threads: t.start()
for t in threads: t.join()
# 期望: 8 核并行, 快 8 倍
# 实际: 8 个线程抢 GIL 轮流跑, 还是只用 1 个核, 加上切换开销, 比单线程还慢!
问题的核心,是 heavy_compute 是一个纯 CPU 密集的任务——它全程都在做计算,没有任何"等待"(没有读文件、没有网络请求、没有查数据库)。而 GIL 的规则是:执行 Python 字节码,必须先持有 GIL;同一时刻只有一个线程能持有它。对于我的 8 个 CPU 密集线程,它们个个都想一刻不停地执行字节码、个个都想霸占 GIL,于是它们只能你方唱罢我登场,抢到锁的跑一会、被切换出去、换另一个抢到锁的再跑一会……从头到尾,真正在执行计算的,永远只有一个线程,也就只用到了一个 CPU 核;另外 7 个核,自始至终都在闲着。
所以我那个"8 线程并行"的优化,完全是个错觉:它没有带来任何并行,8 个线程加起来的总计算量和单线程一模一样(都挤在一个核上串行跑),却平白多出了"8 个线程反复抢锁、被操作系统来回调度切换"的额外开销。多出来的开销,正是它比单线程还慢的原因。这就是 GIL 给无数 Python 新手上的第一课,也是最反直觉的一课:在 CPython 里,多线程,并不能让 CPU 密集型任务获得真正的并行加速。
第一件事:理解 GIL——一把"同一时刻只许一个线程跑"的大锁
要避开这个坑,必须理解 GIL 到底是什么。GIL,全局解释器锁,是 CPython 解释器内部的一把全局互斥锁。它的作用是:保证同一时刻,只有一个线程能执行 Python 字节码。换句话说,哪怕你的机器有 100 个核、你开了 100 个线程,在执行纯 Python 计算时,这 100 个线程也只能轮流地、一个接一个地用那唯一的"执行权",永远没法真正同时跑。
GIL 的本质: 一把全局的、同一时刻只能一个线程持有的"执行许可证"
线程1 ──持有GIL──> 执行字节码 ──释放──┐
线程2 ──────────────等待──────────────抢到GIL──> 执行 ──释放──┐
线程3 ──────────────────等待───────────────────────抢到──> 执行...
(任意时刻, 只有一个线程在真正执行 Python 代码)
为什么有 GIL? CPython 的内存管理(引用计数)不是线程安全的,
用一把大锁保证同一时刻只有一个线程动 Python 对象, 实现简单且高效(对单线程)。
为什么 CPython 要设计这么一把看起来"碍事"的锁?简单说,是历史和实现上的权衡:CPython 用"引用计数"来管理内存,而引用计数的增减不是线程安全的;用一把全局大锁,简单粗暴地保证"同一时刻只有一个线程在操作 Python 对象",就避免了为每个对象都加锁的巨大复杂度和开销。这个设计让 CPython 的实现简单、且对单线程程序很高效,但代价就是——它牺牲了多线程在 CPU 密集任务上的并行能力。所以,GIL 不是一个"bug",而是 CPython 的一个根本性的设计特征;你不能消灭它,只能理解它、绕开它。而理解它的第一步,就是认清:它锁住的,是"执行 Python 字节码"这件事。
第二件事:CPU 密集用多进程,绕开 GIL
既然 GIL 锁住的是"一个进程内的多个线程",那绕开它的办法就很直接了:用多进程(multiprocessing)而不是多线程。因为每个进程都有自己独立的 Python 解释器、自己独立的一把 GIL——多个进程之间,谁也不和谁抢锁,于是它们可以真正地、同时地在多个 CPU 核上并行计算。
from multiprocessing import Pool
def heavy_compute(data_chunk):
result = 0
for x in data_chunk:
result += complex_math(x)
return result
if __name__ == "__main__":
chunks = split(data, 8)
# 用进程池: 8 个独立进程, 各有各的 GIL, 真正并行用满 8 个核
with Pool(processes=8) as pool:
results = pool.map(heavy_compute, chunks)
total = sum(results)
# 现在 8 个核都忙起来了, 这才是真正的并行加速, 接近快 8 倍
把 threading 换成 multiprocessing,效果立竿见影:8 个进程被操作系统分到 8 个核上,真正地同时跑了起来,我那个本来要好几分钟的脚本,这下接近快了 8 倍。这就是绕开 GIL 的核心思路:GIL 是"进程内"的锁,锁不住"进程间";所以对 CPU 密集型任务,用多进程让每个核都跑一个独立的进程,就能实现真正的并行。当然,多进程也有它的代价:进程比线程"重"(创建开销大、占内存多),而且进程之间不共享内存(数据要通过序列化来回传递,有通信开销)——所以它不适合那种需要频繁共享大量数据、或任务粒度很小的场景。但对"把一大块数据拆成几份、各自独立算、最后汇总"这种典型的 CPU 密集任务,多进程是 Python 里实现并行加速的标准答案。
第三件事:那多线程到底什么时候有用?——IO 密集
说了这么多 GIL 的"坏话",你可能会问:那 Python 的多线程岂不是一无是处?当然不是。多线程在另一类任务上,依然非常有用——IO 密集型任务。关键在于一个细节:当一个线程进行 IO 操作(读写文件、网络请求、查数据库)、陷入"等待"时,它会主动释放 GIL,让其它线程趁机去执行。这一下,多线程的价值就出来了。
import threading
def fetch_url(url):
resp = requests.get(url) # 网络请求: 大部分时间在"等"响应
return resp.text # 等待时, 这个线程会释放 GIL!
# IO 密集场景: 多线程非常有效
urls = [...100个url...]
threads = [threading.Thread(target=fetch_url, args=(u,)) for u in urls]
for t in threads: t.start()
for t in threads: t.join()
# 线程A 在等 url1 的响应时释放 GIL → 线程B 趁机去发 url2 的请求 → ...
# 大量的"等待"被重叠起来了, 总耗时大大缩短(虽然计算仍是串行, 但瓶颈在等待)
为什么 IO 密集任务多线程就有效?因为 IO 密集任务的特点是:大部分时间不是在"计算",而是在"等待"(等网络响应、等磁盘读完)。而一个线程在等待 IO 时,会释放 GIL——这就给了其它线程执行的机会。于是,当线程 A 在傻等 url1 的响应时,线程 B 可以趁机去发起 url2 的请求,线程 C 去发 url3……大量原本要串行等待的 IO,被"重叠"了起来:你不再是等完一个再等下一个,而是同时等着一堆。总耗时取决于"最慢的那个 IO",而不是"所有 IO 等待时间之和",于是大大缩短。这就是多线程对 IO 密集任务有效的根本原因——它利用的,正是"IO 等待时 GIL 被释放"这个机会。我把这个关键区别画成图:
这张图是整篇文章最该记住的东西:选并发方案前,先判断你的任务是"CPU 密集"还是"IO 密集"——CPU 密集(瓶颈在计算)用多进程绕开 GIL 实现真并行;IO 密集(瓶颈在等待)用多线程或 asyncio 把等待重叠起来。用错了类型(像我那样给 CPU 密集任务用多线程),不仅没效果,还可能更慢。这个"先分清任务类型,再选并发模型"的判断,是 Python 并发编程的第一性问题。
第四件事:IO 密集的更优解——asyncio 协程
对 IO 密集任务,多线程虽然有效,但线程本身也有开销(每个线程占内存、有创建和切换成本),开成千上万个线程并不现实。Python 为高并发 IO 提供了一个更轻量、更高效的方案:asyncio(异步协程)。它用单线程 + 事件循环,就能高效地"重叠"海量的 IO 等待,比多线程能扛住高得多的并发量。
import asyncio, aiohttp
async def fetch_url(session, url):
async with session.get(url) as resp: # await 时, 让出控制权给别的协程
return await resp.text()
async def main(urls):
async with aiohttp.ClientSession() as session:
# 成千上万个 IO 任务, 单线程协程就能高效并发, 不用开那么多线程
tasks = [fetch_url(session, u) for u in urls]
return await asyncio.gather(*tasks)
asyncio.run(main(urls))
# 协程在 await(等IO)时主动让出, 让事件循环去跑别的协程
# 单线程内把海量 IO 等待重叠起来, 比多线程更轻、并发上限更高
asyncio 和多线程,对 IO 密集任务的核心思路是一样的——都是利用"IO 等待"的时间去做别的事;区别在于实现方式:多线程靠操作系统调度多个线程(线程较重,且依赖 GIL 在 IO 时释放),而 asyncio 靠单线程内的"事件循环 + 协程主动让出"(await 时让出控制权),没有线程切换开销、也不受 GIL 困扰,因此能用极少的资源扛住极高的 IO 并发。所以对"海量 IO、高并发"的场景(比如同时请求几千个 URL、做高并发的网络服务),asyncio 通常是比多线程更优的选择。当然它的代价是"传染性"和心智负担(整条调用链都得是 async 的)——这是另一个话题了。把 Python 几种并发方案放在一起对比:
| 方案 | 适合 | 能利用多核吗 | 特点 |
|---|---|---|---|
| 多线程 threading | IO 密集 | 不能(受 GIL) | 等 IO 时释放 GIL, 写法简单 |
| 多进程 multiprocessing | CPU 密集 | 能(各有 GIL) | 真并行, 但进程重、通信有开销 |
| asyncio 协程 | 海量 IO 高并发 | 不能(单线程) | 极轻量, IO 并发上限高, 有传染性 |
| C 扩展(numpy 等) | 密集数值计算 | 能(计算时放 GIL) | 底层 C 算, 绕过 Python 层 GIL |
表里最后一行还藏着个有用的"彩蛋":很多用 C 写的扩展库(比如 numpy),在执行那些纯粹的、密集的数值计算时,会主动释放 GIL——因为它的计算发生在 C 层、不碰 Python 对象。所以,如果你的 CPU 密集任务能用 numpy 这类库的向量化操作来表达,那不仅单线程就能跑得飞快(C 实现 + 向量化),有时配合多线程还能获得一定的并行(因为它计算时放了 GIL)。这也提示了一条优化 CPU 密集 Python 代码的常见路径:与其费劲搞多进程,不如先看看能不能用 numpy 等 C 扩展库,把热点计算"下沉"到 C 层去做——往往又快又省心。
第五件事:先分清"CPU 密集"还是"IO 密集"
讲到这儿,你应该发现了:Python 并发选型的一切,都建立在一个前置判断上——你的任务,到底是 CPU 密集,还是 IO 密集?判断错了,后面全错(就像我把 CPU 密集当成了能靠多线程加速的任务)。我把这两类任务的特征和对策列成一张表,帮你快速判断:
| 维度 | CPU 密集型 | IO 密集型 |
|---|---|---|
| 瓶颈在 | 计算(CPU 一直忙) | 等待(CPU 大多在闲等) |
| 典型任务 | 大量数学运算、图像处理、加解密 | 网络请求、读写文件、查数据库 |
| 跑时 CPU 表现 | 某核 100% 满载 | CPU 占用低, 大量时间在 wait |
| 该用 | 多进程 / C 扩展 | 多线程 / asyncio |
| 多线程效果 | 无效甚至更慢(本次事故) | 有效 |
怎么快速判断一个任务属于哪类?有个特别朴素的办法:跑这个任务时,看一眼 CPU 占用。如果某个核一直 100% 满载、CPU 是瓶颈,那就是 CPU 密集;如果 CPU 占用很低、程序大量时间在"等"(等网络、等磁盘),那就是 IO 密集。我那次的脚本,跑起来时一个核 100% 满载——这其实早就明明白白地告诉我"这是 CPU 密集任务"了,只是我当时没这个意识去看。分清任务类型,是 Python 并发优化的第一步,也是最关键的一步;这一步对了,选多进程还是多线程才有意义;这一步错了,后面再怎么努力都是南辕北辙。
一张"Python 并发怎么选"的决策图
把这次踩坑沉淀成一张图。每次你想用并发加速一个 Python 任务时,照着它走一遍,就不会再像我一样南辕北辙:
这张图的起点,永远是那句"先看 CPU 是满载还是闲等"——它一句话就帮你分清了 CPU 密集和 IO 密集,而这正是后面一切选型的分水岭。CPU 密集就往多进程/C 扩展走,IO 密集就往多线程/asyncio 走。把这张图刻在脑子里,"开了多线程反而更慢"这种事故,就再也不会发生了。
我立下的几条 Python 并发规矩
这次"多线程越优化越慢"的事故后,我给自己立了几条 Python 并发的规矩:
- 先判任务类型:用并发前,先通过 CPU 占用判断任务是 CPU 密集还是 IO 密集,这是选型的根本前提。
- CPU 密集用多进程:纯计算任务用 multiprocessing 绕开 GIL 实现真并行,绝不用多线程(对它无效甚至更慢)。
- IO 密集用多线程或 asyncio:等待型任务用多线程(简单)或 asyncio(高并发更优),把 IO 等待重叠起来。
- 密集计算先想 C 扩展:CPU 密集且是数值计算时,优先考虑用 numpy 等向量化方案,往往比多进程更省心。
- 别迷信"线程越多越快":线程数不是越多越好,受 GIL、切换开销、任务类型制约,要实测而非想当然。
- 优化必先测量:动手"优化"前,先测出瓶颈到底在哪(是计算还是等待),别凭感觉拍脑袋加并发。
- 多进程注意通信开销:多进程不共享内存,数据传递要序列化;任务粒度太小、数据共享太频繁时,开销可能盖过收益。
这几条里,第一条和第六条是纲。我尤其想强调第六条"优化必先测量":我那次的尴尬,根源就在于我没测量就动手"优化"了——我只是想当然地觉得"多核闲着是浪费,开多线程肯定能用起来",却从没去确认"我的瓶颈到底是不是计算、多线程到底能不能解决它"。这犯了性能优化的大忌:凭直觉、凭想象去优化,而不是凭测量、凭数据。如果当时我先花一分钟看一眼"跑这脚本时一个核 100% 满载、这是 CPU 密集任务",我就根本不会去选那条注定无效的多线程之路了。性能优化的第一原则,永远是"先测量,定位真正的瓶颈,再对症下药";凭感觉优化,十有八九是在错误的方向上白费力气,甚至像我一样帮倒忙。
写在最后:别被"想当然"带进沟里
这次"开了多线程反而更慢"的经历,留给我最深的,其实不是 GIL 这个知识点本身,而是一记关于"想当然"的响亮耳光。回头看,我整个出错的过程,是被一连串"想当然"推着走的:我想当然地以为"多核闲着就是浪费,得用起来";想当然地以为"开多线程就能用上多核";想当然地以为"线程越多越并行越快"。这每一个"想当然",单独看都"很有道理、很符合直觉",可它们恰恰都踩在了 Python 并发那个最反直觉的特性(GIL)上,于是一路把我带进了沟里。而真正的问题在于:我在这一连串想当然的过程中,从没有停下来,去验证一下我的这些假设到底成不成立。
想通这一点,我对"直觉"在工程中的地位,有了更清醒的认识。直觉是宝贵的,它能帮我们快速地形成猜想、指明大致的方向;但直觉绝不能代替验证——尤其是在那些"底层机制反直觉"的领域(并发、性能、分布式……),你的直觉很可能恰恰是错的,因为这些领域的真实规律,往往违背我们朴素的常识。 "多核就该多线程并行加速",这是多么符合直觉啊,可在 Python 的 GIL 面前,它就是错的。如果我对自己的直觉多一分警惕、动手前多花一分钟去测量验证,就能避免那场尴尬的"负优化"。
所以,如果你也在做性能优化、或在任何一个"底层机制可能反直觉"的领域工作,我想把这次踩坑最想说的话送给你:珍惜你的直觉,但永远不要盲信它——尤其当你要基于某个直觉去做重要决策(比如怎么优化)时,先停下来,问自己一句:"我这个假设,验证过吗?有数据支撑吗?"对性能,就去测量(看 CPU、看耗时、看 profile);对机制,就去搞懂它的底层原理(像我后来去补 GIL 的知识)。用"测量和理解"去校验"直觉",是把工程从"凭感觉的玄学"变成"靠证据的科学"的关键一步。那次帮倒忙的多线程,最终用一次"负优化"教会了我:在动手改造一个系统之前,先确保你真的搞懂了它、量过了它——别让一个未经验证的"想当然",把你的努力,变成南辕北辙的徒劳。愿你我的每一次优化,都建立在测量和理解的坚实地基上,而不是漂浮在想当然的流沙之中。
说到底,这次"负优化"花掉我的那点时间不算白费——它让我把 GIL、把"测量先于优化"这两件事,从书本上的道理,变成了刻进骨子里的本能。一个能让你下次少走半天弯路的教训,无论当时多尴尬,都是划算的。愿你也能把每一次"想当然的翻车",都转化成一份对底层多一分敬畏、对直觉多一分警惕的清醒。
—— 别看了 · 2026