GIL (Global Interpreter Lock,全局解释器锁)

比喻:只有一个收银员的超市

想象一个超市,有很多个顾客(代表线程)在同时购物,但收银台只有一个收银员

  • GIL 就是这个收银员。
  • Python 解释器就是这个超市。

规则是:无论超市里有多少顾客,同一时刻,有且只有一位顾客能在收银员那里结账。其他顾客无论多着急,都得排队等着。

这个收银员(GIL)的存在,并不是超市老板傻,而是为了防止收银台的钱箱(内存数据)被多人同时伸手弄得一团糟。这是一种最简单、最粗暴的线程安全机制。

GIL 在工作中的实际表现

1. I/O 密集型任务(等待网络、读磁盘)

这就像顾客结账时需要等经理过来审批(等待网络响应)。这时,这个顾客会主动让出收银员,让后面的顾客先结账。

所以,在多线程处理网络请求、文件读写时,GIL 的影响不大。程序依然可以很快。

2. CPU 密集型任务

这就像每位顾客买的东西都巨多,需要结账员(CPU)不停地扫码算钱,一刻也停不下来。而且,Python 还规定,即使你算得停不下来,过一会儿也得强制停下来,让后面的人先用

这会导致两个严重问题:

  • 无法真正并行:在多核CPU上,你开了4个线程想用满4个核,但对不起,因为收银员(GIL)只有一个,同一时刻还是只有一个核在干活。
  • 频繁切换开销:线程之间被迫频繁地“让位”和“接手”,这个切换本身也会消耗资源,拖慢速度。

为什么 Python 要有 GIL?要它何用?

最直接的原因:Python 的内存管理(特别是垃圾回收机制)不是线程安全的。

CPython(我们最常用的Python解释器)内部用一个叫“引用计数”的方式来管理内存。每个对象都有一个计数器,当引用它的变量增减时,这个计数器就要变化。

如果没有 GIL,多个线程同时修改同一个对象的引用计数,就会发生竞态条件,轻则内存泄漏,重则程序直接崩溃。

为每个对象都加一把细粒度的锁,实现起来非常复杂,而且会严重拖慢单线程程序的性能。于是,Python 的早期设计者选择了最简单的方案:一把超级大锁(GIL),锁住整个解释器。性能保住了,线程安全也解决了,代价就是多核并行彻底凉凉。

GIL 的由来

一:程序要运行,就得用内存
这是最底层的物理现实。你写的任何程序,只要运行,就需要向操作系统申请内存来存放数据。你的变量、对象、函数,在运行时都活在内存里。

二:用完的内存必须回收,否则就是内存泄漏
程序不可能无限申请内存。一个对象创建出来(比如你解析出来的那个巨大的文档树),当它不再被需要时,它占用的内存必须归还给系统。如果不归还,内存会越用越少,最终程序崩溃。这个“回收不用内存”的动作,就叫内存管理

三:怎么判断“不再需要”?Python 选了最简单直观的“引用计数”
这就是 Python 设计理念的第一次登场。判断一个对象是不是垃圾,有很多复杂的算法。但 Python 的设计者追求简单、直观,于是选了最能被人类理解的方式:给每个对象记个数,还有几个人指着它。计数归零,就没人需要它了,立刻回收。 这就是引用计数。它像呼吸一样自然,开发者甚至感觉不到它的存在。

四:引用计数在多线程下,天然不安全
这是问题的引爆点。你程序跑得快了,开了多线程,两个线程可能同时去修改同一个对象的引用计数。没有保护机制的话,计数就会乱掉,造成内存泄漏或程序崩溃。这在上一轮已经详细解释过。

五:要解决这个不安全,最“笨”的办法就是加锁
内存管理要安全,就得保证同一时刻只有一个线程能改引用计数。加锁是最直接的思路。

