“少熬三天夜”这个标题,写出来一点都不夸张。上上周末我在公司优化一个订单归并服务,单线程处理几十万条数据要跑四十多秒,领导嫌慢让我提效。我当时第一反应就是加线程,Python 多线程谁不会?一行ThreadPoolExecutor丢上去,结果一测,比原来还慢了近十秒。那三天我基本在怀疑人生,明明线程数翻倍了,CPU 占用率反而掉下去了,日志里全是上下文切换,数据量还一点没少。后来硬着头皮把 CPython 源码、GIL 相关的机制、还有一堆社区讨论翻了个底朝天,才算把这块彻底想明白。这篇就把我踩过的坑、理清的原理,还有面试里被问烂的问题一次说清楚。
如果你也遇到过“多线程加了反而变慢”的邪门现象,或者面试前被 GIL 折磨过,又或者只是单纯想搞懂“Python 多线程到底是不是假把式”,这篇应该能让你少走几周弯路。文章默认你会写 Python 基础语法,但哪怕是刚装完 Python 的小白,跟着实操部分跑一遍也能有收获。
1. 先把概念捋清楚:GIL 是什么,为什么会有 GIL
1.1 我那次熬夜排错,问题到底出在哪
先复盘一下我那个订单归并服务。逻辑本身不复杂,读取一批订单数据,按用户 ID 做聚合,算金额、算笔数,最后写回结果表。最笨的写法是单线程循环,数据量一大就卡在 CPU 计算上。我当时想的很简单,既然 CPU 跑不满,那就上多线程并行算,每个线程处理一部分订单,最后合并。
代码改完以后我测了一下,结果很奇怪:4 个线程跑出来的耗时不是单线程的四分之一,而是比单线程还多出 30% 左右。一开始我怀疑是锁竞争,又把数据分区、加锁方式全部检查了一遍,没什么毛病。后面用py-spy dump看线程栈,才发现所有线程其实都阻塞在解释器同一条锁上,压根没真正并行。那一刻我才意识到,问题不在我的业务代码,而在 Python 解释器本身,也就是 GIL。
很多人对 GIL 的理解停留在“多线程不能并行”这句话上,但真到写代码的时候,很少有人会意识到它的代价到底有多大。它不是让你的程序跑得慢一点,而是直接决定了你选线程方案还是进程方案,甚至决定你要不要换一门语言来处理某一块逻辑。
1.2 GIL 的全名和它的运作方式
GIL 的全称是 Global Interpreter Lock,意思是全局解释器锁。CPython,也就是你从 python.org 下载安装的那个官方版本解释器,内部靠这把锁来保证同一时刻只有一个线程在执行 Python 字节码。你没看错,是字节码层面的一把全局锁,不管你机器有多少个核,只要跑的是纯 Python 代码,同一个进程里真正在“算”的线程永远只有一个。
为什么需要这把锁?根源在 CPython 的内存管理机制。Python 里的对象是通过引用计数来管理生命周期的,比如你写a = [],这个列表对象的引用计数是 1,再写b = a,引用计数变成 2。如果有两个线程同时去修改同一个对象的引用计数,就可能在计数上出现竞争,轻则内存泄漏,重则直接崩溃。为了避免这种竞争,最粗暴也最有效的办法就是在解释器级别加一把大锁,任何线程想执行字节码之前都得先抢到这把锁。
GIL 的切换机制在 Python 3.2 之后有过一次大改。现在默认情况下,一个线程连续执行大约 5 毫秒(这个值可以通过sys.setswitchinterval()调整)就会主动释放 GIL,让其他线程去抢;另外,线程遇到 IO 操作,比如读文件、发网络请求、打印日志,也会立刻释放 GIL。所以 GIL 不是永远锁死,而是“一会锁一会放”,具体表现取决于你的程序是 CPU 密集还是 IO 密集。
1.3 为什么 CPython 要背着这把锁往前走
很多人会问,GIL 这么碍事,为什么 Python 官方不早点干掉它?答案没那么简单。GIL 虽然限制了多线程并行,但也带来了两个现实好处:一是单线程程序跑得更快,因为内存管理不需要为每个对象单独加细粒度的锁;二是 C 扩展模块写起来更简单,很多底层库默认就有线程安全性,不用担心多个 C 线程同时操作 Python 对象。
历史上不是没尝试过移除 GIL,但每次改造都会导致单线程性能大幅下降,而绝大多数 Python 程序在大多数时间里本来就是单线程跑的。为了一个多线程并行能力,牺牲所有单线程程序的性能,这笔账怎么算都不划算。于是 GIL 就一直留到现在,成为每一个 Python 开发者绕不开的关卡。
这里要澄清一个常见误解:GIL 不是 Python 语言本身的限制,而是 CPython 的实现细节。PyPy、Jython 这些解释器就没有 GIL,只是大家平时用的官方版本恰好是 CPython,所以讨论 GIL 时通常默认说的是 CPython。理解了这层背景,你才能真正理解为什么说它是个“假把式”,也知道该从哪里下手绕过它。
2. 多线程到底“假”在哪:一次实测看清楚
2.1 CPU 密集场景:多线程反而更慢
先看最典型的反面案例:纯计算型任务。我拿一台 8 核 16 线程的机器做了个小实验,任务本身很简单,就是 4 个线程各自对 5000 万个整数做累加计算,这是教科书级的 CPU 密集场景。代码如下:
import time from concurrent.futures import ThreadPoolExecutor def heavy_calc(n): total = 0 for i in range(n): total += i ** 2 return total if __name__ == "__main__": n = 50000000 start = time.time() heavy_calc(n) print(f"单线程耗时: {time.time() - start:.2f}s") start = time.time() with ThreadPoolExecutor(max_workers=4) as executor: list(executor.map(heavy_calc, [n] * 4)) print(f"4线程耗时: {time.time() - start:.2f}s")实测结果让我很清醒:单线程跑 5000 万次累加大约用了 2.1 秒,4 线程各跑 5000 万次加在一起却要 4.7 秒。线程越多越慢,8 线程直接冲到 8.5 秒。为什么?因为每个线程都在抢同一把 GIL,抢到锁的线程在 CPU 上跑一段时间,没抢到的线程就在原地空转等待。四线程并行并没有让计算量分散到四个核上,反而多出了大量锁调度和上下文切换的开销。
所以如果你用多线程去跑图片处理、数据压缩、复杂计算这类 CPU 密集任务,结果多半是失望的。核心问题不是你的代码写得不好,而是 GIL 决定了同一时刻只有一个线程能真的跑 Python 字节码,其他线程都在打酱油。
2.2 IO 密集场景:多线程香到不行
反过来看另一个场景:网络爬虫。爬虫的任务主要花在等待服务器响应上,CPU 本身没多少计算量。我在相同环境里用 requests 库并发请求 100 个 API 接口,单线程逐个请求耗时大约 100 秒,换成 10 个线程并发,总耗时直接降到 12 秒左右;用到 20 个线程,大概 8 秒就能跑完。
原因在于线程进行网络请求时会进入阻塞状态,而阻塞期间 GIL 是释放的,其他线程可以接着跑。看下面的简化代码:
import time import requests from concurrent.futures import ThreadPoolExecutor urls = ["https://example.com/api"] * 100 def fetch(url): resp = requests.get(url) return resp.status_code start = time.time() with ThreadPoolExecutor(max_workers=20) as executor: list(executor.map(fetch, urls)) print(f"20线程耗时: {time.time() - start:.2f}s")这段代码跑起来,你会发现 20 个线程能同时发起网络请求,谁收到响应谁就继续往下走,谁在等待谁就把 GIL 让出来。这种场景下,Python 的“假多线程”反而变成了非常顺手的并发工具。
所以,判断多线程有没有用,先看你的任务属于什么类型。CPU 密集场景多线程是鸡肋,IO 密集场景多线程是真香。很多新手一上来就听别人说“Python 多线程没用”,结果连爬虫这种典型场景都不敢用多线程,这属于把结论记歪了。
2.3 用一张表对号入座,选对并发模型
我平时判断业务该用哪种并发方案,基本按下面这张表走。你可以直接收藏,后面写代码的时候对着看就行。
| 任务类型 | 典型例子 | 推荐方案 | 原因 |
|---|---|---|---|
| CPU 密集 | 计算、压缩、加解密 | 多进程 / C 扩展 | 绕过 GIL,真正用上多核 |
| IO 密集 | 爬虫、文件读写、数据库查询 | 多线程 / 协程 | 等待 IO 时 GIL 自动释放 |
| 混合型 | 先读数据再分析再写结果 | 多进程 + 协程 | 计算和 IO 分离,各取所长 |
| 高并发连接 | Web 服务、消息推送 | 协程 | 单线程内切换开销极小 |
这张表看着简单,其实是我踩了不少坑才总结出来的。以前我总觉得并发就等于多线程,后来才发现选错模型比不优化还惨。比如在 CPU 密集任务里强行上线程池,代码复杂度上去了,性能反而倒退。现在我做技术方案之前都会先问自己一句:瓶颈到底是在计算还是在等待?这个问题的答案,直接决定后面所有架构选型。
3. 绕开 GIL 的主流方案,我都试了一遍
3.1 多进程:multiprocessing 的正确打开方式
既然 GIL 锁的是线程,那我不在线程层面玩,改成多进程行不行?行。每个进程有自己独立的 Python 解释器和内存空间,也就有自己独立的 GIL,互不干扰。真正的多核并行能力,恰恰是通过多进程来实现的。
我拿同样的 CPU 密集任务测过,4 个进程跑 4 份计算,耗时从单线程的 2.1 秒降到 0.6 秒左右,接近线性加速。代码上最简单的方式是用ProcessPoolExecutor,接口和ThreadPoolExecutor几乎一样:
from concurrent.futures import ProcessPoolExecutor def heavy_calc(n): total = 0 for i in range(n): total += i ** 2 return total if __name__ == "__main__": with ProcessPoolExecutor(max_workers=4) as executor: list(executor.map(heavy_calc, [50000000] * 4))需要注意三点:第一,进程池的入口函数必须能被if __name__ == "__main__"保护起来,否则 Windows 上会递归启动进程;第二,进程之间不共享内存,传参和返回值要经过序列化,如果数据量巨大,序列化成本可能吃掉并行收益;第三,进程数不要盲目加大,超过 CPU 物理核心数后,额外的进程调度只会让性能变差。
还有一个很多人不知道的细节:进程启动成本比线程高得多,如果你只是临时甩几个短任务,进程创建的开销可能比任务本身还大。所以多进程适合“长任务大计算”的场景,不适合那种“每隔几秒丢一个小任务进来”的场景。
3.2 协程:单线程里的高效调度
协程是另外一个完全不同的思路:既然 GIL 只让一个线程跑字节码,那我干脆就一个线程,自己控制任务切换。Python 的asyncio就是干这个的,它不需要操作系统介入,在事件循环内部就把任务切来切去,切换开销远小于线程上下文切换。
协程最爽的场景是大量 IO 密集型并发,比如高并发 HTTP 请求、WebSocket 长连接。我用aiohttp写过一个并发抓取任务,1000 个 URL 并发请求,耗时和 20 线程的方案差不多,但内存占用低了很多。因为线程每个大约占 8MB 栈空间,协程任务只要几 KB,谁更适合大规模并发一目了然。
如果你是从头写协程代码,核心就两步。第一步定义异步函数,用async def;第二步在函数内部遇到 IO 时用await让出控制权。一个典型的抓取任务长这样:
import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as resp: return resp.status async def main(): async with aiohttp.ClientSession() as session: tasks = [fetch(session, "https://example.com/api") for _ in range(100)] results = await asyncio.gather(*tasks) print(len(results)) asyncio.run(main())这里有个坑我必须提醒你:协程只对你写的异步代码有效,如果你在协程内部调用了requests.get()这种同步阻塞 IO,整个事件循环会卡住,其他协程全得排队等。所以用协程意味着你的 IO 库也得换成异步版本,比如用aiohttp代替requests。这是协程最容易翻车的地方,我见过有人把同步爬虫塞进async函数里,结果跑起来比单线程还慢。
3.3 把脏活交给 C 扩展:手动释放 GIL
有时候既不想换进程,又想把多核用起来,还有一个思路:把重计算外包给 C 扩展。很多底层库在编译时就知道自己在干什么,可以在计算期间主动释放 GIL,让其他 Python 线程趁虚而入。
最简单的例子是 NumPy。你用 NumPy 做数组运算时,底层是 C 代码在跑,CPU 密集的那段本质不在 Python 字节码里,所以受 GIL 影响很小。这也是为什么很多人说“用 Python 做数据处理没问题”,前提是你把计算量尽量压到 NumPy 或 pandas 这些 C 扩展里,而不是用纯 Python 的 for 循环去死磕。
如果你想自己写 C 扩展来释放 GIL,CPython 提供了现成的宏,叫Py_BEGIN_ALLOW_THREADS和Py_END_ALLOW_THREADS。在一段不碰 Python 对象的纯 C 计算前后加上这两个宏,就能在计算期间放开 GIL,让其他线程去抢锁执行。这个技术对多数业务开发者来说用不上,但如果你在写库、写插件、给 Python 做加速,这招非常实用。
Cython 也能干类似的事,你可以在 Cython 里把循环部分的nogil标记出来:
cdef double heavy_calc_cy(double n) nogil: cdef double total = 0 cdef int i for i in range(n): total += i * i return total使用nogil声明后,这段函数执行时不会持有 GIL,多个线程调用它就能并行跑。代价是函数内部不能直接操作 Python 对象,只能处理 C 类型。你看,这本质上就是把计算能力和 Python 生态解耦,让 GIL 管住 Python 层,C 层干活时保持自由身。
3.4 Python 3.13 的无 GIL 实验构建:路还长
除了绕开 GIL,社区其实一直在想办法干掉它。PEP 703 提出了 free-threaded 方案,从 Python 3.13 开始官方提供了不带 GIL 的实验性构建,社区里管它叫 no-GIL 模式。这个版本让多个线程真正并行执行 Python 字节码,听起来很美好,但现实是很多 C 扩展还没有做好适配,因为有相当一部分扩展库的线程安全是建立在“有 GIL”这个前提上的。
我试过几次,简单的纯计算任务在 free-threaded 构建里确实有加速,但一用到科学计算、图像处理这些依赖 C 扩展的库,要么警告,要么直接崩,生产环境根本不敢碰。官方的态度也很明确,先跑通实验,等生态成熟后再谈默认开启。
所以现阶段如果你要解决性能问题,老老实实用多进程或者协程才是稳的。GIL 确实在慢慢松动,但在它真正消失之前,我们还是要学会在它眼皮子底下干活。
4. 顺着 GIL 往下问:面试题和避坑经验一次讲透
4.1 高频追问与答题思路
面试官问到 Python 并发,十次有八次会落到 GIL 上。我把被问过的问题和推荐回答整理了一下,方便你快速过一遍。
第一个问题:“Python 多线程到底能不能并行?”标准回答是先区分概念:CPython 的 GIL 导致同一进程的多线程无法同时执行纯 Python 字节码,所以 CPU 密集任务不能并行加速;但遇到 IO 阻塞时,GIL 会释放,所以多线程对 IO 密集任务有效。这个回答能直接体现你理解 GIL 的本质,而不是背概念。
第二个问题:“GIL 能保证线程安全吗?”这题容易踩坑。很多人的答案是“能”,但 GIL 只保证单个字节码指令的原子性,不保证一段代码的原子性。比如a += 1这行代码,底层其实是读取、相加、写回三步操作,多线程并发执行时完全可能交错,结果比预期小。面试官通常想听的正是这个例子。
第三个问题:“多线程、多进程、协程怎么选?”我一般这样答:CPU 密集用多进程,IO 密集用多线程或协程,高并发 Web 服务优先协程。如果任务混合,可以外面套多进程、里面跑协程。核心逻辑是绕开 GIL 的限制,同时降低资源开销。
第四个问题:“GIL 有没有可能被移除?”可以从 PEP 703 说起,提到 Python 3.13 的实验性 free-threaded 构建,同时强调 C 扩展生态还没跟上,所以短时间内官方 CPython 默认还是带 GIL。这样回答显得你既关注前沿又了解现实。
4.2 GIL 不是万能的:这两类“线程安全”陷阱要命
GIL 的存在会让很多新手产生一种错觉——Python 多线程是安全的,所以不用加锁。这个想法非常危险。GIL 保护的是解释器内部对象不被并发访问搞坏,但它不保护你的业务数据完整性。
我踩过的一个真实坑是这样的:在线程池里做计数器累加,每个线程是独立的计数值,最后统一汇总到全局字典。当时我想着 Python 的字典操作是原子的,直接global_dict[key] += value应该没问题。结果高并发下总是丢数据,原来dict[key] += value分解成读、加、写三步之后,多个线程可能同时读到旧的 value,然后互相覆盖。
还有一个更隐蔽的坑是“混合使用多线程和 C 扩展”。有些 C 扩展库在设计时假设调用者已经持有 GIL,它们内部不会再做额外保护。如果你在无 GIL 模式下操作这些库,或者用 Cython 写了nogil函数又回头去碰 Python 对象,很容易把解释器搞崩溃。这种错误不是逻辑错误,是内存级别的非法访问,排查起来非常痛苦。
我的经验是:无论 GIL 存在与否,只要涉及多个线程共享可变数据,一律加锁或用queue来传递数据。把“共享数据”这件事降到最低,才是安全的根本思路。
4.3 GIL 相关面试官常追问的细节:说实话,问到这层说明想招高级
有些面试官会继续往下问:“你平时怎么排查 GIL 导致的性能问题?”我通常讲三步。第一步看 CPU 占用率,如果程序很忙但 CPU 总使用率上不去,多半在等锁;第二步用py-spy dump看线程栈,能看到线程停在哪里;第三步把代码改成多进程或者协程做对比实验,用数据确认瓶颈是不是 GIL。
还有一个细节:“sys.setswitchinterval()和 GIL 有什么关系?”这是从机制上考你。GIL 每隔一段时间会强制切换线程,这个时间间隔默认是 5 毫秒,调整它会影响线程切换频率,但一般不建议乱调。调太短,线程频繁切换,开销大;调太长,交互型程序会感觉卡顿。能讲出这些细节的候选人,基本说明对并发有实际调优经验。
面试其实不怕你不会,怕的是你只会背概念。GIL 相关的题答得好不好,关键看你有没有亲身踩过坑、调过性能。如果能把上面 4.1 到 4.2 的例子都讲一遍,面试官基本就能确认你的水平了。
5. 拿这些经验去少熬几个夜
5.1 并发优化的顺序,我建议你先 profile 再动手
我最初优化订单服务时犯的最大错误,就是没做性能剖析就直接上多线程。后来养成习惯,遇到性能问题先用cProfile或者py-spy看一下热点在哪里。如果热点在 CPU 计算,再想并发方案;如果热点在数据库查询或者网络等待,先考虑缓存和批处理,未必一定要上并发。
有一次我优化一个报表接口,本来想用多进程并行计算,结果 profile 后发现 90% 时间都耗在数据库慢查询上。我改成优化 SQL、加索引之后,接口响应从 4 秒降到 0.3 秒,压根没动并发代码。这个例子基本诠释了“先定位再优化”的价值,不 profile 就动手,等于蒙着眼睛开车。
如果你不知道从哪儿开始,可以先看看cProfile的输出排序,找出耗时最高的函数。如果是纯 Python 函数,再判断它是 CPU 密集还是 IO 密集,然后按前面那张表选方案。这个过程花不了十分钟,但能帮你少走几百条弯路。
5.2 排查并发问题的工具,用顺手能省一整天
除了 profiling,并发问题排查还需要一套顺手的工具。我个人最常用的组合是py-spy加faulthandler。py-spy dump --pid <进程号>可以随时看到 Python 进程里每个线程的当前调用栈,不需要改代码,不用重启服务,排查线上卡顿、死锁问题非常管用。
faulthandler.dump_traceback_later(10)则可以在服务运行 10 秒后自动打印堆栈,适合定位“跑着跑着突然卡住”的问题。还有一个不太起眼但很有用的办法:在怀疑有锁竞争的地方,临时打印线程名和当前时间戳,通过日志里线程切换的间隔来推测 GIL 是否有问题。这类土办法虽然不高级,但在现场调试的时候往往比任何工具都好使。
我把这些工具的使用方法写在公司内部手册里之后,团队排查并发问题的平均时间从几天缩短到几个小时。你如果手头正遇到“程序变慢了但不知道卡在哪”的情况,强烈建议先把py-spy装上,它会让你看到多线程底层发生了什么,而不是靠猜。
5.3 搞清楚 GIL 之后,我的代码习惯变了
以前我写 Python 并发,第一反应总是堆线程池,觉得“并发度越高越好”。现在我的默认路径变成了这样:先问一个任务等不等 IO,不等,去看有没有成熟的 C 扩展库能用;没有,考虑多进程。等 IO,再看并发量有多大,几千以下用线程池,几万以上用协程。这个顺序可能不是最优解,但应对绝大多数业务场景足够了。
还有一个小习惯:写并发代码时,我尽量让任务之间不共享可变状态,数据通过返回值、队列、或者消息中间件传递。这样既绕开了加锁的复杂度,也减少了 GIL 带来的不确定性。
最后说句掏心窝的话,GIL 不是 Python 的缺陷,它更像一把保护伞,让初学者不容易写出内存级崩溃的并发代码。真正应该掌握的,不是抱怨它,而是知道它在什么时候会影响你,以及怎么用工程手段绕过去。想明白这一点,后面写代码的路就顺了。