第 14 章 并发全景:GIL 卡了什么

这一章帮你把 Python 的并发三件套摆上桌,先认清 GIL 到底卡在哪,再学会「threading / asyncio / multiprocessing 三选一」——不然你在 Go 里那套 goroutine 肌肉记忆,在 Python 里会处处撞墙。

墨叔:老哥,先问你个扎心的。你在 Go 里 go func() 一甩,成千上万个 goroutine 在多个核上跑得飞起,CPU 和 IO 都不带怂的。换成 Python,你兴奋地 threading.Thread 起十个线程去算数,结果一跑——耗时没减半,反而更慢了。你第一反应是不是「Python 多线程是个假货」?

老哥:……差不多。我之前就是这么以为的,后来听说有个叫 GIL 的东西。

墨叔:对,GIL 是「假象」的源头,但它不是假货,是 CPython 当年一个非常务实的设计选择,只是代价你今天要还。今天咱们把三件事掰开:GIL 到底是什么、卡了什么;CPU 密集和 IO 密集到底差在哪;以及三选一决策树。最后拿 Go 的 goroutine+channel 当镜子,照清楚「为什么 Python 没有那种随手就并发的爽感」。

一、GIL 到底是什么(怎么用 / 怎么看它)

GIL 全称 Global Interpreter Lock,全局解释器锁。它是一把进程内的互斥锁,规则就一句话:同一时刻,只有一个线程能执行 Python 字节码。

你先别急着骂。这把锁当年为什么要加?因为 CPython 的对象内存管理靠引用计数(每个对象有个 refcount,归零就回收)。引用计数不是线程安全的——两个线程同时对一个对象 +1、-1,不加锁就会丢更新,最后要么内存泄漏,要么对象被提前释放、程序直接崩。GIL 用一把大锁把整个解释器护住,单线程跑得飞快,写 C 扩展也简单(不用每个数据结构都自己加锁)。代价就是:多线程在 CPython 里没法真正并行执行 Python 代码。

怎么「看见」它?给你一段能直接跑的代码,纯 CPU 活儿(死循环减法),分别用单线程和双线程跑:

import time
from threading import Thread
 
def cpu_bound(n: int) -> None:
    # 纯计算,不碰任何 IO,全程占着 GIL
    while n > 0:
        n -= 1
 
def run_serial() -> float:
    start = time.perf_counter()
    cpu_bound(20_000_000)
    cpu_bound(20_000_000)
    return time.perf_counter() - start
 
def run_threaded() -> float:
    t1 = Thread(target=cpu_bound, args=(20_000_000,))
    t2 = Thread(target=cpu_bound, args=(20_000_000,))
    start = time.perf_counter()
    t1.start(); t2.start()
    t1.join(); t2.join()
    return time.perf_counter() - start
 
print(f"串行:  {run_serial():.3f}s")
print(f"双线程:{run_threaded():.3f}s")

老哥你跑一下会发现:双线程那版基本不比串行快,常常还更慢。为什么慢?因为两个线程抢同一把 GIL,抢到的人跑几毫秒字节码就被解释器强制「让锁」(sys.setswitchinterval 默认 5ms),然后上下文切换、再抢——纯属空耗。这就是 GIL 卡的东西:卡的是「同一进程内多个线程同时跑 Python 代码」。

但注意一个关键细节:GIL 在线程阻塞在 IO 时会被释放。比如 time.sleep、网络读写、文件读写这些,底层是 C 调用,进入等待前会放开 GIL。所以「多线程干 IO 活儿是有用的」,后面第二节细说。

还有个容易忽略的出口:很多计算库(numpy、pandas 的底层)是 C 写的,它们做大量运算时会主动释放 GIL。所以你 numpy.dot 这种重计算,多线程是能并行受益的——因为真正的活儿发生在释放 GIL 的 C 扩展里,不在 Python 字节码上。

二、CPU 密集 vs IO 密集(什么场景用)

墨叔:老哥,你能不能一句话说出,你那十线程算数为什么该用多进程而不是多线程?

老哥:因为算数是 CPU 密集,GIL 不让多线程并行……那 IO 密集又是什么?

墨叔:问到点子上了。区分这两个词,是你选武器的一半功力。

CPU 密集:活儿的瓶颈在「算」。比如图像缩放、加密解密、JSON 大对象序列化、机器学习推理。这段时间线程一直在执行 Python 字节码(或没释放 GIL 的 C 代码),GIL 锁死,多线程白搭。