六:怎么加这把锁?两种选择,代表了两种哲学
到了这里,才是真正的设计分叉口。Python 的设计理念在这里起了决定性作用。

  • 选择 A(细粒度锁):给每个对象都配一把自己的小锁。

    • 后果:如上一轮所说,这会导致巨额内存消耗、惨重的性能损失和极高的死锁风险。
    • 为什么 Python 不选:因为这让整个语言变得复杂、笨重、难用,完全违背了“简单优于复杂”的设计哲学。
  • 选择 B(全局大锁 GIL):整个 Python 解释器就放一把大锁。

    • 后果:多线程在 CPU 密集型任务上无法并行。
    • 为什么 Python 选择它:因为它的实现极其简单,几乎不增加任何复杂性,完美保证了单线程场景下的安全和性能。这把锁对普通开发者几乎透明,只有当你需要极致多核并行时才会感受到它的存在。

把这条链完整地串起来

  1. 程序运行需要内存。
  2. 用过的内存必须回收,所以需要“内存管理”。
  3. Python 追求简单,所以选了最直观的“引用计数”做内存管理。
  4. “引用计数”在多线程下不安全,会出错。
  5. 要让它安全,必须加锁。
  6. 加锁有两种:每个对象一把小锁(细粒度),或者整个解释器一把大锁(GIL)。
  7. 细粒度锁太复杂、太耗资源、太容易死锁,违背 Python “简单”的设计理念。
  8. 因此,Python 选择了简单到极致的 GIL。简单、安全、单线程性能好,代价是多核并行能力受限。

简单地说:因为程序要管内存,Python 选了最简单的管法,这个管法怕多线程捣乱,于是加了一把最简单的大锁来保护它。这就是 GIL 的由来。

Python 的设计理念

Python 的设计理念可以浓缩为一句话:“程序员的时间比 CPU 的时间更宝贵。”

这不是一句空话,它贯穿在 Python 的每一个设计决策里。

设计原则具体体现例子
可读性至上用缩进定义代码块,语法接近自然语言。强制缩进,一眼就能看清代码结构。
简单优于复杂提供一种、且最好是只有一种显而易见的做事方法。字符串格式化,.format() 逐渐统一江湖。
实用性胜于纯粹不求理论完美,优先解决实际问题。GIL 就是典型,它用理论上的“不完美”换来了实用的单线程性能和内存安全。
“电池已包含”标准库极其庞大,开箱即用。csvjsonhttp 等模块,不用额外安装。
动态类型,鸭子类型“如果它走起来像鸭子,叫起来像鸭子,那它就是鸭子。”关注对象的行为,而非其类型。任何实现了 __len__() 方法的对象,都可以直接用于 len() 函数。

基于这些理念,Python 的早期设计者在面临“内存管理”这个难题时,做了一个非常 Pythonic 的决定:用最简单、最易维护的方式来保证内存安全,即使牺牲多核并行性能也在所不惜。 这个决定就是 GIL。

为什么 Python 的内存管理不是线程安全的?

要回答这个问题,必须先了解 CPython 最核心的内存管理机制:引用计数

它就像一个超级简朴的管家,管理每个对象的生死。

  • 每个对象身上都贴着一个数字:这个数字就叫“引用计数”,表示有多少个“标签”(变量)指向它。
  • 规则极其简单
    • 有变量指向它时,计数 +1
    • 变量不再指向它时,计数 -1
    • 当计数归零时,立刻、马上把它扫地出门(释放内存)。

整个系统都没有一把全局锁,看看多线程下会发生什么:

想象两个线程 AB,几乎同时操作一个名为 doc 的解析结果对象。

  1. 初始状态doc 对象的引用计数是 1(只有一个变量指向它)。
  2. 并发的灾难
    • 线程 A 正要执行 del doc,它读到的计数是 1,正准备去把计数减到 0,然后销毁对象。
    • 就在同一纳秒,线程 B 正要创建一个新引用 my_doc = doc,它读到计数也是 1,正准备把计数 +1 变成 2
  3. 结果
    • 如果 A 的手快:A 把计数减到 0,释放了内存。紧接着 B 把已释放内存上的计数从 0 改成了 1B 现在指向了一块已被收回的垃圾内存,程序随时会崩溃(野指针)。
    • 如果 B 的手快:B 把计数从 1 改成 2。但紧接着 A 继续执行,它依然认为自己读到的 1 是对的,于是在 2 的基础上减掉 1,变成了 1。本应继续存活的 doc 对象,引用计数永远少了一个,最终永远无法回收,造成内存泄漏

