Python import 到底做了什么?从 `sys.modules`、循环导入到插件加载与 5 秒冷启动诊断

Python import 到底做了什么?从 sys.modules、循环导入到插件加载与 5 秒冷启动诊断

很多 Python 开发者第一次接触 import 时,会自然地把它理解为一句很简单的话:

“把另一个 .py 文件拿过来用。”

这个理解不能说错,但只覆盖了表面。

在真实的 Python 工程里,import 背后牵涉到模块缓存、路径搜索、模块创建、代码执行、命名空间绑定、Finder、Loader、ModuleSpec 以及动态加载机制。一旦项目规模扩大,这些机制会直接影响架构设计、循环依赖、插件系统,甚至服务的冷启动性能。

比如这两行代码:

import foo
import foo

为什么 foo.py 里的代码通常只执行一次?

再比如:

ImportError: cannot import name 'User' from partially initialized module 'models'
(most likely due to a circular import)

所谓 partially initialized module 到底“初始化了一半”是什么意思?

还有一个更现实的问题:

一个大型 Python 服务冷启动需要 8 秒,profiling 后发现其中 5 秒都花在 import 上,应该从哪里下手?

这篇文章就从这一句最普通的 import foo 出发,把 Python 导入系统完整拆开。


一、先做一个实验:为什么第二次 import foo 不重新执行?

创建 foo.py

print("foo.py 正在执行")

VALUE = 42

def hello():
    print("hello")

然后创建 main.py

import foo
import foo

print(foo.VALUE)

执行:

python main.py

结果通常是:

foo.py 正在执行
42

注意:

foo.py 正在执行

只出现了一次。

原因并不是 Python 看到“上一行刚 import 过”,而是 Python 维护着一张非常重要的模块缓存:

sys.modules

第一次:

import foo

完成后,大致会产生这样的状态:

import sys

print(sys.modules["foo"])

输出类似:

<module 'foo' from '/project/foo.py'>

也就是说:

sys.modules
    ↓
{
    "foo": <module foo>
}

第二次执行:

import foo

Python 首先检查:

"foo" in sys.modules

发现模块已经存在,于是直接复用已有模块对象,而不会再次执行 foo.py 的顶层代码。

Python 官方导入系统文档也明确说明:导入搜索首先检查 sys.modules;如果对应模块已经存在,通常直接返回缓存中的模块对象。(Python documentation)


二、sys.modules 到底是什么?

可以把:

sys.modules

理解成 Python 解释器当前进程中的:

模块对象注册表 + 一级 import 缓存。

它本质上是一个字典。

import sys

print(type(sys.modules))

输出:

<class 'dict'>

键是完整模块名:

"json"
"os"
"collections"
"package.submodule"

值是对应的模块对象。

例如:

import json
import sys

print(sys.modules["json"] is json)

结果:

True

甚至可以观察两次 import 得到的对象:

import foo

first = foo

import foo

second = foo

print(first is second)

输出:

True

所以更准确地说:

import foo

并不意味着:

每次都重新读取 foo.py。

而更接近:

if "foo" in sys.modules:
    foo = sys.modules["foo"]
else:
    # 搜索、创建并执行 foo
    ...

当然,真实实现复杂得多。


可以手动删除 sys.modules 吗?

技术上可以:

import sys
import foo

del sys.modules["foo"]

import foo

这时 Python 会重新进行一次模块搜索和加载。

但是不要把它当作普通的“重新加载模块”方案。

因为:

old_foo = foo

del sys.modules["foo"]

import foo as new_foo

print(old_foo is new_foo)

通常得到:

False

旧模块对象并不会因为删除 sys.modules 项就自动消失,因为其他地方可能仍然持有它的引用。

如果真的需要重新执行模块,更合适的是:

import importlib
import foo

importlib.reload(foo)

即便如此,生产系统中也应该慎用 reload():已有实例、from x import y 导入出来的旧引用、C 扩展状态等,都可能带来非常微妙的问题。


三、一条 import foo 背后究竟发生了什么?

忽略部分边界情况后,可以把 Python import 的核心流程理解为:

import foo
   │
   ▼
检查 sys.modules
   │
   ├── 已存在 ──────► 直接复用模块
   │
   ▼
通过 Finder 查找模块
   │
   ▼
得到 ModuleSpec
   │
   ▼
创建 module 对象
   │
   ▼
提前加入 sys.modules
   │
   ▼
Loader 执行模块代码
   │
   ▼
模块初始化完成
   │
   ▼