IO 密集:活儿的瓶颈在「等」。比如等数据库返回、等下游 HTTP、等磁盘读写。这段等待时间里,线程卡在系统调用上,GIL 早放了,别的线程就能顶上来跑。所以 IO 密集用多线程 / 协程,能扛很高的并发。

给你一段对照代码,同样「等 0.1 秒」的 IO 活儿,多线程就比串行快接近 N 倍:

import time
from threading import Thread
 
def io_bound(tag: str) -> None:
    time.sleep(0.1)   # 模拟网络/磁盘等待,期间释放 GIL
 
def run_serial() -> float:
    start = time.perf_counter()
    io_bound("a"); io_bound("b"); io_bound("c"); io_bound("c")
    return time.perf_counter() - start
 
def run_threaded() -> float:
    threads = [Thread(target=io_bound, args=(t,)) for t in ("a", "b", "c", "d")]
    start = time.perf_counter()
    for th in threads:
        th.start()
    for th in threads:
        th.join()
    return time.perf_counter() - start
 
print(f"串行:  {run_serial():.3f}s")   # 约 0.4s
print(f"四线程:{run_threaded():.3f}s")  # 约 0.1s

老哥:哦!所以 GIL 不是「多线程没用」,是「多线程只对 IO 有用,对 CPU 没用」。

墨叔:精准。再补一句:asyncio(协程)跟多线程一样,也只对 IO 密集有用,对 CPU 密集也没用——因为它本质是在一个线程里靠事件循环切换,CPU 活儿照样卡住整个循环。所以核心判据是:你的活儿主要在算,还是主要在等?

三、三选一决策树(收益与代价 + 对照 Go)

现在把场景摊开,给你一棵直接能查的决策树:

你的任务主要是?
├─ CPU 密集(算得多)
│   └─ 用 multiprocessing(多进程,每进程一把独立 GIL,真并行)
│       特例:活儿在 numpy/C 扩展里 → 多线程也行(它们放 GIL)
│
└─ IO 密集(等得多)
    ├─ 并发量不大(几十个以内)且逻辑简单
    │   └─ 用 threading(上手快,不用改写法,但线程数有上限、切换有开销)
    │
    └─ 并发量高(成百上千连接)/ 想精确控制超时取消
        └─ 用 asyncio(单线程扛万级连接,内存极低,但要全程 async)

老哥:那 multiprocessing 就没缺点?

墨叔:有,而且不小。进程间不共享内存。你想让两个 worker 改同一个 dict,得用 multiprocessing.Queue / Pipe / 共享内存(Value/Array),或者干脆各算各的再汇总。数据序列化(pickle)在进程间传来传去,又有开销。所以 CPU 密集上多进程,换来的是「真并行」和「进程隔离稳」,代价是「通信贵、启动重」。

对照 Go,你立刻明白 Python 缺的那块是什么:

  • Go:goroutine 是用户态轻量协程,runtime 用 M:N 调度器把它们铺到 N 个 OS 线程、再铺到 M 个核。没有 GIL,所以无论是 CPU 还是 IO,goroutine 都能真正并行;channel 是语言一等公民, goroutine 之间通信又便宜又安全。
  • Python:没有统一的「一把梭」并发原语。CPU 活儿得逃到多进程(丢共享内存),IO 活儿在协程或多线程里二选一,而且协程要求整条调用链都 async(下面细说),线程又不能并行 CPU。

墨叔:老哥,你有没有发现一个最反直觉的点——在 Go 里你随手 go f(),f 是同步函数也能跑;Python 的 asyncio 里,一旦你进了 async def,里面调一个同步阻塞函数就会卡死整个事件循环,而且你不能「顺手 await 一个同步函数」。这叫「函数着色(function coloring)」:你的代码被分成「同步的」和「异步的」两类,跨过去要显式桥接。Go 没有这堵墙,所以 goroutine 用着顺。

还有,Go 的 channel 是内置的;Python asyncio 也有 asyncio.Queue,但它只活在协程世界里,且要你主动 await。换句话说,Python 的并发不是「没有」,是「碎成三块、各有各的适用面和坑」,你得自己当调度长。

四、一个贴近项目的例子

假设你在写一个服务:要同时去三个上游接口拉数据,再把结果汇总算个分。这明显是 IO 密集(等 HTTP),并发量小(就三个),逻辑简单。

import time
from concurrent.futures import ThreadPoolExecutor
import urllib.request
import json
 