这就叫竞态条件。一个简单的整数增减操作,在多线程下如果没有保护,就不再安全。

所以,Python 的内存管理不是线程安全的,因为它采用的“引用计数”这种机制,本身就不是线程安全的。

为什么为每个对象都加一把细粒度的锁很复杂?

既然上面问题的根源是多个线程同时修改一个对象的引用计数,那最直观的补救就是:给每个对象都配一把自己的小锁。 想修改引用计数?先拿到这个对象的锁再说。

这个想法在理论上是对的,但在工程实现上,对于 CPython 来说是一场噩梦。原因有三:

1. 内存开销,足以压垮系统

Python 里**“万物皆对象”**。一个简单的整数 0,一个短字符串 "a",一个空列表 [],统统是对象。一个普通的 Python 程序,可能同时存在上百万个对象。

一把轻量级的锁,无论多小,也要占用内存空间(几个到几十个字节)。给这上百万个对象每人都配一把锁,新增的内存开销将会是天文数字,会瞬间吞噬掉所有内存,系统直接 OOM(内存溢出)。

2. 性能开销,让 Python 慢到无法接受

引用计数的增减是 Python 中发生频率最高的操作。你每写一行 x = 1y = xlist.append(z),都在背后进行着疯狂的计数修改。

现在,要在这个最高频的路径上,加入“获取锁”和“释放锁”的操作。锁操作本身需要系统调用,非常昂贵。这意味着,程序 90% 以上的时间可能不是在跑你的解析逻辑,而是在反复地加锁、解锁。Python 本就饱受诟病的性能会雪上加霜,彻底变成一个慢腾腾的锁管理器。

3. 死锁风险,让代码逻辑彻底失控

多锁环境下最恐怖的幽灵就是“死锁”。当对象间相互引用时,问题会变得极其棘手。

想象一个Document对象,它内部有一个metadata字典。线程A先锁了Document,想修改metadata,需要再去拿metadata字典的锁。同时,线程B先锁了metadata字典,想去更新Document的总页数,需要再拿Document的锁。

结果A 拿着 Document 锁等 metadata 锁,B 拿着 metadata 锁等 Document 锁。双方都在等待对方释放自己需要的资源,程序就永远卡死了。

这种由业务逻辑引发的死锁,是应用程序员的噩梦。而如果连语言底层都要为这数百万对象的锁依赖负责,那它几乎无法保证你能写出一个不死锁的程序。

易混淆点

a=1,没有任何对象引用它,它还占内存吗???

  • 变量 a:只是贴在对象身上的一个标签。
  • 对象 1:真正占用内存的那个整数实体。

a=1 的真实含义是:先创建一个整数对象 1 放在内存里,然后给它贴上一个叫 a 的标签。 标签丢了,不等于对象没了。

情况一:小整数(最反直觉,也最关键)

Python 在启动时,会预先把 -5 到 256 之间的小整数 全部创建好,放在一个缓存池里。

a = 1    # 给缓存池里那个现成的“1”贴标签
del a    # 撕掉标签。但缓存池里的“1”依然在,内存不释放。
  • 占用内存吗? 占。 因为它是缓存对象,跟你的代码没有引用关系了,但 Python 解释器本身还持有对它的引用,它永远不会被回收。
  • 为什么这么做? 这些小数用得太频繁了,频繁创建销毁反而浪费性能,不如一次创建,终身复用。
为什么是-5到256?

这个范围不是某个数学定律,而是一个基于实际使用统计的工程权衡。简单说就是:因为日常代码里,这个区间的数字出现得最频繁。

为什么是 0 到 256?

答案很简单:因为它们用得最多。 你的代码里到处都能看到它们的身影:

  • 循环和计数for i in range(100),这里的 i 会反复从0取到99。
  • 索引和切片my_list[0], my_list[-1],这是数据结构操作的基础。
  • 状态码和标志位:函数成功返回 0,失败返回 -1,或者 True/False(对应 1/0)。
  • ASCII 字符集:计算机处理文本时,每个英文字符背后都是一个 0-127 的整数。

