AIGC标识 Python 的 import 为什么第二次几乎不要钱?sys.modules 缓存与模块单例的代价

系列:Python 底层设计逻辑|类型:原理拆解型|分发:CSDN / 博客园 / 腾讯云 / 知乎(回答改写)
字数:约 1700 字|实验环境:CPython 3.13.14(64 位,Windows)

Python 的 import 为什么第二次几乎不要钱?sys.modules 缓存与模块单例的代价

结论先行:import 的「第二次免费」靠的是一张字典——sys.modules。模块一旦加载过,再次 import 只是 230 纳秒的字典查询;但第一次 import 要走过「查找→编译→执行」全链路,实测一个 1500 个函数的模块冷启动 46.8 毫秒,有 pyc 缓存后降到 3.9 毫秒。这篇用 8 组实验讲清缓存机制、模块单例、pyc 到底省了什么,以及循环 import 为什么有时能跑有时崩。


1. 第二次 import 只是一次字典查询

import sys, time
t0 = time.perf_counter()
for _ in range(100000):
    import json
print((time.perf_counter() - t0) / 100000)   # 2.3e-07 秒

实测 10 万次 import json 总共 23 毫秒——平均每次 230 纳秒。这不是「重新加载了一遍 json」,而是 import 系统第一件事就是查 sys.modules 字典,命中就直接返回。import 语句的编译结果里甚至没有文件 IO,只有一个字典查找加名字绑定。

2. sys.modules:整个 import 系统的缓存表

sys.modules 是一个普通的 dict,键是模块名,值是模块对象。它的行为决定了 import 的三个特点:

其一,同进程同名单例。 import json as j2 之后 j2 is json 恒为 True——全进程只有一个 json 模块对象,所有 import 者共享同一份状态。这是模块级单例模式的实现基础,也是「修改模块属性会影响所有使用者」的原因。

其二,import 是一棵树。 import json 实际加载的是 json 全家:

>>> import decimal, sys
>>> sorted(k for k in sys.modules if k == 'json' or k.startswith('json.'))
['json', 'json.decoder', 'json.encoder', 'json.scanner']
>>> 'json' in sys.modules   # import decimal 之后
True                        # decimal 把 json 也拖进来了

一次 import 触发的是整棵依赖树的加载,树上的每个节点都进 sys.modules。这也是「import 开销」难以预估的原因:你以为 import 了一个小工具,它可能拖进来半个标准库。

3. 打破单例的两种方式,代价完全不同

模块对象进了 sys.modules 就稳如泰山,但有两把刀能动它:

操作 模块对象 旧引用 适用场景
importlib.reload(m) 复用同一对象,就地重执行代码 自动看到新代码 交互式调试
del sys.modules['m'] 后重新 import 创建全新对象 仍指向旧对象,成「孤儿」 测试隔离

实测确认:reload 前后 id(m) 相同——它把模块代码在原有命名空间里重新跑一遍,所以旧引用无需更新就能看到新定义;而删字典条目再 import,拿到的是另一个对象,旧引用还挂在老对象上,两个副本的状态会分叉。测试里 mock 模块用后者,线上热更新想都别想。

4. pyc 省的是编译,不是执行

sys.modules 管的是「进程内」,pyc 管的是「进程间」。第一次 import 一个 1500 个函数的模块(源码 65KB):

场景 耗时 说明
冷启动(无 pyc) 46.8 ms 读源码→编译字节码→执行模块体
热启动(有 pyc) 3.9 ms 跳过编译,直接执行模块体

快了 12 倍,但剩下的 3.9 毫秒一分没少——pyc 只省「源码→字节码」的编译,模块体的执行(定义 1500 个函数、建 1500 个对象)每次 import 都要重跑。这解释了一个常见困惑:明明有 __pycache__,为什么 import 还是慢——编译可以缓存,执行不能。

顺带一提:PYTHONDONTWRITEBYTECODE=1 的环境(比如多数 Docker 镜像)连 pyc 都不写,每次冷启动全额付费。

5. importtime:把 import 开销摊开看

CPython 自带 -X importtime,直接看每笔账:

import time:       3391 |      15806 | decimal
import time:       4063 |       9423 |   _decimal
import time:       4807 |      57830 |     json

读法:第一列是模块自身耗时(μs),第二列是含子模块的累计。decimal 自身只要 3.4 毫秒,但连带 _decimal 和 json,累计 15.8 毫秒。做启动优化时,先跑 importtime 找累计值最大的那几个,比猜快得多。

6. 循环 import:为什么有时能跑,有时崩

# cyc_a.py
import cyc_b        # 此时 cyc_b 开始执行
VAL_A = 1
# cyc_b.py
import cyc_a        # cyc_a 已在 sys.modules(部分初始化)→ 拿到半成品
VAL_B = 2

这套能跑:import cyc_b 拿到的是 sys.modules 里已注册但只执行了一半的 cyc_a 模块对象,只要 cyc_b 不在模块顶层立刻访问 cyc_a.VAL_A,相安无事。

但把 import cyc_b 换成 from cyc_b import VAL_B,如果 cyc_b 又在顶层 from cyc_a import VAL_A,直接崩:

ImportError: cannot import name 'VAL_C' from partially initialized module 'cyc_c'

规律:import x 形式对循环宽容(拿到半成品也行),from x import y 形式苛刻(必须当场拿到属性)。 所以循环 import 的救命写法就是统一用 import x + 函数内延迟访问。

7. from X import Y 并不比 import X 便宜

实测新进程里 from decimal import Decimal(13.5ms)和 import decimal(13.6ms)几乎一样——from-import 先完整加载整个模块,再从模块对象上取属性绑定到本地名字,一步都没少。它省不了加载,只在用法上顺手。


一句话收尾:import 的第二春来自 sys.modules 字典查询(230 纳秒),第一春的账单是编译加执行(46.8 毫秒);pyc 只能缓存前者的一半,模块单例和循环 import 的脾气,都从这张字典的语义里来。

posted @ 2026-10-19 11:11  小小张说故事  阅读(9)  评论(0)    收藏  举报