Python GIL 工作原理及踩坑指南
深入浅出解释全局解释器锁如何影响多线程性能。帮助 Python 开发者理解并发瓶颈,避免常见陷阱。
深入浅出解释全局解释器锁如何影响多线程性能。帮助 Python 开发者理解并发瓶颈,避免常见陷阱。
你好,我是 Maneshwar。我正在开发 git-lrc——一个会在每次 commit 时运行的微型 AI 代码审查工具。它完全免费,源码可在 Github 上查看。欢迎为我们点 Star,帮助更多开发者发现这个项目。也请试用一下,并分享你的反馈,帮助我们改进产品。
我还记得 GIL 第一次毁掉我整个晚上的情景。
当时我正在开发 FreeDevTools,有一个脚本需要解析、校验并重写几十万个小型 markdown 文件的元数据,最后再将其存入 sqlite DB。
绝大部分工作都是用纯 Python 完成的:大量循环、字典查找、字符串拼接,以及频繁创建和销毁小对象。没有 NumPy,没有 Pillow,也没有原生库替我承担繁重的计算。
单线程版本感觉要跑到天荒地老,所以我做了任何一个正常人都会做的事:给它加线程。
八个工作线程。应该会快很多,对吧?
结果速度完全一样,甚至可能还慢了一点点。我盯着终端足足看了一分钟,以为自己哪里写错了。
我没有写错。
我只是遇到了 GIL。
GIL 是 Global Interpreter Lock(全局解释器锁)的缩写。
简单来说,它是 CPython(也就是几乎可以肯定你正在使用的标准 Python)内部的一把锁,用来确保在单个解释器中,同一时间只有一个线程能够执行 Python bytecode。
这里的「bytecode」至关重要,稍后我们还会回来讲它。现在先记住一个简单版本:即使你的笔记本电脑有 16 个核心,即使你启动了 50 个线程,在任何一个瞬间,也只有其中一个线程正在运行 Python 代码。其他线程都在排队等候。
这些线程确实会轮流执行。
CPython 会定期给其他正在等待的线程一个获取 GIL 的机会。默认间隔为 5 ms,可以通过 sys.setswitchinterval() 调整。
真正的切换会发生在 bytecode 求值循环中的下一个安全点。因此,从表面上看,这些线程似乎在并发运行,但实际上并没有两个线程在并行执行 Python。
可以这样类比:你雇了十名厨师,但厨房里只有一把刀,于是其中九个人只能看着第十个人切洋葱。他们可以轮流使用这把刀,却不能同时切菜。(除非其中一名厨师离开厨房,去等待食材送达——这大致就是发生阻塞 I/O 时的情形。)
在继续之前,有一点需要特别说明:GIL 是 CPython 特有的机制。Jython 和 IronPython 等其他 Python runtime 并不采用相同的 GIL 模型,而现代 CPython 现在也提供了实验性的 free-threaded build。
所以,当人们说「Python 有 GIL」时,他们真正想表达的是:「最常见的 Python 实现有 GIL。」
这并不是因为谁偷懒了。GIL 是一个真实存在的工程权衡。
Python 主要通过引用计数管理内存,同时使用循环垃圾收集器处理引用环。
每次变量赋值、每次函数调用,以及每次向 list 中塞入某个对象时,后台都会有一个计数器增加或减少。
如果两个线程在没有协调的情况下同时更新这些计数器,就会产生 race condition。而内存管理中的 race condition 会导致程序崩溃和数据损坏。
GIL 的替代方案,是为每个对象使用细粒度锁,或者重写内存模型。但这些方案也有各自的代价:每次操作都会产生更多开销、单线程代码会变慢,而且所有编写 C extension 的开发者都会过得更加艰难。
C extension 生态中的很大一部分——NumPy、Pillow、lxml,以及每一个数据库驱动——都是在假设 GIL 存在的前提下构建的。为了实现线程安全而重写这一切,将会是一个跨时代的工程。
因此,GIL 一直保留了下来,因为从实际情况看,这项权衡确实更有利:实现更简单、单线程代码更快,而且能为 C extension 提供一套简单明确的约定。
代价则是 Python bytecode 无法并行执行。对于大多数用户来说,这项权衡是可以接受的。
下面这个脚本展示了 GIL 的限制。我们会执行两次高负载倒计时,先顺序运行,再使用线程运行:
import time
import threading
def countdown(n):
while n > 0:
n -= 1
COUNT = 50_000_000
start = time.time()
countdown(COUNT)
countdown(COUNT)
print(f"sequential: {time.time() - start:.2f}s")
start = time.time()
t1 = threading.Thread(target=countdown, args=(COUNT,))
t2 = threading.Thread(target=countdown, args=(COUNT,))
t1.start(); t2.start()
t1.join(); t2.join()
print(f"threaded: {time.time() - start:.2f}s")
这个 benchmark 是刻意设计出来的合成测试。真实 workload 会有更多细节,但它能以一种最直观的方式,把最核心的限制展示出来。
接下来这一点,我花了长得令人尴尬的时间才真正理解。
当 Python 执行阻塞 I/O 时,GIL 会被释放。更准确地说,是实现该 I/O 操作的 C 代码会显式释放 GIL。
读取文件、等待网络请求、查询数据库、sleep——在等待期间,底层 C 调用会释放 GIL,让其他线程运行,然后在返回 Python 代码之前重新获取 GIL。
这意味着 threading 确实非常有用,只是它的用途和我最初理解的不一样。
import time
import threading
def fake_request():
time.sleep(2) # pretend this is a network call
start = time.time()
fake_request()
fake_request()
fake_request()
print(f"sequential: {time.time() - start:.2f}s")
start = time.time()
threads = [threading.Thread(target=fake_request) for _ in range(3)]
for t in threads: t.start()
for t in threads: t.join()
print(f"threaded: {time.time() - start:.2f}s")
最后真正让我想明白的经验法则是:
如果你的 workload 大部分时间都在等待 I/O,线程通常会带来很大帮助。
如果你的 workload 大部分时间都在执行纯 Python bytecode 计算,线程通常无法让它变快。
Web scraper、API client,以及任何需要与数据库或文件系统通信的程序,都非常适合使用 threading(或 asyncio)。
但对于那些使用纯 Python 编写、密集处理数据的循环,线程会让你失望。
顺便简单讲一下 asyncio,因为它总会被提到:如果你的目标是使用更多 CPU 核心,asyncio 并不是 threading 的替代方案。
它是在单线程中实现的并发,也就是由一个 event loop 同时调度大量 I/O-bound coroutine,并且不需要承担 OS 线程的额外开销。
它非常适合高并发 I/O workload,但无法绕过 GIL 来执行 CPU-bound Python 代码。
如果你的 workload 是 CPU-bound,asyncio 很可能也救不了你。
我真希望一年前就有人把这一节交给我。
GIL 限制的只是 Python bytecode 的执行。
C extension 在原生代码中执行工作时可以释放 GIL,而且许多 C extension 确实就是这么做的。
当 C extension 内部释放 GIL 后,其他 Python 线程便可以在多个核心上并行运行。
因此,「Python 中的线程无法使用多个核心」是互联网上流传最广的半真半假说法之一。
如果线程的工作是在一个编写良好的 C extension 中执行,它当然可以使用多个核心。
如果底层数值计算库或原生库会释放 GIL,并且它自身还没有把 CPU 完全跑满,那么在其上层使用 threading 可以带来一定程度的并行能力。(如果你想深入研究,这个 Stack Overflow 讨论串记录了围绕这个问题最经典的来回辩论。)
这也解释了为什么科学计算领域的 Python 快得不像这门语言本该有的速度:绝大部分实际工作都发生在 C/C++/Fortran 中,在这些工作执行期间,GIL 会主动让路。
这就是 multiprocessing 的用武之地。
每个 process 都拥有自己的 Python interpreter、自己的内存,以及自己的 GIL。在一台四核机器上启动四个 process,你就真的可以获得四个核心的计算能力——不会争抢同一把锁,也不需要轮流执行。
import time
from multiprocessing import Process
def countdown(n):
while n > 0:
n -= 1
if __name__ == "__main__":
COUNT = 50_000_000
start = time.time()
p1 = Process(target=countdown, args=(COUNT,))
p2 = Process(target=countdown, args=(COUNT,))
p1.start(); p2.start()
p1.join(); p2.join()
print(f"multiprocessing: {time.time() - start:.2f}s")
对于多核系统上计算量足够大的 CPU-heavy workload,这通常会比顺序执行版本更快。速度并不一定恰好提升一倍,这取决于 CPU 拓扑结构、散热余量,以及机器同时还在执行哪些其他任务。但没错,你确实会看到并行效果。
问题在于:process 比 thread 更重。它们的启动时间更长,默认不共享内存,而且在 process 之间传递数据需要进行序列化(通常是 pickling),这又会带来一系列需要注意的问题。如果你见过 Can't pickle local object error,那你已经遇到过其中一个了。
对于大多数日常场景,可以优先考虑 concurrent.futures.ProcessPoolExecutor。它是一层更友好的封装,能让 multiprocessing 用起来几乎和 threading 一样简单。
下面这些情况,我见过别人中招,我自己也中过招:
使用纯 Python 和 thread 编写一个所谓「快速」的数据转换程序,期待性能线性增长,结果一点提升都没有。
为一个主要由使用 Python 函数的 .apply() 组成的 pandas pipeline 添加线程。执行向量化操作时,pandas 会进入 C 代码并释放 GIL;但 .apply() 会回调 Python——于是 Python 又回到了一次只能运行一个线程的状态。
Web scraper 使用线程处理纯 Python parser 的解析阶段,而不是获取数据的阶段。获取数据属于 I/O,适合使用线程;HTML parsing 则可能变成 CPU-bound workload,尤其是使用 html.parser 时。如果使用 lxml,情况就会好很多,因为 lxml 会释放 GIL。parser 的选择很重要。
其中贯穿始终的核心逻辑都一样:先弄清楚瓶颈究竟是等待、在纯 Python 中进行计算,还是在 C extension 中进行计算。Threading 对第一种和第三种情况有帮助,对中间那种没有。
PEP 703 是一项让 CPython 中的 GIL 变为可选机制的提案。Steering Council 于 2023 年接受了这项提案,但附带了明确条件:推广过程必须循序渐进,对生态系统造成的冲击必须保持在可控范围内;如果最终证明它带来的破坏性过大,整个计划仍然可能被撤回。
Python 3.13(2024 年 10 月)在常规的 GIL-enabled build 之外,首次提供了实验性的 CPython free-threaded build。它允许 Python 在没有 GIL 的情况下运行,不过许多 extension module 仍然依赖关于 GIL 的既有假设,并且可能会在 import 时自动重新启用 GIL。
Python 3.14 对实验性的 free-threaded runtime 做出了显著改进。adaptive interpreter 被重新启用,单线程性能损失大幅降低,项目也进入了 Steering Council 推广计划的 Phase II。在这一阶段,free-threaded Python 被视为受支持的运行方式,但它仍然是可选项,并非默认 runtime。
CPython 显然正在走向一个 no-GIL Python 真正可用的未来,但在短期内,我们还远没有到标准 build 即将消失的阶段。
如果要把那些我希望自己早点知道的事情压缩成几句话,那就是:
当代码运行缓慢时,不要急着采用任何并发方案,先对它进行性能分析。找出时间究竟花在了哪里。然后问一个问题:这个线程是在等待、执行纯 Python 计算,还是在 C extension 内部计算?
等待 → threading 或 asyncio 会很有帮助。
纯 Python → threading 没有帮助;应该使用 multiprocessing,或者寻找一个会进入 C 代码执行的库,例如 NumPy、polars,或任何带有原生 backend 的库。
会释放 GIL 的 C extension → threading 已经能够提供帮助,你可能不再需要其他方案。
如果你曾经被这个问题坑过,也不必觉得自己很笨。每个人都遇到过。GIL 就是这样一种东西:事后回头看时一切都显而易见,但第一次撞上它时,却完全摸不着头脑。
现在,你已经在一个受控环境中主动遇到了它,旁边还有一篇 blog post 陪着你。这已经让你领先于当时的我。
Python Wiki——Global Interpreter Lock
Python FAQ——Can't we get rid of the Global Interpreter Lock?
PEP 703——Making the Global Interpreter Lock Optional in CPython
Python HOWTO——Python support for free threading
What's New in Python 3.14——Free-threaded CPython
Steering Council 关于 PEP 703 的通知
Python docs——concurrent.futures.ProcessPoolExecutor
Python docs——multiprocessing 入门
SciPy Cookbook——Parallel Programming
Stack Overflow——Is Python capable of running on multiple cores?
AI agents 写代码很快。它们也会悄无声息地删除逻辑、改变行为并引入 bug——却不会告诉你。你通常要等到 production 环境出问题才会发现。
git-lrc 可以解决这个问题。它会挂接到 git commit,在每一个 diff 落地之前完成审查。只需 60 秒即可完成设置。完全免费。*
欢迎提供任何反馈,也欢迎贡献代码!它已在线开放,源码可供查看,任何人都可以直接使用。
每次 Commit 时运行的免费微型 AI 代码审查
| 🇩🇰 Dansk | 🇪🇸 Español | 🇮🇷 Farsi | 🇫🇮 Suomi | 🇯🇵 日本語 | 🇳🇴 Norsk | 🇵🇹 Português | 🇷🇺 Русский | 🇦🇱 Shqip | 🇨🇳 中文 | 🇮🇳 हिन्दी |
每次 Commit 时运行的免费微型 AI 代码审查
AI agents 写代码很快。它们也会悄无声息地删除逻辑、改变行为并引入 bug——却不会告诉你。你通常要等到 production 环境出问题才会发现。
git-lrc 可以解决这个问题。它会挂接到 git commit,在每一个 diff 落地之前完成审查。只需 60 秒即可完成设置。完全免费。
看看 git-lrc 如何捕获严重的安全问题,例如泄露的凭据、成本高昂的云端操作,以及 log statement 中的敏感信息。
🤖 AI agents 会悄无声息地破坏代码。代码被删除,逻辑被修改,边界情况消失。直到进入 production,你都不会注意到。
🔍 在发布之前抓住问题。由 AI 驱动的 inline comment 会准确告诉你哪些内容发生了变化,以及哪些地方看起来有问题。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。