如果这些“小数字”每次使用时都要在内存里创建新对象,用完再销毁,那 Python 的速度会慢得惊人,内存里也会充满重复对象的“垃圾”。

所以,Python 干脆在启动时就一次性把它们全创建好,放在一个现成的“池子”里。以后每次需要用到这些数字时,直接从池子里拿同一个对象来用,省时又省内存。

那为什么上限定在 256?这很可能受到了**单字节(8位)最大能表示的无符号整数范围(0到255)**的影响,这是一个在计算机底层非常自然的“分界线”。既然加上 0 这个特别常用的数,顺延到 256 也合情合理。

为什么是 -5 到 -1?

正数好理解,那为什么负数这边不是对称的,比如到 -256,而仅仅是 -5?

因为真的用不上。 这也是一个由实际代码“投票”出来的结果。

  • 最常见的负数用法:回想一下你写过的代码,用到负数的场景是不是很集中?绝大多数时候,我们只用 -1 来索引列表的最后一个元素,或者表示“未找到”。偶尔,可能会用 -2-3 做一些偏移。
  • 经济学原理:缓存任何一个数字都是有成本的(虽然很小,但要占用内存和管理)。缓存 -5 到 256 这个范围,已经用很小的代价覆盖了程序中绝大多数的“小数字”使用场景。如果再把负数范围扩大到 -100 甚至 -256,所带来的额外性能收益已经微乎其微,因为那些数字被用到的概率远小于 0 到 256 这个“黄金区间”。

** 结论**
-5 到 256 这个范围,本质上是 “二八定律” 在编程语言设计中的体现。
它不是一个绝对的真理,甚至在未来 Python 版本中也可能调整。但这背后的设计思路——观察实际模式,优化最常见的情况,而不是追求理论上的完美——正是 Python 设计哲学的精髓所在。

https://github.com/python/cpython/blob/3e7ee02327db13e4337374597cdc4458ecb9e3ad/Include/internal/pycore_interp.h#L211
https://mail.python.org/archives/list/tutor@python.org/thread/JL7QFQJJAEZYOUR2QTGSHCS4KSAT36AW/?noscript
https://www.reddit.com/r/Python/comments/18leav/python_integer_range_5_256_and_identity_comparison/?tl=zh-hans

情况二:普通对象,引用计数归零(标准流程)

a = 999    # 创建一个值为999的整数对象,引用计数=1
a = 888    # 999的引用计数归零,被立即销毁,内存释放。
           # 888的对象创建,引用计数=1。
  • a 现在指向了 888,不再指向 999
  • 999 这个对象,没有任何人引用它了,引用计数归零,它占用的内存被立即、自动地回收了。这就是引用计数机制的标准工作流程。

情况三:循环引用(引用计数的致命弱点)

list1 = []
list2 = []
list1.append(list2)  # list2的引用计数=2(list2变量和list1内部)
list2.append(list1)  # list1的引用计数=2(list1变量和list2内部)

del list1  # 撕掉标签list1,但list2内部还引着它,计数=1
del list2  # 撕掉标签list2,但list1内部还引着它,计数=1
  • 现在,这两个列表对象相互引用,各自的引用计数都是 1,永不为零。
  • 但从程序逻辑上,你已经没有任何办法访问它们了。
  • 它们成了占用内存、却永远收不回来的“垃圾”。这就是内存泄漏。

引用计数的完整机制

所以,Python 的内存管理其实是两层配合:

  • 主机制:引用计数。 负责即时、确定性地回收大部分垃圾。
  • 辅助机制:分代垃圾回收(GC)。 专门扫描并打破循环引用,回收引用计数处理不了的“循环垃圾”。

总结一句话:引用计数归零,内存立即释放。但有些对象(小整数缓存、循环引用),即使你没引用了,它也可能赖着不走。

