场景:加了线程,却没变快
你写了一段纯计算的 Python 代码,算一堆斐波那契或做密集的数值循环,跑满 100% 单核。想当然地用 4 个线程并行,期待提速 4 倍。结果一跑,总耗时几乎没变,甚至更慢了。换成多进程,立刻接近线性加速。同一份计算逻辑,只是换了并发原语,结果天差地别——罪魁祸首就是 GIL(Global Interpreter Lock,全局解释器锁)。
理解 GIL 不是为了背概念,而是为了在"该用线程还是进程还是协程"这个每天都要做的决策上,不踩坑、不浪费机器。
机制:GIL 到底锁住了什么
CPython(最主流的 Python 实现)用引用计数来管理内存。每个对象有个 ob_refcnt 字段,被引用时 +1,解除引用时 -1,归零就回收。问题是:引用计数的 +1/-1 不是原子操作。如果两个线程同时改同一个对象的计数,就会出现竞态,导致计数错乱——要么内存泄漏(该回收的没回收),要么提前释放(还在用的被回收了,直接段错误崩溃)。
GIL 就是 CPython 给出的、简单粗暴但有效的答案:一个进程内,任意时刻只有一个线程能持有 GIL、执行 Python 字节码。有了这把全局锁,引用计数等解释器内部状态的访问天然串行化,不需要给每个对象都加细粒度锁。
所以 GIL 锁的不是"你的业务数据",而是"解释器执行字节码的权利"。它的直接后果是:CPython 的多线程无法让多个线程真正同时执行 Python 字节码。哪怕你有 16 核,纯 Python 计算也只能用满 1 核。
1 | 理想中的多线程(其它语言): |
机制:GIL 什么时候被释放
如果 GIL 全程不放,那多线程一点用都没有。关键在于:GIL 会在两类时机被释放。
第一类,线程主动让出。CPython 会周期性地检查"是否该把 GIL 交给别的线程"(由一个切换间隔控制,默认是很短的时间片量级),触发后当前线程释放 GIL,让等待的线程有机会运行。这保证了线程间的"公平",但对 CPU 密集任务来说,这只是在串行执行的基础上不停地切来切去,纯属开销。
第二类,也是真正有价值的,阻塞式 IO 操作期间会释放 GIL。当一个线程执行文件读写、网络收发、time.sleep 这类会阻塞的系统调用时,CPython 会在进入阻塞前主动释放 GIL,让其它线程跑;等 IO 返回再重新争抢 GIL。
1 | # 简化示意:CPython C 层的典型模式 |
这就是为什么 IO 密集型任务用多线程依然有效:线程大部分时间在等 IO,等的时候 GIL 是放开的,其它线程趁机干活。同理,很多 C 扩展(如 NumPy 的底层运算)也会在进入纯 C 计算时释放 GIL,所以 NumPy 密集运算的多线程能真正并行。
取舍:三种并发模型怎么选
把上面的机制翻译成决策,就清晰了:
CPU 密集型 → 多进程(multiprocessing)。 每个进程有独立的解释器和独立的 GIL,所以多进程能真正吃满多核。代价是:进程创建开销大、内存不共享、进程间通信(IPC)要走序列化(pickle)+ 管道/共享内存,跨进程传大对象很贵。
1 | from multiprocessing import Pool |
IO 密集型 → 多线程或 asyncio。 线程在等 IO 时让出 GIL,并发有效。线程模型的好处是代码改动小、能直接用阻塞库;asyncio 则用单线程事件循环承载海量并发,内存开销更低,但要求全链路非阻塞。
1 | import threading, requests |
混合型:常见做法是用进程池分摊 CPU 计算,每个进程内再用线程/协程处理 IO。
常见误区与踩坑
误区一:“GIL 让多线程毫无意义。” 错。GIL 只对 CPU 密集 的纯 Python 代码是瓶颈。IO 密集场景下多线程照样能大幅提升吞吐,因为瓶颈是等待而非计算。
误区二:用多线程跑 CPU 任务还期待加速。 不仅不会加速,通常会更慢——因为多了 GIL 争抢和上下文切换的开销,却没换来任何并行。
误区三:以为 GIL 能替代锁。 GIL 保护的是解释器内部状态,不保证你的业务逻辑原子性。像 counter += 1 这种操作,在字节码层面是"读-改-写"多步,中间可能被切换,依然会丢更新。该加 threading.Lock 还得加。
误区四:multiprocessing 里忘了 if __name__ == "__main__":。 在 spawn 启动方式下(Windows 和较新的 macOS 默认),子进程会重新导入主模块,不加这个守卫会无限递归创建子进程。
还有一个常被忽视的细节:多进程的 fork 在涉及线程锁、文件句柄时可能产生诡异的死锁或句柄继承问题,大型服务里要谨慎选择启动方式。
边界与趋势
GIL 是 CPython 的实现选择,不是 Python 语言规范。其它实现(如 Jython、无 GIL 的实验性构建)行为不同。社区也在推进可选地移除 GIL 的工作,但这涉及引用计数线程安全、C 扩展兼容性等深层改动,在主流生产环境普遍落地仍需时间。在那之前,"CPU 密集用进程、IO 密集用线程/协程"这条经验法则,依然是你做并发设计时最可靠的出发点。
小结
GIL 的本质是 CPython 为了让引用计数式内存管理免于竞态,而引入的一把"同一时刻只许一个线程执行字节码"的全局锁。它在阻塞 IO 和某些 C 扩展计算期间会被释放——这正是多线程对 IO 密集任务有效、对 CPU 密集任务无效的根本原因。掌握了"GIL 锁的是字节码执行权、IO 时会让出"这一条,你就能在多进程(吃满多核但 IPC 贵)、多线程(IO 友好但 CPU 无力)、协程(海量并发但需全链路非阻塞)之间做出正确取舍,不再对着没变快的多线程代码百思不得其解。