Python 运行时、对象生命周期与 GIL

# Python 运行时、对象生命周期与 GIL

本篇目标

看懂真实项目中对象为什么会共享、什么时候释放、内存为什么可能持续增长,以及 GIL 对线程并发究竟有什么影响。

理解引用语义区分引用计数与循环回收解释 GIL定位常见内存问题

# 先记住一句话

Python 变量保存的是对象引用;CPython 主要靠引用计数及时释放对象,再用循环垃圾回收器处理互相引用,而默认 GIL 只限制同一进程内同时执行 Python 字节码,不等于程序不能并发。

# 从源码到对象发生了什么

以 CPython 为例,执行一段源码可以先理解成:

Python 源码
└─ 解析并编译为字节码
   └─ CPython 虚拟机逐条执行
      ├─ 创建对象
      ├─ 让名字引用对象
      ├─ 调用 Python 或扩展函数
      └─ 更新引用关系并回收不可达对象
1
2
3
4
5
6
7

“Python 是解释型语言” 只是简化说法。常见的 CPython 会先把源码编译成字节码,再由虚拟机执行;.pyc 是可复用的字节码缓存,不是独立的原生可执行文件。

# 变量不是盒子,而是名字

# 文件位置:任意 Python 模块,例如 examples/references.py
first = {"title": "Python"}  # 创建字典对象,并让 first 引用它。
second = first  # 没有复制字典,只让 second 指向同一个对象。

second["title"] = "Go"  # 通过 second 修改共享对象。
assert first["title"] == "Go"  # first 能观察到同一次修改。

third = first.copy()  # 只复制最外层字典,得到新的外层对象。
third["title"] = "Rust"  # 修改新字典,不再影响 first。
assert first["title"] == "Go"
1
2
3
4
5
6
7
8
9
10

函数参数也是引用传递的效果:调用时把对象引用绑定到形参。函数能修改传入的可变对象,但给形参重新赋值不会改变调用方的名字。

# 文件位置:examples/arguments.py
def append_item(items: list[str]) -> None:
    items.append("new")  # 修改调用方和函数共同引用的列表对象。

def replace_items(items: list[str]) -> None:
    items = ["replacement"]  # 只让局部名字 items 改指向新列表。
    assert items == ["replacement"]

values = ["old"]
append_item(values)
replace_items(values)
assert values == ["old", "new"]  # 重新绑定形参没有替换 values。
1
2
3
4
5
6
7
8
9
10
11
12

# 引用计数与循环垃圾回收

CPython 的常规对象会记录有多少有效引用指向自己。引用计数降到零时,对象通常可以立即释放;但两个对象互相引用时,即使外部已经访问不到它们,彼此的引用计数仍不为零,因此还需要循环垃圾回收器补充处理。

# 文件位置:examples/reference_cycle.py
# future import 让本文件中的类型标注延迟求值,便于引用尚未完全定义的类型。
from __future__ import annotations

class Node:
    def __init__(self, name: str) -> None:
        self.name = name
        self.next: Node | None = None  # next 可以引用另一个 Node。

first = Node("first")
second = Node("second")
first.next = second  # first 引用 second。
second.next = first  # second 又引用 first,形成循环。

# del 删除名字与对象的绑定,不等于立刻销毁对象。
del first
del second  # 删除两个名字,但循环中的对象仍彼此引用。

# CPython 的循环垃圾回收器会在之后识别这组不可达对象。
# 业务代码通常不需要手动调用 gc.collect()。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

del name 删除的是一个名字或容器中的引用,不是向对象发送 “立刻销毁” 命令。对象是否释放,取决于是否还有其他引用、解释器实现和回收时机。

# 为什么内存没有马上还给操作系统

“对象已经回收” 和 “进程常驻内存立即下降” 不是同一件事。CPython 的内存分配器可能保留已经空闲的内存区域,供后续对象重复使用;第三方扩展、缓存和连接池也可能主动保留资源。

排查内存问题时按下面的顺序更可靠:

  1. 先确认对象数量是否持续增长,而不是只看进程 RSS;
  2. 检查无上限缓存、全局集合、未消费队列和长期 Task;
  3. 使用 tracemalloc 比较 Python 分配快照;
  4. 如果增长发生在 C 扩展或模型运行时,再使用对应工具排查原生内存;
  5. 不要把频繁 gc.collect() 当成通用修复。
# 文件位置:scripts/check_allocations.py
# tracemalloc 是 Python 标准库的 “内存分配追踪” 模块,用来定位 Python 代码在哪里申请了内存。
import tracemalloc

tracemalloc.start()  # 开始记录 Python 内存分配的调用位置。
before = tracemalloc.take_snapshot()  # 保存执行业务逻辑前的内存分配快照,作为比较基线。

# 在这里执行需要观察的业务逻辑;示例用列表模拟一批临时对象。
temporary_rows = [{"id": index} for index in range(10_000)]
del temporary_rows  # 删除本地引用,避免示例本身持有对象。

# 再保存一份快照;compare_to() 会与基线比较,"lineno" 表示按源码行汇总差异。
after = tracemalloc.take_snapshot()
for stat in after.compare_to(before, "lineno")[:10]:
    print(stat)  # [:10] 只取内存增长最明显的前 10 个源码位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# GIL 到底限制什么