总结

  1. 设计理念:“简单与实用,胜于复杂与纯粹。”
  2. 面临问题:基于“引用计数”的内存管理,在多线程下天生不安全。
  3. 可选方案及否决理由
    • 方案 A:为每个对象加细粒度锁。➡️ 太复杂、太耗内存、太慢、极易死锁。这违背了“简单至上”的信条。
    • 方案 B:改用更复杂的垃圾回收算法(如JVM/Go的分代回收,可以无锁或更高效地处理并发)。➡️ 在当时,这意味着完全重写代码,且会引入“暂停世界”(Stop-The-World)的卡顿,牺牲引用计数那种“实时回收”的简洁美。
  4. 最终抉择:方案 C:加一把全局解释器锁(GIL)
    • 优点:实现极其简单,完美保证单线程性能,彻底杜绝了底层的死锁和内存安全问题。这完美践行了 Python 的“简单、实用”的设计哲学。
    • 代价:多线程无法并行处理 CPU 密集型任务。

所以,GIL 不是 Python 的一个 Bug,而是在其设计理念和当时历史条件下,为了让 Python 简单、稳定、能用而做出的一个极其理性的工程妥协。

如何突破 GIL 的限制?

方案如何绕开 GIL你在文件解析项目中的应用
多进程 (multiprocessing)每个进程都有自己独立的 Python 解释器和独立的 GIL。它们之间互不干扰,是真正的并行。这是核心方案! 启动一个进程池,池子里的每个进程都独占一个 CPU 核心来解析文件。这正是我们之前说的 ProcessPoolExecutor 在做的事。
用其他语言扩展 (C/Rust/Go)在用 C 写的扩展库里,可以手动释放 GIL。你在 C 代码里做复杂计算时,把 GIL 交还给 Python,计算完了再申请回来。numpypdfplumber 这些高性能库的底层,就是这么干的。调它们的时候,GIL 的影响其实已经很小了。
换一个解释器 (Jython, IronPython)用 Java 或 C# 重新实现的 Python,没有 GIL。不推荐,它们生态落后,不支持最新的 Python 语法和流行的 C 扩展库。
等待未来官方版本Python 社区一直在努力远水解不了近渴,目前最成熟的方案还是多进程。

https://peps.python.org/pep-0734/

总结

GIL 就是那把为了保证 CPython 解释器内部线程安全,而加上的全局大锁。它让你在写多线程 CPU 密集型程序时,无法利用多核优势。

“用多进程,而不是多线程” 就是对这个问题的标准解答。用 ProcessPoolExecutor,本质上就是在给每个任务分配一个独立的、不受干扰的“小超市”。

番外

  • 内存 = 短期记忆(工作记忆)

    • 是大脑暂时存放、加工当前信息的地方。
    • 特点是:容量有限,断电(分心)即忘,速度极快。
    • 你心算 23 × 47 时,用来暂存数字和中间结果的那个空间,就是内存。
  • CPU = 逻辑运算能力(思维逻辑)

    • 是大脑对这些信息进行运算、推理、判断的能力。
    • 特点是:能力有强弱快慢之分,本身不存储信息,只管计算。
    • 你心算 23 × 47 时,调用乘法口诀和进位规则的那个思考动作,就是CPU在干活。
  • 硬盘 = 长期记忆

    • 是你存储所有知识、经验、回忆的地方。
    • 特点是:容量巨大,永久保存,但调取速度慢。
    • 你之前背下的乘法口诀表,就存在这里。计算时,需要先“加载”到短期记忆里才能用。
计算机概念大脑类比说明
内存占用过高同时想太多事,脑子“转不动”了短期记忆塞满了,新的信息挤不进来,思考速度暴降。对应电脑就是开始用硬盘当内存(虚拟内存),卡成PPT。
CPU 100%拼命思考一道难题,对外界浑然不觉逻辑运算能力全被占用,没工夫理你。对应电脑就是风扇狂转,鼠标都挪不动。
内存泄漏有个念头在后台一直想,甩不掉就像脑子里总有个声音在循环播放一段旋律。你都没在主动想它了,但它就是占着你的短期记忆不放。时间长了,能用的脑容量越来越少。
GIL的痛点单核脑力,一次只能专注想一件事你可以快速在不同事之间切换(I/O并发),但无法在同一秒内,既做数学题又构思文章(CPU并行)。要真正并行,只能再长一个脑子(多进程)。

更多推荐