把名称 foo 绑定到当前命名空间

其中最容易被忽视的一步是:

创建模块
   ↓
加入 sys.modules
   ↓
执行模块代码

不是:

执行完代码
   ↓
再放进 sys.modules

这一点极其重要。

官方文档给出的导入过程伪代码,本质上就是:

module = create_module(...)

sys.modules[name] = module

try:
    loader.exec_module(module)
except BaseException:
    del sys.modules[name]
    raise

也就是说,模块的代码还没执行完,它就已经出现在 sys.modules 中了。

这是循环导入问题的核心。(Python documentation)


四、为什么循环导入会出现 partially initialized module

假设有两个文件。

user.py

from order import create_order

class User:
    pass

order.py

from user import User

def create_order():
    return User()

现在执行:

import user

发生了什么?


第一步:开始加载 user

Python 创建:

module user

然后先登记

sys.modules["user"] = user

但是此时:

class User:
    ...

还没有执行。

于是现在的 user 模块可以近似看成:

user.__dict__ = {
    "__name__": "user",
    ...
}

里面暂时没有:

User

第二步:执行到 from order import create_order

Python 开始加载 order.py

同样:

sys.modules["order"] = order

然后执行:

from user import User

第三步:再次寻找 user

Python一查:

"user" in sys.modules

结果是:

True

于是不会重新执行 user.py

问题来了。

当前 user 虽然已经存在,但:

class User:

那一行还没运行。

于是:

user.User

不存在。

这就是:

partially initialized module

它不是一个神秘状态。

翻译成人话就是:

模块对象已经创建并注册了,但模块顶层代码还没有执行完成。


五、循环导入怎么解决?

不要看到循环导入就机械地把 import 移到函数里面。真正应该问的是:

为什么模块 A 和模块 B 在架构上互相依赖?

常见解决方案有四种。

方案一:提取共同依赖

原来:

user.py ───► order.py
   ▲           │
   └───────────┘

可以调整为:

user.py ─────┐
             ▼
          domain.py
             ▲
order.py ────┘

例如把公共模型移出来:

# domain.py

class User:
    pass

随后:

# user.py
from domain import User
# order.py
from domain import User

这是大型项目最值得优先考虑的解决方式。


方案二:延迟 import

如果依赖只在运行某个函数时才需要:

def create_order():
    from user import User
    return User()

这样导入发生在函数运行阶段,而不是模块初始化阶段。

这种方式简单有效,但不要用它掩盖糟糕的模块边界。


方案三:使用模块引用,而不是立即取属性

相比:

from user import User

有时:

import user

更容易处理循环依赖,因为真正访问:

user.User

可以延后发生。


方案四:类型提示使用 TYPE_CHECKING

很多循环导入只是为了类型注解。

可以写:

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from user import User

def create_order(user: "User"):
    ...

这样静态类型检查器能够看到依赖,但运行时不会真的执行这次 import。


六、importlib、Finder、Loader 分别负责什么?

如果想真正理解 Python import,记住三个角色:

importlib
Finder
Loader

可以把整个系统想象成物流体系。

你:我要 foo

Finder:
“我知道 foo 在哪里。”

Loader:
“我负责把它变成可使用的 Python 模块。”

importlib:
“我负责组织整个过程。”

1. importlib:Python import 系统的程序化 API

最常用的是:

import importlib

module = importlib.import_module("json")

效果类似:

import json

真正的价值在于模块名可以动态生成:

module_name = "plugins.email"

plugin = importlib.import_module(module_name)

这正是插件系统、框架自动发现、配置驱动模块加载的基础。

官方文档也推荐使用 importlib.import_module() 进行程序化模块导入。(Python documentation)


2. Finder:模块“在哪里”?

Finder 负责回答:

myapp.services.payment 到底在哪里?

Python 会遍历:

sys.meta_path

可以看看当前解释器中有哪些 Finder:

import sys

for finder in sys.meta_path:
    print(finder)

常见角色包括:

BuiltinImporter
FrozenImporter
PathFinder

Finder 通常通过:

find_spec()

返回:

ModuleSpec

例如:

import importlib.util

spec = importlib.util.find_spec("json")

print(spec)
print(spec.origin)
print(spec.loader)

ModuleSpec 可以理解成模块的“装货单”:

模块叫什么?
从哪里来?
谁负责加载?
是不是 package?
子模块应该去哪里找?

Finder 本身通常不负责执行模块代码。它主要负责找到模块,并提供 ModuleSpec。(Python documentation)


