Python 基础体系 · 第 21/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。
Python 导入系统:搜索路径、缓存、Finder、Loader 与循环依赖
Python 中的 import 看起来像一句简单的声明:
import json
但它实际上启动了一套完整的模块加载协议。这个协议至少包含以下阶段:
- 确定要导入的全限定模块名;
- 检查
sys.modules模块缓存; - 通过
sys.meta_path查找模块; - 必要时根据
sys.path或包的__path__搜索位置; - 由 Finder 返回
ModuleSpec; - 由 Loader 创建并执行模块;
- 将模块对象放入
sys.modules; - 对包的子模块建立属性绑定;
- 将
import语句的结果绑定到当前作用域。
导入系统最容易被误解的地方在于:导入不是“读取一个 .py 文件”这么简单,而是“按名称查找、创建模块对象、执行代码并缓存对象”的过程。
Python 3.3 之后,导入系统的主要扩展点统一暴露在 sys.meta_path、sys.path_hooks 等对象上;Python 3.4 又引入了 ModuleSpec,让查找信息与加载过程之间形成了更明确的协议。(docs.python.org)
一、先区分三个概念:模块名、模块对象与模块文件
理解导入系统,首先要区分三个不同层次的东西。
1. 模块名是逻辑标识
例如:
import requests.sessions
这里的模块名是:
requests.sessions
它是导入系统使用的逻辑名称,不等于某个文件系统路径。
在常见安装环境中,它可能对应:
/path/to/site-packages/requests/sessions.py
但也可能来自:
- 内置模块;
- 冻结模块;
- ZIP 文件;
- 命名空间包的多个目录;
- 自定义 Finder;
- 其他非文件系统资源。
因此,下面两个问题并不等价:
模块名是什么?
模块文件在哪里?
模块名用于参与导入协议,文件路径只是某类 Loader 的实现细节。
2. 模块对象是运行时对象
模块被导入后,Python 会创建一个模块对象。这个对象通常具有如下属性:
import math
print(math)
print(math.__name__)
print(math.__spec__)
print(math.__loader__)
print(math.__package__)
print(math.__file__)
一个典型输出可能类似:
<module 'math' ...>
math
ModuleSpec(name='math', loader=...)
<class '_frozen_importlib.BuiltinImporter'>
对于纯 Python 文件,常见属性还包括:
module.__file__
module.__cached__
module.__path__ # 只有包通常具有
这些属性不是“额外注释”,而是导入系统在加载时根据 ModuleSpec 写入模块对象的运行时状态。(docs.python.org)
3. 模块文件只是模块的一种来源
例如:
import sys
print(sys.__file__)
在许多 CPython 环境中,sys 没有普通的 Python 源文件路径,因为它通常是内置模块。
再例如:
import _frozen_importlib
print(_frozen_importlib.__spec__)
有些模块由解释器以冻结形式提供。
所以,更准确的描述是:
Loader 根据 Finder 提供的模块规范,创建或取得一个模块对象,并执行模块内容;模块内容不一定来自普通的
.py文件。
二、import 语句实际上做了两件事
Python 语言参考将 import 拆成两个逻辑动作:
- 搜索并加载模块;
- 在当前作用域中绑定名称。
这两个动作经常被混在一起,但它们并不相同。(docs.python.org)
1. import package.module 的绑定结果
假设目录结构如下:
project/
├── main.py
└── acme/
├── __init__.py
└── tools.py
main.py:
import acme.tools
print(acme)
print(acme.tools)
执行后,当前作用域中通常绑定的是顶层名称:
acme
而不是直接绑定:
tools
acme.tools 是通过 acme 模块对象上的属性访问的。
可以近似理解为:
import acme.tools
完成了:
acme = sys.modules["acme"]
# acme.tools 指向 sys.modules["acme.tools"]
导入子模块时,导入系统还必须维护一个不变量:
sys.modules["acme.tools"] is acme.tools
也就是说,只要 acme 和 acme.tools 都已经成功导入,父包上的 tools 属性就应当指向对应的子模块对象。(docs.python.org)
2. from package import name 的绑定结果
from acme import tools
当前作用域直接得到:
tools
而:
from acme.tools import parse
当前作用域直接得到:
parse
这也是为什么 import 与 from ... import ... 在循环依赖中的表现可能不同:它们要求的名称绑定时机不同。
3. __import__() 与 importlib.import_module()
内置函数:
module = __import__("acme.tools")
print(module.__name__)
通常返回顶层包:
acme
而:
import importlib
module = importlib.import_module("acme.tools")
print(module.__name__)
返回被请求的具体模块:
acme.tools
程序化导入通常应使用:
importlib.import_module("acme.tools")
而不是直接调用 __import__(),因为后者主要是语言级导入机制的底层接口。(docs.python.org)
三、完整导入流程:从名称到模块对象
以:
import acme.tools
为例,可以把导入流程抽象成下面的状态变化:
flowchart TD
A[执行 import acme.tools] --> B[确定全限定名称]
B --> C{acme 是否在 sys.modules?}
C -->|是| D[复用 acme 模块]
C -->|否| E[遍历 sys.meta_path 查找 acme]
E --> F[得到 acme 的 ModuleSpec]
F --> G[创建并初始化 acme 模块]
G --> H[先放入 sys.modules]
H --> I[执行 acme.__init__.py]
I --> J[得到 acme.__path__]
D --> K{acme.tools 是否在 sys.modules?}
J --> K
K -->|是| L[复用 acme.tools]
K -->|否| M[遍历 sys.meta_path 查找 acme.tools]
M --> N[使用 acme.__path__ 进行子模块搜索]
N --> O[得到 acme.tools 的 ModuleSpec]
O --> P[创建模块并放入 sys.modules]
P --> Q[执行 tools.py]
Q --> R[设置 acme.tools 属性]
L --> S[完成 import 的名称绑定]
R --> S
其中最重要的两个顺序是:
先放入 sys.modules,再执行模块代码
以及:
先导入父包,再导入子模块
第二点解释了为什么导入:
import acme.tools.parsers
时,逻辑上需要依次处理:
acme
acme.tools
acme.tools.parsers
如果任何中间包导入失败,最终导入也会失败。(docs.python.org)
四、sys.modules:导入系统的模块缓存
1. sys.modules 缓存什么
sys.modules 是一个字典:
import sys
print(type(sys.modules))
print("json" in sys.modules)
它将模块全限定名映射到模块对象:
sys.modules["json"] -> json 模块对象
导入系统首先检查这个字典。若目标名称已经存在且值不是 None,Python 通常直接复用对应模块对象,不再重新查找和执行。(docs.python.org)
因此:
import math
first = math
import math
second = math
print(first is second)
输出:
True
这个结果不是因为 Python 每次都重新执行 math,而是因为两次导入取得了同一个缓存模块对象。
2. 缓存键是模块名,不是文件路径
以下两个名称可能对应同一个文件,但在导入系统看来是两个不同的缓存键:
import package.module
和某些情况下通过另一条路径或别名加载:
import another_name
如果两个不同名称分别创建了模块对象,那么可能出现:
sys.modules["package.module"] is not sys.modules["another_name"]
这会导致:
- 类身份不一致;
- 单例失效;
- 注册表出现两份;
isinstance()结果异常;- 模块级状态分裂。
例如:
class Plugin:
pass
如果同一个源文件以两个模块名加载,其中的两个 Plugin 类不是同一个类对象:
PluginA is not PluginB
所以,生产代码中不应依赖“同一个文件路径必然对应同一个模块对象”。导入系统的身份基础是模块全限定名。
3. 删除缓存不会销毁模块对象
import sys
import demo
module = demo
del sys.modules["demo"]
此时:
module
仍然是一个有效的模块对象,因为其他变量仍持有它的引用。
但下一次:
import demo
会重新搜索并创建或加载另一个模块对象。
于是可能出现:
module is not demo
官方文档明确指出,删除 sys.modules 中的键会使后续导入重新搜索,但不会自动销毁仍被其他对象引用的模块对象。(docs.python.org)
4. sys.modules[name] = None 的含义
可以显式阻止某个模块被导入:
import sys
sys.modules["blocked_module"] = None
随后:
import blocked_module
会抛出:
ModuleNotFoundError
但直接修改 sys.modules 是低层操作,容易让进程进入难以诊断的状态。它适合测试导入失败路径,不适合用作普通的模块禁用机制。
五、为什么模块要在执行前放入 sys.modules
这是理解循环依赖的关键。
一个简化的导入算法如下:
def approximate_import(fullname):
if fullname in sys.modules:
module = sys.modules[fullname]
if module is None:
raise ModuleNotFoundError(fullname)
return module
spec = find_spec(fullname)
module = module_from_spec(spec)
sys.modules[fullname] = module
try:
spec.loader.exec_module(module)
except BaseException:
del sys.modules[fullname]
raise
return sys.modules[fullname]
真实实现比这更复杂,但核心顺序相同:
- 找到
ModuleSpec; - 创建模块对象;
- 初始化模块属性;
- 将模块放入
sys.modules; - 执行模块代码;
- 执行失败时清理导入中的条目;
- 执行成功后返回模块。
官方文档给出的近似实现也体现了“创建模块后先写入 sys.modules,再调用 exec_module()”这一顺序。(docs.python.org)
这样做有两个原因:
原因一:避免递归创建无限模块对象
如果 a.py 导入 b.py,而 b.py 又导入 a.py:
a -> b -> a
当 b 再次请求 a 时,导入系统可以发现:
"a" in sys.modules
从而复用正在初始化中的 a 模块对象,而不是重新创建第三个 a。
原因二:允许模块之间共享同一个初始化中的对象
循环导入并不总是立即失败。如果被访问的名称已经在模块中定义,导入可能成功:
# a.py
value_a = "defined before importing b"
import b
# b.py
import a
print(a.value_a)
a.value_a 在 a 导入 b 之前已经创建,因此 b 可以读取它。
但如果定义顺序相反:
# a.py
import b
value_a = "defined after importing b"
# b.py
import a
print(a.value_a)
此时 b 访问的是一个尚未执行到 value_a 定义语句的部分初始化模块,通常会得到类似错误:
AttributeError: partially initialized module 'a' has no attribute 'value_a'
这不是 Python “随机失败”,而是模块执行顺序的直接结果。
六、Finder、Loader 与 ModuleSpec
1. Finder 的职责:回答“在哪里、由谁加载”
Finder 是参与导入查找的对象。它不负责执行模块代码,而是尝试为指定模块名返回一个 ModuleSpec。
概念上:
spec = finder.find_spec(fullname, path, target)
参数含义通常是:
fullname:全限定模块名,例如"acme.tools";path:父包的搜索路径;顶层模块通常为None;target:重新加载时可能传入的目标模块。
Finder 可以:
- 找到模块并返回
ModuleSpec; - 不处理该名称并返回
None; - 发现明确禁止导入时抛出异常。
如果 sys.meta_path 上的 Finder 都返回 None,导入系统最终抛出 ModuleNotFoundError。(docs.python.org)
2. Loader 的职责:创建并执行模块
Loader 负责加载阶段。现代 Loader 的主要接口是:
create_module(spec)
exec_module(module)
其中:
create_module()可以返回自定义模块对象,也可以返回None,让导入系统创建默认模块对象;exec_module()在模块对象的命名空间中执行初始化逻辑;exec_module()的返回值会被忽略。
对于 Python 源文件,Loader 的核心任务是把编译后的代码对象执行在:
module.__dict__
中。(docs.python.org)
旧式的:
load_module()
已经被弃用,并将在 Python 3.15 移除;新代码应实现 exec_module() 协议。(docs.python.org)
3. ModuleSpec 保存什么
可以查看一个模块的规范:
import json
spec = json.__spec__
print(spec.name)
print(spec.loader)
print(spec.origin)
print(spec.submodule_search_locations)
常见字段包括:
| 属性 | 含义 |
|---|---|
name |
模块全限定名 |
loader |
负责执行模块的 Loader |
origin |
模块来源,例如文件路径、built-in |
submodule_search_locations |
包的子模块搜索位置;普通模块通常为 None |
cached |
可能对应的字节码缓存位置 |
parent |
父包名称 |
ModuleSpec 将“模块叫什么”“从哪里来”“由谁加载”“是不是包”等信息集中起来,是 Finder 与 Loader 之间的协议对象。(docs.python.org)
七、sys.meta_path:导入查找的第一层分派
默认情况下,Python 会遍历 sys.meta_path:
import sys
for finder in sys.meta_path:
print(finder)
CPython 默认包含几类重要 Finder:
BuiltinImporter:查找内置模块;FrozenImporter:查找冻结模块;PathFinder:根据导入路径查找模块。
sys.meta_path 中的 Finder 按顺序尝试。顺序很重要:排在前面的 Finder 可以优先处理名称,甚至覆盖后面的标准路径搜索。
例如:
class DenyFinder:
@classmethod
def find_spec(cls, fullname, path=None, target=None):
if fullname == "dangerous_module":
raise ModuleNotFoundError(
"blocked by import policy",
name=fullname,
)
return None
注册:
import sys
sys.meta_path.insert(0, DenyFinder)
这里:
return None
表示“我不处理,继续让后面的 Finder 尝试”。
而:
raise ModuleNotFoundError(...)
表示“明确终止这次导入”。
这两个行为不能混淆。官方文档特别区分了“返回 None 继续搜索”和“抛出异常中止搜索”。(docs.python.org)
sys.meta_path 的遍历次数
导入:
import acme.tools.parsers
并不是只调用一次 Finder。
在所有模块都未缓存时,逻辑上可能经历:
find_spec("acme", None, None)
find_spec("acme.tools", acme.__path__, None)
find_spec("acme.tools.parsers", acme.tools.__path__, None)
也就是说,顶层包、子包和最终子模块各自进行一次名称查找。(docs.python.org)
八、sys.path:顶层模块的搜索位置
sys.path 是一个字符串列表,提供顶层模块和包的搜索位置:
import sys
for entry in sys.path:
print(repr(entry))
其中可能包含:
- 当前脚本目录;
- 当前工作目录对应的路径;
PYTHONPATH环境变量中的目录;- 标准库目录;
- 第三方包安装目录;
- ZIP 文件路径;
- 其他由导入系统支持的路径项。
具体初始化过程与启动方式、解释器安装位置、环境变量和虚拟环境有关。sys.path 的作用是提供待搜索的位置,而不是直接保存所有模块。(docs.python.org)
1. 路径顺序决定名称冲突时的优先级
假设目录结构:
project/
├── json.py
└── main.py
如果 project/ 排在标准库路径之前:
# main.py
import json
print(json.__file__)
可能导入当前项目中的 json.py,而不是标准库 json。
这类问题称为模块遮蔽。常见危险名称包括:
json.py
typing.py
socket.py
logging.py
email.py
random.py
诊断方式:
import json
print(json.__file__)
print(json.__spec__)
不要只根据导入语句判断来源,应检查模块对象的实际来源。
2. PYTHONPATH 不是唯一搜索路径
下面的命令会影响进程启动时的路径:
PYTHONPATH=/opt/acme python main.py
但运行时也可以修改:
import sys
sys.path.insert(0, "/opt/acme")
两者都能影响路径搜索,但含义不同:
PYTHONPATH影响解释器启动时的初始配置;sys.path.insert()影响当前进程后续导入;- 插入位置决定优先级;
- 修改后可能造成同名模块遮蔽。
路径修改不会自动重新加载已经缓存的模块。若目标模块已经存在于 sys.modules,下一次导入通常仍会直接复用缓存。
九、Path Finder、sys.path_hooks 与 sys.path_importer_cache
sys.meta_path 负责第一层查找。对于默认的文件系统和 ZIP 路径搜索,PathFinder 还要处理 sys.path 中的每个路径项。
可以将这部分过程抽象为:
sys.path 中的字符串
│
▼
sys.path_hooks
│
▼
Path Entry Finder
│
▼
ModuleSpec
1. Path Hook 的职责
sys.path_hooks 是一组可调用对象:
import sys
for hook in sys.path_hooks:
print(hook)
对于某个路径项,导入系统依次调用这些 Hook,尝试为该路径创建 Path Entry Finder。
如果某个 Hook 不支持该路径,通常抛出 ImportError,导入系统继续尝试下一个 Hook。
2. Path Entry Finder 的职责
Path Entry Finder 只负责在特定路径项内查找模块。例如:
- 在普通目录中查找
.py、.pyc或扩展模块; - 在 ZIP 文件中查找模块;
- 在自定义资源系统中查找模块。
它与 sys.meta_path 上的 Meta Path Finder 概念相似,但搜索范围不同:
| 类型 | 搜索范围 |
|---|---|
| Meta Path Finder | 可以在全局层面决定如何查找 |
| Path Entry Finder | 负责某个具体路径项 |
3. sys.path_importer_cache
路径项对应的 Finder 通常会被缓存到:
import sys
print(sys.path_importer_cache)
这样,同一个路径不必每次导入都重新构造 Path Entry Finder。
这解释了动态创建模块时的一个现象:
from pathlib import Path
import importlib
Path("dynamic_mod.py").write_text("value = 42", encoding="utf-8")
importlib.invalidate_caches()
dynamic_mod = importlib.import_module("dynamic_mod")
print(dynamic_mod.value)
当模块文件是在解释器运行期间才创建时,某些 Finder 可能已经缓存了目录内容或相关状态。importlib.invalidate_caches() 会要求已安装的 Finder 重新检查其缓存。(docs.python.org)
注意:
invalidate_caches()
不会:
- 清空
sys.modules; - 重新执行已加载模块;
- 修复错误的
sys.path; - 自动卸载旧模块。
它只针对 Finder 的搜索缓存。
十、包、__path__ 与子模块搜索
1. 普通模块与包的区别
一个普通模块通常对应:
tools.py
一个传统包通常对应:
acme/
├── __init__.py
└── tools.py
导入:
import acme.tools
时,Python 先导入 acme,然后使用:
acme.__path__
查找 tools。
因此:
sys.path
用于查找顶层包 acme,而:
acme.__path__
用于查找 acme 下的子模块。
2. __path__ 不是普通路径字符串
对包而言:
import acme
print(acme.__path__)
__path__ 通常是一个可迭代对象,表示包内部的子模块搜索位置。
不要将以下两个概念混为一谈:
sys.path 顶层导入搜索路径
package.__path__ 该包内部的子模块搜索路径
3. 命名空间包
Python 3.3 起支持原生命名空间包。命名空间包可以由多个目录共同组成,且不要求每个目录都存在 __init__.py。(docs.python.org)
例如:
/opt/vendor_a/acme/plugins/
└── a.py
/opt/vendor_b/acme/plugins/
└── b.py
如果这些位置都在有效搜索路径中,就可能形成:
acme.plugins
其 __path__ 包含多个目录。
查看:
import acme.plugins
print(list(acme.plugins.__path__))
命名空间包的关键特征是:
- 不一定有
__file__; __spec__.origin可能为None;__spec__.submodule_search_locations不为None;- 多个路径可以共同提供子模块。
这也是导入系统不能简单地“把包映射为一个目录”的原因。
十一、Loader 的执行边界:导入代码就是执行代码
导入模块时,模块顶层代码会执行:
# settings.py
print("settings imported")
value = 42
import settings
输出:
settings imported
因此,导入不是纯查询操作。以下代码都会在导入时发生:
# module.py
open("log.txt", "a").write("imported\n")
database.connect()
register_plugin()
start_background_thread()
这些副作用可能导致:
- 仅为了读取一个类而连接数据库;
- 测试导入模块时启动线程;
- 循环导入时注册状态不完整;
- 重新加载后重复注册;
- 导入顺序影响全局状态。
Loader 执行的是模块初始化过程。对于 Python 模块,代码通常在模块对象的全局命名空间中执行;exec_module() 返回什么并不决定最终导入结果。(docs.python.org)
模块执行失败时发生什么
# broken.py
print("before error")
raise RuntimeError("boom")
import broken
输出可能为:
before error
RuntimeError: boom
导入失败后,导入系统通常会清理本次失败模块在 sys.modules 中的条目,但它引发的外部副作用不会自动回滚:
- 已写入文件的内容不会消失;
- 已发送的网络请求不会撤销;
- 已注册到其他模块的对象可能仍然存在;
- 已启动的线程不会因为导入失败自动停止。
所以,模块顶层代码具有进程启动阶段代码的性质,应当认真对待其失败路径。
十二、importlib.util.find_spec():只查找,不等于完全无副作用
可以使用:
import importlib.util
spec = importlib.util.find_spec("json")
print(spec)
print(spec.name)
print(spec.loader)
print(spec.origin)
这适合诊断:
- 模块是否可发现;
- 模块来自哪里;
- 使用什么 Loader;
- 是否是包。
但有一个重要边界:
importlib.util.find_spec("package.submodule")
如果目标是子模块,寻找它之前可能需要先导入父包,因为必须获得父包的 __path__。官方文档明确提醒,查找带点号的子模块可能会导入父模块。(docs.python.org)
因此,“调用 find_spec() 就绝对不会执行任何导入代码”是不正确的。
十三、动态导入:import_module() 的生命周期
一个常见插件系统会根据字符串导入模块:
import importlib
module_name = "plugins.image"
plugin_module = importlib.import_module(module_name)
完整示例:
project/
├── main.py
└── plugins/
├── __init__.py
└── image.py
plugins/image.py:
class Plugin:
name = "image"
def run(self):
return "image plugin executed"
main.py:
import importlib
module = importlib.import_module("plugins.image")
plugin = module.Plugin()
print(plugin.name)
print(plugin.run())
预期输出:
image
image plugin executed
这里的生命周期是:
字符串模块名
-> import_module()
-> 查找 ModuleSpec
-> 创建或复用模块
-> 执行 image.py
-> 返回 plugins.image 模块对象
如果模块名是相对名称:
module = importlib.import_module(".image", package="plugins")
则:
".image" + "plugins" -> "plugins.image"
相对导入必须有锚点包。importlib.import_module("..mod", "pkg.subpkg") 这类调用会根据 package 解析目标名称。(docs.python.org)
十四、相对导入为什么依赖 __package__
相对导入:
from .utils import parse
中的点不是文件系统路径,而是包层级。
- 一个点:当前包;
- 两个点:父包;
- 更多点:继续向上移动包层级。
例如:
acme/
├── __init__.py
├── utils.py
└── sub/
├── __init__.py
└── worker.py
在 acme/sub/worker.py 中:
from ..utils import parse
表示导入:
acme.utils
相对导入的解析依赖模块的包上下文,通常由:
__package__
提供。
直接执行文件时的失败
直接执行:
python acme/sub/worker.py
此时该文件通常作为:
__name__ == "__main__"
运行,不能自然地获得完整包上下文,因此:
from ..utils import parse
可能失败。
而从项目根目录以模块方式执行:
python -m acme.sub.worker
导入系统可以根据包名建立正确的模块上下文,相对导入才有解析依据。
这就是“脚本路径执行”和“模块方式执行”行为不同的根本原因,而不是相对导入本身不稳定。相对导入必须依赖明确的包层级;官方语言参考也将 __main__ 视为导入系统中的特殊情形。(docs.python.org)
十五、循环依赖:本质是模块执行图中的回边
把每个模块看成一个节点,把导入关系看成一条有向边:
a -> b
表示 a 的执行过程需要导入 b。
如果导入图是:
a -> b -> c
它是有向无环图,模块可以按拓扑顺序初始化:
c -> b -> a
如果存在:
a -> b -> a
就形成环。
更复杂的环可能是:
a -> b -> c -> a
1. 一个可运行的循环依赖示例
a.py:
print("start a")
import b
value_a = "A"
print("end a")
b.py:
print("start b")
import a
print("b sees a.value_a =", a.value_a)
执行:
python -c "import a"
过程大致是:
1. 开始导入 a
2. 创建 a 模块对象
3. sys.modules["a"] = a
4. 执行 a.py
5. a 导入 b
6. 创建 b 模块对象
7. sys.modules["b"] = b
8. 执行 b.py
9. b 导入 a
10. 命中 sys.modules["a"],取得部分初始化的 a
11. b 访问 a.value_a
12. 此时 a 尚未执行到 value_a = "A"
13. 导入失败
失败通常类似:
AttributeError: partially initialized module 'a' has no attribute 'value_a'
2. from ... import ... 更容易暴露时序问题
b.py 改成:
from a import value_a
此时它不是只取得 a 模块对象,而是立即要求从 a 的命名空间中取得:
value_a
如果该名称还没有定义,通常会得到:
ImportError: cannot import name 'value_a' from partially initialized module 'a'
相比之下:
import a
只是先绑定模块对象,属性访问可能延迟到之后:
import a
def use():
return a.value_a
这有时可以避开初始化阶段的错误,但它并没有消除依赖环。若 use() 在 a 完成初始化前被调用,问题仍然存在。
3. 循环依赖不是“所有情况下都错误”
下面的代码可能成功:
a.py:
value_a = "A"
import b
b.py:
import a
print(a.value_a)
因为 value_a 已经在 a 导入 b 前定义。
因此,循环依赖能否成功,取决于:
- 环中模块的执行顺序;
- 被访问名称定义在模块的哪个位置;
- 是否通过模块属性延迟访问;
- 是否发生函数调用、类定义或注册行为;
- 是否存在异常路径。
准确说法不是“循环导入一定失败”,而是:
循环导入会使模块处于部分初始化状态;任何依赖尚未执行代码的名称访问,都可能失败。
十六、循环依赖的另一种来源:模块被执行两次
下面的项目:
project/
├── app.py
└── package/
├── __init__.py
└── app.py
如果同时出现:
python app.py
以及:
import package.app
那么同一份或相似代码可能分别以两个名称存在:
__main__
package.app
这不是同一个缓存键,因此可能创建两个模块对象。
后果包括:
- 顶层初始化执行两次;
- 类对象不是同一个身份;
- 全局注册重复;
if __name__ == "__main__"分支行为不同;- 看似没有循环导入,实际出现双重初始化。
使用:
python -m package.app
可以让代码以模块上下文运行,但 __main__ 仍然是执行入口的特殊模块名。设计入口时,应避免让同一业务模块既承担可复用库模块,又被多个方式直接执行。
十七、导入锁、并发与线程安全边界
导入具有共享全局状态:
sys.modules
sys.meta_path
sys.path
sys.path_importer_cache
如果两个线程同时导入同一个尚未加载的模块,导入系统需要避免它们并行执行同一模块的初始化代码。CPython 使用与导入相关的锁,现代实现主要采用按模块的锁,而不是简单地用一个全局锁保护所有模块导入。(docs.python.org)
这意味着:
# module.py
print("module initialization")
在正常导入协议下,不应因为两个线程同时首次导入而简单地执行两次。
但这不等于模块初始化天然适合并发。以下情况仍需谨慎:
- 模块导入期间启动线程;
- 导入代码等待另一个线程;
- 自定义 Finder 或 Loader 使用自己的锁;
- 导入模块时访问尚未完成初始化的其他模块;
- 手工删除或替换
sys.modules; - 使用
reload()与其他线程并发访问模块。
导入锁只能保护导入协议中的特定阶段,不能回滚模块副作用,也不能保证模块自身的全局状态设计是线程安全的。
十八、reload() 不是“卸载后重新导入”
import importlib
import config
importlib.reload(config)
reload() 会在原模块对象上重新执行模块代码,而不是保证创建一个全新的模块对象。官方文档指出,reload() 会复用同一个模块对象,重新初始化其内容。(docs.python.org)
这会产生几个容易忽略的结果。
1. 外部引用不会自动更新
from config import value
importlib.reload(config)
重新加载 config 后,局部变量 value 仍然是之前绑定的对象,不会自动重新执行:
from config import value
2. 被删除的旧名称可能残留
假设第一次加载:
# config.py
old_name = 1
后来改成:
# config.py
new_name = 2
重新加载时,模块字典可能仍保留旧的:
config.old_name
因为重新执行代码不一定清空模块字典。
3. 类实例不会自动变成新类
obj = config.Config()
importlib.reload(config)
重新加载后,config.Config 可能是一个新的类对象,但已有的:
obj
仍然是旧类的实例。
因此,reload() 更适合开发期配置或实验场景,不适合作为通用热更新机制。
十九、导入缓存与字节码缓存不是一回事
两个名称经常被混淆:
sys.modules
__pycache__
sys.modules
它是运行时内存缓存,保存:
模块名 -> 模块对象
作用是避免同一进程重复创建和执行模块。
__pycache__
它保存编译后的字节码文件,通常形如:
__pycache__/module.cpython-314.pyc
作用是减少下次启动时从源代码重新编译的成本。
因此:
rm -rf __pycache__
不会让当前进程中已经导入的模块重新执行。
而:
del sys.modules["module"]
也不会删除磁盘上的字节码缓存。
两者属于不同层次:
磁盘字节码缓存:减少编译成本
内存模块缓存:避免重复初始化
删除 .pyc 不能解决已经发生的模块状态污染;删除 sys.modules 也不能替代正确的代码部署和缓存失效策略。
二十、诊断导入问题的最小工具集
1. 检查实际导入来源
import some_module
print("name =", some_module.__name__)
print("file =", getattr(some_module, "__file__", None))
print("spec =", some_module.__spec__)
print("loader =", some_module.__loader__)
print("pkg =", some_module.__package__)
print("path =", getattr(some_module, "__path__", None))
重点观察:
__file__是否指向预期目录;__spec__.origin是否显示built-in或其他来源;__loader__是否为预期 Loader;- 是否意外加载了同名本地文件。
2. 检查缓存身份
import sys
import some_module
print(sys.modules["some_module"] is some_module)
print(id(sys.modules["some_module"]))
print(id(some_module))
如果怀疑同一个源文件被以多个名称加载,可以检查:
for name, module in sys.modules.items():
if getattr(module, "__file__", None) == "/path/to/target.py":
print(name, module)
同一文件对应多个模块名,通常意味着项目边界、入口方式、路径配置或自定义加载逻辑存在问题。
3. 检查模块是否可发现
import importlib.util
spec = importlib.util.find_spec("some_module")
print(spec)
如果输出为:
None
通常表示当前导入环境下没有 Finder 能提供该模块。
但如果目标是子模块,记住父包可能已经被导入。
4. 使用导入时间分析
可以使用解释器选项:
python -X importtime -c "import json"
它会输出模块导入耗时及其层级关系。这个工具适合发现:
- 启动时导入链过长;
- 某个模块导入异常缓慢;
- 不必要的顶层导入;
- 第三方包初始化开销集中在哪里。
它测量的是导入阶段耗时,不等于业务首次调用耗时,也不等于模块所有运行时成本。
5. 使用 -v 观察导入细节
python -v -c "import json"
可以看到更详细的导入尝试、路径和缓存信息。输出较多,适合定位:
- 实际搜索了哪些路径;
- 是否命中
.pyc; - 哪个包先被导入;
- 为什么搜索到了意外位置。
二十一、自定义 Finder 与 Loader:一个完整的内存模块示例
下面创建一个不依赖 .py 文件的导入器。它将特定模块名映射到内存中的源代码字符串。
import importlib.abc
import importlib.util
import sys
import types
SOURCES = {
"virtual_hello": "message = 'hello from memory'\n",
}
class MemoryLoader(importlib.abc.Loader):
def create_module(self, spec):
# 返回 None,表示使用导入系统创建的默认模块对象。
return None
def exec_module(self, module):
source = SOURCES[module.__name__]
code = compile(source, f"<memory:{module.__name__}>", "exec")
exec(code, module.__dict__)
class MemoryFinder(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path=None, target=None):
if fullname not in SOURCES:
return None
loader = MemoryLoader()
return importlib.util.spec_from_loader(
fullname,
loader,
origin=f"<memory:{fullname}>",
)
sys.meta_path.insert(0, MemoryFinder())
import virtual_hello
print(virtual_hello.message)
print(virtual_hello.__spec__.origin)
预期输出:
hello from memory
<memory:virtual_hello>
这个例子完整展示了:
import virtual_hello
-> sys.modules 中没有目标
-> MemoryFinder.find_spec()
-> 返回 ModuleSpec
-> MemoryLoader.create_module()
-> 导入系统初始化模块属性
-> MemoryLoader.exec_module()
-> 模块命名空间得到 message
-> 模块进入并保留在 sys.modules
如果没有:
return None
而是对所有未知名称都抛出异常,那么标准库和第三方模块也可能无法继续由后续 Finder 处理。
自定义导入器因此不是普通的“重写 import”,而是在标准导入协议中插入新的查找与执行策略。(docs.python.org)
二十二、导入失败的几类不同含义
1. ModuleNotFoundError
import not_exist
通常表示没有 Finder 找到指定模块,或者模块名被显式设置为 None。
但报错位置很重要:
import package.submodule
可能是:
package找不到;package导入失败;package没有正确的__path__;submodule找不到;- 某个更深层依赖找不到。
不能只看最后一行的模块名,要查看完整 traceback。
2. ImportError: cannot import name
from module import missing_name
说明模块本身可能已经找到并加载,但指定名称没有在其命名空间中取得。
可能原因包括:
- 名称拼写错误;
- 模块没有定义该名称;
__all__或重新导出逻辑不符合预期;- 循环依赖导致模块尚未完成初始化;
- 版本之间 API 已变化。
3. “部分初始化模块”
类似:
partially initialized module
通常是循环依赖的重要信号,但也可能与导入模块自身抛出异常、名称遮蔽或多重加载有关。
诊断顺序可以是:
print(module.__name__)
print(module.__file__)
print(module.__spec__)
然后检查:
- 导入图是否存在环;
- 是否有本地文件遮蔽标准库;
- 是否以多个模块名加载同一文件;
- 失败模块是否在导入期间执行了外部副作用;
- 是否修改过
sys.modules或sys.path。
二十三、常见误解
误解一:import 每次都会重新执行文件
不正确。
只要目标模块已经在 sys.modules 中,通常会复用已有模块对象。只有删除缓存、重新启动解释器、显式 reload() 或以另一个名称加载等情况,才可能再次执行代码。
误解二:sys.path 决定所有模块的搜索
不完整。
顶层路径搜索通常依赖 sys.path,但 sys.meta_path 可以在它之前拦截请求;包内部搜索还依赖父包的 __path__;内置和冻结模块也不一定来自普通文件系统路径。
误解三:Finder 找到文件后直接执行文件
不准确。
现代协议中,Finder 返回的是 ModuleSpec,其中包含 Loader。Loader 才负责创建或执行模块。Finder 和 Loader 是查找与执行的不同职责。(docs.python.org)
误解四:相对导入相对于当前文件目录
不准确。
相对导入相对于包上下文,而不是简单地对当前文件路径做字符串拼接。直接执行包内文件时缺少正确包上下文,是相对导入失败的常见原因。
误解五:reload() 会恢复模块到干净状态
不正确。
reload() 复用原模块对象并重新执行代码,外部引用、旧名称、旧实例和外部副作用都不会自动恢复或撤销。
误解六:循环导入必然报错
不准确。
循环导入是否失败取决于环中各模块的执行顺序,以及访问的名称是否已经初始化。循环导入的本质问题是“访问部分初始化状态”,而不是环本身在语法层面非法。
二十四、导入系统的工程取舍
1. 顶层导入与函数内导入
顶层导入:
import database
优点是依赖关系清晰,导入错误尽早暴露。
函数内导入:
def handle():
import database
return database.query()
可能用于:
- 延迟加载;
- 可选依赖;
- 避免启动时初始化;
- 打破部分导入时序问题。
但函数内导入不是循环依赖的根本修复。如果模块之间仍然在运行时互相依赖,复杂度只是从导入阶段推迟到了函数调用阶段。
2. 共享依赖应下沉到更低层模块
假设:
service_a -> service_b -> service_a
如果两者共享数据结构,可以提取为:
service_a -> contracts
service_b -> contracts
这不是为了机械地“消灭所有环”,而是为了让依赖图表达真实的分层关系。
尤其不要让基础模块导入上层业务模块:
基础类型 -> 业务服务 -> 基础类型
这类环通常说明模块边界与依赖方向不匹配。
3. 用类型检查专用导入降低运行时依赖
如果依赖只用于类型注解,可以使用:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from .service import Service
并配合字符串注解或延迟注解策略:
def run(service: "Service") -> None:
...
但这只能减少运行时导入,不能解决真正的运行时调用环。类型检查器、解释器和代码生成工具还可能对这些导入有不同处理方式,因此应明确区分:
静态类型依赖
运行时对象依赖
4. 不要随意改写全局导入器
修改:
sys.meta_path
sys.path_hooks
sys.path
sys.modules
会影响整个进程,而不仅仅是当前函数。
自定义导入器适合:
- 插件系统;
- 特定资源后端;
- 受控的模块虚拟化;
- 解释器扩展。
它不适合用来掩盖:
- 包结构混乱;
- 同名模块冲突;
- 不清晰的部署路径;
- 循环依赖;
- 不可控的顶层副作用。
二十五、导入系统与安全边界
动态导入本身不是安全沙箱。
import importlib
module = importlib.import_module(user_input)
如果 user_input 可由不可信用户控制,攻击者可能导入进程环境中可访问的任意模块,并触发其顶层代码。
更危险的形式包括:
module = importlib.import_module(name)
getattr(module, function_name)(*args)
这里不仅模块名可控,调用对象也可控。
安全边界不能依赖:
try:
import ...
except ImportError:
...
也不能依赖简单的字符串黑名单。更可靠的方式是:
- 使用显式白名单;
- 将插件注册表与导入名称分离;
- 验证插件接口;
- 将不可信插件放入独立进程;
- 限制文件系统、网络和系统调用权限。
导入系统的扩展性是协议能力,不是隔离能力。
二十六、用一句近似算法串起全部机制
可以用下面的伪代码总结普通模块导入:
def import_module(fullname):
# 第一阶段:模块缓存
if fullname in sys.modules:
cached = sys.modules[fullname]
if cached is None:
raise ModuleNotFoundError(fullname)
return cached
# 第二阶段:准备父包路径
if "." in fullname:
parent_name, _, child_name = fullname.rpartition(".")
parent = import_module(parent_name)
search_path = parent.__path__
else:
parent = None
child_name = fullname
search_path = None
# 第三阶段:Finder 查找
for finder in sys.meta_path:
spec = finder.find_spec(fullname, search_path, None)
if spec is not None:
break
else:
raise ModuleNotFoundError(fullname)
# 第四阶段:Loader 创建模块
module = module_from_spec(spec)
# 第五阶段:提前登记,支持循环依赖检测与复用
sys.modules[fullname] = module
try:
# 第六阶段:执行模块代码
spec.loader.exec_module(module)
except BaseException:
# 第七阶段:失败清理
sys.modules.pop(fullname, None)
raise
# 第八阶段:建立父子模块属性关系
if parent is not None:
setattr(parent, child_name, module)
return module
这不是 Python 标准库源码的逐字实现,而是用于理解控制流的近似模型。真正实现还涉及:
- 导入锁;
- 命名空间包;
- 内置和冻结模块;
- 扩展模块;
- 相对名称解析;
- 缓存失效;
- 重载目标;
- 异常清理;
- 不同解释器实现差异。
但这段模型已经解释了大部分日常问题:
为什么第二次 import 很快?
因为命中 sys.modules。
为什么要先导入父包?
因为子模块需要父包的 __path__。
Finder 找到后为什么还要 Loader?
Finder 只返回 ModuleSpec,Loader 执行模块。
为什么会有 partially initialized module?
因为模块已进入 sys.modules,但代码尚未执行完。
为什么修改 sys.path 有时不生效?
因为模块可能已在 sys.modules,或 Finder 缓存了路径信息。
为什么 reload 后外部变量没变?
因为 reload 重新执行模块对象,不会重做其他作用域的名称绑定。
结语
Python 导入系统的核心不是某个单独的 API,而是一条完整的数据流:
模块名
-> sys.modules 缓存
-> sys.meta_path Finder
-> ModuleSpec
-> Loader
-> 模块对象
-> 执行模块代码
-> sys.modules
-> 当前作用域绑定
其中:
- 搜索路径决定默认的可发现范围;
- 缓存决定模块身份和是否重复执行;
- Finder决定模块由什么策略找到;
- Loader决定模块如何创建和执行;
- 循环依赖暴露了模块执行尚未完成时的部分状态;
- **包的
__path__**决定子模块继续在哪里搜索; __main__、相对导入和入口方式决定包上下文是否成立。
当导入出现异常时,不应只盯着一句 import。应沿着这条链逐层检查:
名称是否正确
→ 是否命中错误缓存
→ sys.path 是否正确
→ 父包是否正确
→ Finder 找到了什么
→ Loader 执行了什么
→ 模块是否部分初始化
→ 是否存在同一文件的多名称加载
掌握这条链之后,ModuleNotFoundError、模块遮蔽、循环依赖、插件加载、动态导入和导入性能问题,就不再是彼此孤立的故障,而是同一套导入状态机在不同阶段暴露出的结果。
系列导航与关联阅读
- 系列入口:Python 完整学习路线:从语言模型、并发到 Web、数据、AI 与生产交付
- 上一篇:Python 模块与包:命名空间、init、入口和项目边界
- 下一篇:Python 对象模型:type、object、属性查找、身份与生命周期
- 延伸:Python 反射与自省:inspect、签名、Frame、AST 和安全边界
官方资料
本文依据 Python 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论