def fetch(url: str) -> dict:
    # 同步 HTTP,但在独立线程里跑,不挡主线程
    with urllib.request.urlopen(url, timeout=5) as resp:
        return json.loads(resp.read())
 
def aggregate(urls: list[str]) -> list[dict]:
    # IO 密集 + 并发量小 → ThreadPoolExecutor 最省事
    with ThreadPoolExecutor(max_workers=len(urls)) as pool:
        return list(pool.map(fetch, urls))
 
if __name__ == "__main__":
    t0 = time.perf_counter()
    data = aggregate([
        "https://api.github.com/repos/python/cpython",
        "https://api.github.com/repos/golang/go",
        "https://api.github.com/repos/rust-lang/rust",
    ])
    print(f"拿到 {len(data)} 个仓库, 用时 {time.perf_counter()-t0:.2f}s")

这里用 ThreadPoolExecutor 而不是裸 Thread,是因为它帮管线程生命周期、异常会冒出来、还能 map 保序。注意:这是 IO 密集,所以多线程是对的;要是你把 fetch 换成「本地读 10GB 文件做哈希」(CPU 密集),这写法就错了,得换 ProcessPoolExecutor。concurrent.futures 这层抽象的好处正在于此——ThreadPoolExecutor 和 ProcessPoolExecutor 接口一模一样,你只要换一个类名,就从「多线程」切到「多进程」,不用改业务代码。这正是 Python 给「不想纠结调度」的人留的甜头。

反观 Go,同样的事你大概写:

var wg sync.WaitGroup
results := make([]Repo, len(urls))
for i, u := range urls {
    wg.Add(1)
    go func(i int, u string) {
        defer wg.Done()
        results[i] = fetch(u) // 真并行,IO 等的时候 goroutine 让出 M,别的 goroutine 上
    }(i, u)
}
wg.Wait()

不一样的地方:Go 版 goroutine 真并行且语法零负担;Python 版得挑 ThreadPoolExecutor、还得想清楚「这到底是 IO 还是 CPU」。这就是区别——不是 Python 不能做,是 Python 把「选模型的决策」推给了你。

五、把判据刻进肌肉:三个实战提醒

  1. 先量再换。别一上来就 asyncio 大改。先用单线程写出来能跑,profile 一看瓶颈在 IO 等待,再上并发;瓶颈在 CPU,才上多进程。过早并发是 Python 项目里最常见的过度设计。
  2. 混用是大忌。事件循环里塞阻塞调用、多进程里共享大对象、线程里不保护共享状态——这三种混搭每一项都能让你 debug 到怀疑人生。后面两章专治 asyncio 和异步生态的坑。
  3. GIL 在变,但别赌它。Python 3.13 起有实验性的「nof_gil」自由线程构建,但生产默认还是带 GIL。写代码时按「有 GIL」来设计,这样无论解释器怎么演进都稳。

回到开头那句:Python 多线程不是假货,是「只对 IO 真有用、对 CPU 帮倒忙」的实货。认清 GIL 卡的是「同进程多线程并行执行 Python 字节码」,你就不会再被它坑,也能跟老板说清「这活儿为啥得用多进程 / 协程」。

六、再往深一层:GIL 会消失吗,以及多进程的真实代价

老哥:墨叔,我听说 Python 社区一直在骂 GIL,那它早晚得去掉吧?要真去掉了,我这一章不白学了?

墨叔:你这个疑问特别典型,我认真答。GIL 不是没人想去掉——PEP 703 已经提出「无 GIL 构建」(自由线程),在 Python 3.13 里作为实验选项 ./configure --disable-gil 存在。但关键两点你得记住:第一,默认发行版依然带 GIL,生产环境几年内不会变;第二,去掉 GIL 不等于免费并行——它需要所有 C 扩展都改成线程安全,否则反而更慢或更崩。所以「按有 GIL 设计」在未来很多年都是稳妥的防御性写法,这一章的判据不会过时。

再说多进程的真实代价,很多人只看到「能并行」就冲了,结果掉进三个坑。第一是启动慢:进程比线程重几个数量级,起十个进程光创建就要几十毫秒,任务若本身才跑几毫秒,启动时间就把并行收益吃光。第二是通信贵:进程间内存不共享,你想汇总结果得把对象 pickle 序列化传到主进程,大对象(比如一张图、一个 DataFrame)传一次可能比计算还久。第三是资源占用:每个进程独立占一份 Python 解释器和它加载的模块,内存是线程方案的好几倍。所以多进程是「真并行」的利器,但只在「单任务足够重、能摊薄启动与通信成本」时才划算,别拿它去并发几十个轻量小活儿——那场景下线程或协程更合适。