3. Loader:真正创建和执行模块

Loader 负责:

创建 module
执行模块代码
填充 module.__dict__

现代 Loader 主要关注两个接口:

create_module(spec)
exec_module(module)

其中真正执行 Python 模块顶层代码的关键步骤是:

exec_module(module)

所以 import 可以粗略理解为:

spec = finder.find_spec(name)

module = importlib.util.module_from_spec(spec)

sys.modules[name] = module

spec.loader.exec_module(module)

官方 importlib 文档甚至给出了与此非常接近的程序化导入示例。(Python documentation)


七、怎样利用 importlib 实现插件动态加载?

假设我们正在开发一个数据处理平台。

希望用户通过配置:

PLUGINS = [
    "plugins.csv_plugin",
    "plugins.json_plugin",
]

动态加载插件。

项目结构:

project/
├── main.py
└── plugins/
    ├── __init__.py
    ├── csv_plugin.py
    └── json_plugin.py

每个插件约定实现:

def run(data):
    ...

例如:

# plugins/csv_plugin.py

def run(data):
    print("CSV plugin:", data)

然后:

import importlib


def load_plugin(module_name: str):
    module = importlib.import_module(module_name)

    if not hasattr(module, "run"):
        raise TypeError(
            f"{module_name} 没有实现 run()"
        )

    return module


plugins = [
    load_plugin("plugins.csv_plugin"),
    load_plugin("plugins.json_plugin"),
]

for plugin in plugins:
    plugin.run({"name": "Python"})

这样配置决定加载什么,而不是业务代码写死:

import plugins.csv_plugin
import plugins.json_plugin

更工程化:定义插件协议

可以配合 Protocol

from typing import Protocol, Any


class Plugin(Protocol):
    def run(self, data: Any) -> Any:
        ...

然后插件只需要满足协议。

真实工程中,建议继续增加:

插件名称
版本
能力声明
初始化
生命周期
异常隔离
依赖版本检查

例如:

class BasePlugin:

    name = "unknown"
    version = "1.0"

    def initialize(self):
        pass

    def run(self, data):
        raise NotImplementedError

    def shutdown(self):
        pass

八、更成熟的插件系统:Entry Points

如果插件来自不同的 pip 包,那么手动维护:

"plugins.foo"
"plugins.bar"

很快就会变得麻烦。

更成熟的方式是使用 Python 包生态中的 Entry Points

宿主程序:

from importlib.metadata import entry_points


def load_plugins():
    plugins = entry_points(group="myapp.plugins")

    for entry in plugins:
        plugin = entry.load()
        yield entry.name, plugin

第三方包安装后可以声明:

[project.entry-points."myapp.plugins"]
csv = "my_csv_plugin:CSVPlugin"
excel = "my_excel_plugin:ExcelPlugin"

然后:

pip install my-csv-plugin

宿主系统就能够发现它。

Python 当前 importlib.metadata API 支持按 group 查询 entry points,并通过:

entry.load()

加载对应对象。(Python documentation)

这类架构很适合:

CLI 扩展
工作流平台
数据处理系统
测试框架
IDE 插件
业务规则引擎
模型适配器

不过要记住:

动态加载 Python 插件,本质上就是执行 Python 代码。

不要从不可信配置中直接执行:

importlib.import_module(user_input)

生产环境应该建立插件白名单、包来源控制、版本约束和权限边界。


九、真正的生产问题:服务启动 8 秒,5 秒耗在 import,怎么诊断?

现在来到最有工程价值的部分。

假设:

Python Web Service
冷启动:8.0 秒

其中:
import:5.0 秒
其他初始化:3.0 秒

不要第一反应就是:

“Python import 太慢。”

import 本身通常并不是根因。

真正昂贵的往往是 import 触发的大量模块级代码。

例如:

# model.py

import torch

model = load_big_model()

或者:

# database.py

client = connect_database()

甚至:

# config.py

CONFIG = requests.get(
    "https://config-server/config"
).json()

这些操作全发生在:

import model
import database
import config

期间。

所以你看到的“import 5 秒”,很可能实际上是:

import
 ├── 文件读取
 ├── C 扩展加载
 ├── 网络访问
 ├── 数据库连接
 ├── 模型初始化
 ├── 配置解析
 └── 数千个级联 import

十、第一步:使用 -X importtime

CPython 自带一个非常有价值的分析功能:

python -X importtime -m my_service

如果只是想隔离某个入口模块:

python -X importtime -c "import my_service"

你会看到类似:

import time: self [us] | cumulative | imported package
import time:       200 |        200 |   foo
import time:       800 |       1000 | bar
import time:    850000 |     851000 | pandas

这里需要区分两个指标。

self

模块自身 import 所花的时间。

cumulative

包含其子依赖之后的累计时间。

假设:

A
└── B
    └── C

如果:

A cumulative = 2 秒
A self       = 10 毫秒

说明:

A 本身几乎不慢,是它拉进来的依赖慢。

这是 import 性能分析里最重要的判断之一。

Python 官方命令行文档确认,-X importtime 会输出每次 import 的自身耗时和累计耗时;在 Python 3.14 中还增加了 -X importtime=2,用于显示已经缓存的模块导入。(Python documentation)

也可以:

PYTHONPROFILEIMPORTTIME=1 python -m my_service

十一、第二步:把 import 耗时排序

把日志保存下来:

python -X importtime -m my_service 2> import.log

然后重点寻找:

self time 高
cumulative time 高

的模块。

例如最终得到:

模块selfcumulative
app.api20 ms4300 ms
pandas900 ms1500 ms
reports50 ms1800 ms
ml.pipeline1200 ms1300 ms
config700 ms720 ms

一眼就能发现:

app.api

不是根因。

真正值得检查的是:

pandas
ml.pipeline
config

特别是:

self time 很高

的内部模块。


十二、第三步:检查所有模块级副作用

看到慢模块之后,直接打开代码。

重点搜索:

client = ...
engine = ...
model = ...
df = ...
requests.get(...)
open(...)
load(...)
connect(...)
scan(...)

危险写法:

# service.py

MODEL = load_model("model.bin")

这意味着:

import service

就必须加载模型。

可以改成:

_model = None


def get_model():
    global _model

    if _model is None:
        _model = load_model("model.bin")

    return _model

这样:

import service

只负责定义函数。

真正需要模型时才加载:

model = get_model()

这就是典型的 lazy initialization。


十三、一个真实感很强的优化案例

假设服务有一个报表接口:

# reports.py

import pandas as pd


def build_report(data):
    df = pd.DataFrame(data)
    return df.describe()

而整个 Web 服务启动时:

from reports import build_report

即使 99% 的请求都不会访问报表接口,服务还是必须在启动阶段加载 Pandas。

如果 profiling 显示:

pandas import:1.2 秒

可以调整为:

def build_report(data):
    import pandas as pd

    df = pd.DataFrame(data)
    return df.describe()

冷启动可能立刻减少约 1 秒。

当然代价是:

第一次访问报表接口时,会承担 Pandas 的首次导入延迟。

所以真正的工程问题不是:

要不要 lazy import?

而是:

这 1 秒延迟应该发生在什么时候?

例如 Serverless 应用非常在意冷启动,那么延迟加载很有价值。

而一个长期运行、首个请求 SLA 很严格的 API 服务,则可能更适合:

进程启动
   ↓
核心模块初始化
   ↓
服务 Ready
   ↓
后台预热可选依赖

性能优化永远是在移动成本,而不是让成本凭空消失。


十四、__init__.py 可能是隐藏的性能杀手

很多项目喜欢在:

package/__init__.py

里写:

from .user import *
from .order import *
from .report import *
from .machine_learning import *

于是:

import package

本来只想得到一个 package,却瞬间加载:

User
Order
Pandas
NumPy
Torch
数据库 SDK
云平台 SDK
……

大型项目建议保持:

__init__.py

尽量薄。

尤其不要为了所谓“优雅 API”就把整个依赖树全部提前导入。


十五、5 秒 import,我会按这个顺序排查

遇到真实生产问题,我通常不会直接修改代码,而是按照下面的路径推进:

1. 精确测量启动总时间
          ↓
2. -X importtime 找慢模块
          ↓
3. 区分 self / cumulative
          ↓
4. 找出 import dependency tree
          ↓
5. 检查模块级副作用
          ↓
6. 找重型可选依赖
          ↓
7. 检查 __init__.py
          ↓
8. 延迟非关键 import / 初始化
          ↓
9. 重构依赖边界
          ↓
10. 再次 benchmark

同时注意区分:

冷文件系统
热文件系统

开发机器
Docker 容器

首次启动
重复启动

本地磁盘
网络挂载

Python 版本
依赖版本

否则你很可能优化了一个并不存在于生产环境的问题。


十六、几个非常实用的 import 最佳实践

1. 模块顶层尽量只做“声明”

理想:

DEFAULT_TIMEOUT = 10