默认 CPython 中的全局解释器锁(Global Interpreter Lock,GIL)使一个进程里的多个线程通常不能在同一时刻并行执行 Python 字节码。

下面这张表不是用来记术语,而是帮助判断 当前任务是否适合使用多线程。先问自己:程序的大部分时间是在 “等待结果” ,还是在 “执行计算” ?I/O(Input / Output,输入 / 输出)等待包括请求接口、读取文件和查询数据库。

程序主要在做什么 多线程通常有没有用 应该怎么选
等待网络、文件或数据库返回结果 有用。一个线程等待时,其他线程可以继续工作 使用多线程或异步 I/O
使用 Python 代码持续进行大量计算 通常帮助不大。GIL 会限制多个线程同时执行 Python 字节码 使用多进程、原生扩展或独立任务服务
使用 NumPy 等底层原生库计算 不一定。某些底层计算会释放 GIL,能否加速取决于具体操作和库的实现 先测试不同线程数,再根据结果选择

可以简单记成:多线程适合让多个等待过程重叠,不擅长让纯 Python 计算同时占满多个 CPU 核心。

GIL 不是业务锁,也不保证一组业务操作具有原子性。下面的 “读取、判断、写入” 仍可能被多个线程交错执行:

# 文件位置:examples/thread_safety.py
from threading import Lock

balance = 100
balance_lock = Lock()  # 业务共享状态仍需要自己的同步机制。

def withdraw(amount: int) -> bool:
    # global 表示下面重新赋值的是模块级 balance,而不是创建同名局部变量。
    global balance
    with balance_lock:  # 把检查和扣减保护成同一个临界区。
        if balance < amount:
            return False
        balance -= amount
        return True
1
2
3
4
5
6
7
8
9
10
11
12
13
14

从 Python 3.13 开始,CPython 提供可选的 free-threaded 构建,但它不是默认运行方式。面试中应先说明自己讨论的是 “默认启用 GIL 的 CPython” ,不要把所有 Python 实现和所有版本说成完全一样。

Thread 与 free-threaded 是什么

Thread 中文叫 “线程” ,是进程中负责执行代码、由操作系统调度的单位。同一个 Process(进程,即一个正在运行的程序实例)可以包含多个线程,这些线程共享进程里的内存和资源。例如,一个线程等待接口返回时,另一个线程可以继续处理其他任务。

free-threaded 中文可理解为 “自由线程” ,是可以关闭 GIL 的 CPython 构建模式。默认 CPython 中的多个线程受 GIL 限制,通常不能同时执行 Python 字节码;关闭 GIL 后,多个线程可以在不同 CPU 核心上并行执行 Python 代码,但第三方扩展是否兼容、程序是否更快,仍需要实际确认。

# 项目中怎样读这一层

看到真实代码时,可以沿四个问题检查:

  • 对象由谁创建,当前变量是新对象还是共享引用;
  • 生命周期由函数、请求、应用还是全局进程控制;
  • 容器、缓存、Task 或回调是否会永久持有引用;
  • 多线程修改共享状态时,是否有锁、队列或更高层事务保护。

如果一个 FastAPI 应用在模块导入时创建大型模型,它会活到进程退出;如果每个请求都重新创建模型,则会反复消耗加载时间和内存。更合理的方式通常是在应用 lifespan 中创建一次,在关闭阶段释放。

lifespan 是什么

lifespan 中文是 “应用生命周期” ,是 FastAPI 提供的启动与关闭管理机制:应用启动时创建模型、数据库连接池等共享资源,应用关闭时再统一释放。这样可以避免每个请求都重复创建昂贵资源。

# 高频面试题与回答

1. Python 的垃圾回收只有引用计数吗?参考答案

以 CPython 为例,主要机制是引用计数,因此多数对象在引用计数归零时能及时释放;它还使用循环垃圾回收器处理容器之间的循环引用。del 只是删除引用,不保证对象或进程内存立刻消失。

2. 有 GIL 为什么线程仍适合 I/O 密集任务?参考答案

线程等待网络、文件等 I/O 时,解释器可以让其他线程继续工作,所以能提高等待型任务的重叠程度。GIL 主要限制默认 CPython 中多个线程并行执行 Python 字节码,因此纯 Python CPU 密集任务通常更适合多进程或释放 GIL 的原生实现。

3. GIL 能保证字典或业务状态线程安全吗?参考答案

不能把 GIL 当成业务并发控制。一条内建操作在当前实现中可能不会被其他 Python 字节码打断,但多个操作组成的 “检查后写入” 仍会交错,而且实现保证也会随运行模式变化。共享可变状态应使用锁、线程安全队列、数据库事务或避免共享。

4. Python 对象释放后,内存为什么没有立刻下降?参考答案

对象不可达后,其空间可能先归还给 Python 内存分配器,留在进程内供后续对象复用,而不是立即归还操作系统;内存碎片和仍被引用的对象也会影响进程占用。应先用 Profile 区分真实对象持续增长和分配器复用,再决定是否优化。

# 接下来学什么

下一篇学习 Python 异步编程,理解事件循环、协程和 Task 怎样在等待 I/O 时让出执行权。

# 参考资料

上次更新时间: 2026年09月10日 00:03:52