把这三章的并发全景收个尾:GIL 卡住线程的 CPU 并行,IO 密集用线程或协程,CPU 密集用多进程;对照 Go 你会清楚 Python 缺的是「统一轻量并发原语 + 无全局锁 + 语言级 channel」。理解差异,比背诵语法重要十倍。

老哥:那别的 Python 实现,比如 PyPy、Jython,也受 GIL 限制吗?

墨叔:这个补充能帮你建立全局观。CPython 是官方且最主流的实现,GIL 就在它身上。Jython 跑在 JVM 上、IronPython 跑在 .NET 上,它们用各自虚拟机的线程模型,没有 GIL,多线程能真并行——但它们生态跟不上、很多 C 扩展用不了,生产里极少。PyPy 是带 JIT 的高性能实现,默认也带 GIL(虽有过无 GIL 实验分支)。所以「换实现绕开 GIL」基本不现实,工程上还是得靠多进程。这也解释了为什么 NumPy、Pandas 这类计算库要自己用 C 扩展释放 GIL——社区早就认了「CPython 有 GIL」这个事实,绕不开就正面用 C 扩展打穿它。你写 Python 并发,心里要装这张地图:默认 CPython + GIL,CPU 重活交给释放 GIL 的 C 扩展或多进程,IO 重活交给线程或协程。

【思考题】

  1. 你有个任务:本地把 100 万条记录做 JSON 序列化后写磁盘,再并发调用 5 个外部 API 做富化。这两段分别该用 threading 还是 multiprocessing?给你一个理由,再想想能不能用 concurrent.futures 的两种 Executor 拼起来做。
  2. 有人说「asyncio 比多线程快,所以一律用 asyncio」。结合 GIL 和「函数着色」两点,反驳他:什么情况下多线程反而更简单、更合适?
  3. 你的服务用 multiprocessing 起了 4 个 worker 算图,结果比单进程还慢。从「进程间通信 / 数据拷贝 / 启动开销 / 任务是否真能拆分」四个角度,列出三种最可能的病因。

【参考答案】

  1. 两段性质不同:JSON 序列化 + 写磁盘是 CPU 密集(序列化占算、写盘是 IO 但整体被序列化主导),要用 ProcessPoolExecutor 才能真并行;调用 5 个外部 API 是 IO 密集,用 ThreadPoolExecutor 即可。拼接做法:主进程串行或线程池里拉完 5 个 API 拿到富化数据,再把 100 万条记录切块 submit 给 ProcessPoolExecutor.map 做序列化写盘——两个 Executor 接口一致,业务代码不动,只是分别 new 一个。关键判断原则:谁占主导就按谁选模型,IO 主导用线程、CPU 主导用进程。
  2. 两个硬理由反驳「一律 asyncio」:(a) GIL 维度——asyncio 在 CPU 密集上和多线程一样无能为力(都在单线程跑字节码),此时它毫无优势,反不如 multiprocessing 真并行;(b) 函数着色维度——一旦上 asyncio,整条调用链都得 async,你依赖的所有库(DB 驱动、HTTP 客户端、甚至某个工具函数)都得有 async 版,否则你在协程里调同步阻塞函数会卡死事件循环,得用 loop.run_in_executor 桥接,反而更绕。所以当「并发量小、逻辑简单、依赖的同步库很多」时,多线程 ThreadPoolExecutor 改起来几行、不用动写法,比把全栈改成 async 省事得多。asyncio 的真正主场是高并发 IO(成百上千连接)且你想精细控制超时/取消。
  3. 四种病因:(a) 任务拆不开——如果 100 万条里每条依赖上一条的结果(有数据依赖),根本无法并行,多进程只是多了启动和 IPC 开销;(b) 数据拷贝贵——把大对象 pickle 进子进程、再 pickle 回来,序列化成本可能超过计算收益;(c) 启动开销——进程比线程重得多,4 个 worker 启动慢,任务若本身就短,启动时间吃掉全部收益;(d) 锁/队列瓶颈——若用 Queue 在进程间频繁传中间结果,队列成了串行瓶颈,并行度被锁死。排查顺序:先确认任务可独立拆分,再用 time 分别量「单进程纯计算」「进程启动+IPC」「多进程总耗时」,哪段占比高就从哪砍。