class Client:
    ...

def create_client():
    ...

谨慎:

CLIENT = connect_remote_service()

危险:

DATA = requests.get(REMOTE_URL).json()

2. import 阶段不要访问网络

不要:

CONFIG = fetch_remote_config()

更合理的是:

def load_config():
    return fetch_remote_config()

然后在明确的启动生命周期中调用。


3. 保持依赖方向单向

优秀的工程结构更接近:

API
 ↓
Service
 ↓
Domain
 ↓
Infrastructure

而不是:

A → B → C
↑       ↓
└───────┘

大量 circular import 往往不是 Python 问题,而是架构边界正在发出警报。


4. 不要滥用 sys.path.append()

很多项目中出现:

import sys

sys.path.append("../../")

这通常是在用运行时路径修改掩盖包结构问题。

更好的办法是:

规范 package
使用 pyproject.toml
正确安装项目
使用绝对/相对导入

5. 不要用删除 sys.modules 实现热更新

这可能让同一个模块在进程里存在多个实例:

old module object
new module object

进一步造成:

类型判断异常
单例失效
全局状态分裂
旧对象继续引用旧类

生产热更新应该使用成熟的进程重启、worker reload 或部署机制。


十七、一个值得记住的 Import 心智模型

以后再看到:

import foo

不要只把它想成:

读取 foo.py

而应该想到:

              import foo
                   │
                   ▼
           sys.modules 有吗?
              /         \
            有           没有
            │             │
            ▼             ▼
        返回缓存       Finder 查找
                          │
                          ▼
                     ModuleSpec
                          │
                          ▼
                    创建 module
                          │
                          ▼
                 放入 sys.modules
                          │
                          ▼
                   Loader 执行代码
                          │
                          ▼
                     初始化完成

只要这个模型真正建立起来,很多曾经看起来很“玄学”的 Python 问题都会突然变得清晰:

为什么第二次 import 不执行?

为什么循环导入不是无限递归?

为什么会出现 partially initialized module?

为什么删除 sys.modules 很危险?

为什么 importlib 可以动态加载插件?

为什么 import 可能让服务启动变慢?

它们其实都是同一套机制在不同场景下的表现。


十八、最后:真正值得优化的往往不是 import,而是模块边界

Python 的 import system 表面上是一个语言基础设施,深入进去以后,却会一路碰到:

模块设计
依赖管理
架构分层
插件化
启动性能
可测试性
部署方式

这也是 Python 有意思的地方。

一个最普通的:

import foo

背后已经是一套完整的运行时模块系统。

如果你的服务出现:

启动 8 秒
其中 import 5 秒

不要急着寻找所谓的“加速 import 黑科技”。

先问三个问题:

  1. 到底是哪几个模块慢?
  2. 慢的是模块搜索,还是模块顶层代码执行?
  3. 这些工作真的必须发生在 import 阶段吗?

很多时候,仅仅把:

model = load_model()
database = connect()
config = fetch_config()

从模块顶层移到明确的应用生命周期中,服务架构就会同时获得:

更快启动
更容易测试
更少副作用
更清晰依赖
更高可维护性

性能问题最终常常会把我们带回软件工程最基本的一句话:

让模块负责定义能力,让应用生命周期负责启动能力。

这比单纯追求“少 import 几个包”重要得多。


参考资料

Python 官方《导入系统》详细解释了 sys.modules、Finder、Loader、ModuleSpec 以及模块加载过程。Python 官方:The import system

程序化导入、自定义 Finder / Loader、find_spec() 与动态加载相关接口可参考 importlib 官方文档。Python 官方:importlib

插件生态中的 Entry Points 可参考 importlib.metadataPython 官方:importlib.metadata

启动耗时分析中的 -X importtimePYTHONPROFILEIMPORTTIME 可参考 Python 命令行官方文档。Python 官方:命令行与环境变量


留给读者的两个问题

你是否遇到过这样一种循环导入:明明把某个 import 移进函数里就“修好了”,但半年之后项目里出现了更多循环依赖?如果重新设计,你会怎样划分模块边界?

以及,如果一个 Python 服务的启动时间从:

2 秒 → 5 秒 → 10 秒

一路增长,你认为应该从什么时候开始把 import time 正式纳入 CI 性能指标?

欢迎把你的案例、踩坑经历和优化方案分享出来。理解 Python 的最好方式,从来不只是记住语法,而是不断追问:

“这一行代码,在解释器内部究竟发生了什么?”

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

铭渊老黄

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值