Python 基础体系 · 第 21/112 篇。示例统一以 Python 3.14 为语言基线;第三方库使用与其兼容的现代稳定版本,版本敏感行为会单独说明。

Python 导入系统:搜索路径、缓存、Finder、Loader 与循环依赖

Python 中的 import 看起来像一句简单的声明:

import json

但它实际上启动了一套完整的模块加载协议。这个协议至少包含以下阶段:

  1. 确定要导入的全限定模块名
  2. 检查 sys.modules 模块缓存;
  3. 通过 sys.meta_path 查找模块;
  4. 必要时根据 sys.path 或包的 __path__ 搜索位置;
  5. 由 Finder 返回 ModuleSpec
  6. 由 Loader 创建并执行模块;
  7. 将模块对象放入 sys.modules
  8. 对包的子模块建立属性绑定;
  9. import 语句的结果绑定到当前作用域。

导入系统最容易被误解的地方在于:导入不是“读取一个 .py 文件”这么简单,而是“按名称查找、创建模块对象、执行代码并缓存对象”的过程。

Python 3.3 之后,导入系统的主要扩展点统一暴露在 sys.meta_pathsys.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 拆成两个逻辑动作:

  1. 搜索并加载模块
  2. 在当前作用域中绑定名称

这两个动作经常被混在一起,但它们并不相同。(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

也就是说,只要 acmeacme.tools 都已经成功导入,父包上的 tools 属性就应当指向对应的子模块对象。(docs.python.org)

2. from package import name 的绑定结果

from acme import tools

当前作用域直接得到:

tools

而:

from acme.tools import parse

当前作用域直接得到:

parse

这也是为什么 importfrom ... 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]

真实实现比这更复杂,但核心顺序相同:

  1. 找到 ModuleSpec
  2. 创建模块对象;
  3. 初始化模块属性;
  4. 将模块放入 sys.modules
  5. 执行模块代码;
  6. 执行失败时清理导入中的条目;
  7. 执行成功后返回模块。

官方文档给出的近似实现也体现了“创建模块后先写入 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_aa 导入 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:

  1. BuiltinImporter:查找内置模块;
  2. FrozenImporter:查找冻结模块;
  3. 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_hookssys.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__)

然后检查:

  1. 导入图是否存在环;
  2. 是否有本地文件遮蔽标准库;
  3. 是否以多个模块名加载同一文件;
  4. 失败模块是否在导入期间执行了外部副作用;
  5. 是否修改过 sys.modulessys.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 官方文档、相关 PEP 与生态项目官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。