Chapter 71: importlib asyncio and contextlib
本章讨论三个标准库模块如何把前面章节中的对象模型、协议分发、frame 状态和资源生命周期组织成可复用的工程抽象。读完本章后,读者应能追踪一个动态插件从模块名进入 importlib,再进入 asyncio 调度,最后由 contextlib 收束资源的完整路径,并能判断一个标准库抽象依赖的是哪一层 runtime 机制。
贯穿本章的材料是一段短程序:运行时收到插件名,动态导入插件模块,创建异步任务执行插件函数,并用异步 context manager 管理资源。这个程序覆盖了三类标准库设计:importlib 把字符串模块名转成 module object,asyncio 把 coroutine object 放入 event loop 推进,contextlib 把进入、退出和异常清理折叠成统一协议。
# app.py
import asyncio
import importlib
from contextlib import AsyncExitStack, asynccontextmanager
@asynccontextmanager
async def open_session(plugin_name):
session = {"plugin": plugin_name, "closed": False}
try:
yield session
finally:
session["closed"] = True
async def run_plugin(module_name):
module = importlib.import_module(module_name)
async with AsyncExitStack() as stack:
session = await stack.enter_async_context(open_session(module_name))
task = asyncio.create_task(module.fetch(session))
return await task
asyncio.run(run_plugin("plugins.weather"))
# plugins/weather.py
import asyncio
async def fetch(session):
await asyncio.sleep(0)
return {"source": session["plugin"], "status": "ok"}
这段代码表面上只调用三个标准库入口,runtime 内部发生的是三条状态链:模块链把 "plugins.weather" 解析为 plugins package 下的 weather module;异步链把 fetch(session) 创建出的 coroutine 包装进 Task 并在 event loop 中恢复执行;资源链把 open_session 的进入值注册到 AsyncExitStack,在返回、异常或取消路径上执行 cleanup。标准库的组织方式由这些状态链决定。
本章的版本边界以 Python 3.14 文档和 CPython 运行模型为准。importlib、asyncio、contextlib 都是标准库接口,语义入口面向 Python 语言;具体调度细节、性能优化和 C 层结构在不同 Python 版本与实现中会变化。正文中的源码方向只作为阅读定位,例如 Lib/importlib/、Lib/asyncio/、Lib/contextlib.py,不声称已经验证读者本地源码行号。
71.1 import system
import system 要解决的问题是:当代码给出一个模块名时,runtime 如何定位可加载对象、创建 module object、执行模块代码,并保证同一个模块名在一次解释器进程中有稳定的缓存语义。importlib 是这个系统的 Python 层实现与公开抽象,它把 import statement、__import__()、finder、loader、ModuleSpec 和 sys.modules 组织到同一条路径中。Python 3.14 的 importlib 文档把它定位为 import 的实现、导入组件的公开入口,以及包资源和包元数据相关能力的容器。
在贯穿示例中,importlib.import_module("plugins.weather") 的输入是字符串,输出是 module object。这个转换经过三类对象。第一类是 finder,它回答“这个名字能否被找到,以及应由谁加载”。第二类是 ModuleSpec,它记录模块名、loader、origin、package path 等加载计划。第三类是 loader,它负责创建模块或执行模块代码。import_module() 是面向业务代码的简化入口,它返回指定模块本身;底层 __import__() 在某些调用形态下返回顶层 package,这也是动态导入时优先使用 import_module() 的原因。
导入路径可以按下面的状态图理解。图只描述常见 Python 源文件模块路径,不覆盖 frozen module、built-in module、extension module 和自定义 importer 的所有差异。
sys.modules 是导入系统的模块缓存,也是循环导入能够推进的关键边界。loader 在执行模块代码前通常要把 module object 放入 sys.modules,这样同一个导入链中再次请求该名字时可以拿到同一个模块对象。这个提前缓存让循环引用有机会完成,也带来一个工程后果:模块执行失败时,导入系统要处理缓存中的半初始化模块。排查“导入成功但属性缺失”时,应先确认异常是否发生在模块顶层执行期间,再确认调用方是否观察到了半初始化状态。
ModuleSpec 是导入计划的中心对象。它把“在哪里找到模块”和“如何执行模块”从 module object 上的运行结果中分离出来。对于 plugins.weather,父包 plugins 的 __path__ 决定子模块查找范围,finder 根据这个 path 产生 spec,spec 指向具体 loader。这个结构解释了 package path 的作用:顶层模块查找依赖 sys.meta_path 和 sys.path,子模块查找还依赖父包的 __path__。namespace package、自定义 path hook 和 zip importer 都可以通过这一层接入。
模块执行发生在 module namespace 中。loader 执行 plugins/weather.py 时,模块顶层代码会建立 module dictionary,async def fetch(session) 会创建 function object 并绑定到 weather 模块的 fetch 名字。这里没有调用 fetch,也没有创建 coroutine object;导入只执行函数定义语句,把后续可调用对象放进模块 namespace。把导入阶段和调用阶段分开,是读标准库和业务框架插件系统时的基本判断。
动态导入还要处理缓存失效。程序运行中新增了 Python 文件、安装了插件包、修改了 package path 时,已有 finder 可能持有目录扫描结果。importlib.invalidate_caches() 会通知 sys.meta_path 上支持该方法的 finder 清理内部缓存。它解决的是“新的模块文件已经存在,finder 还沿用旧发现结果”的问题;它不会自动卸载已经在 sys.modules 中的模块,也不会替调用方更新已经保存的旧对象引用。
reload() 展示了导入系统的对象身份边界。importlib.reload(module) 会重新编译并执行模块代码,模块 dictionary 会被复用,新绑定会覆盖旧绑定,未被新代码定义的旧名字仍可能留下。外部通过 from plugins.weather import fetch 保存的函数引用不会随模块 reload 自动改指向。工程上排查热更新、插件 reload、测试隔离时,应区分四个对象:模块对象、模块 dictionary、模块中的新函数对象、外部 namespace 中旧引用。
错误边界也应按导入阶段拆开。名字找不到通常落在 finder 和 spec 生成阶段;源码语法错误发生在 loader 编译阶段;顶层执行异常发生在 exec_module() 阶段;循环导入导致的属性缺失发生在模块对象已缓存但执行尚未完成的阶段。把所有导入失败都归为“路径错误”会错过真正的状态点。稳定检查顺序是:先看 sys.modules 是否已有目标名,再看父包和 __path__,再看 finder 是否返回 spec,再看 loader 执行模块顶层代码时是否抛出异常,最后看调用方是否保存了旧引用。
对于标准库设计,importlib 展示了一种常见模式:语法表面保持简单,内部能力通过 protocol object 暴露。import x 对用户是语法;对实现是 finder/loader 协议、module cache、spec object 和 namespace 执行。自定义 importer、资源读取、插件扫描都复用这一套抽象。读到某个标准库模块支持“自定义后端”时,可以先寻找它是否定义了 finder、loader、adapter、hook 或 protocol method。
71.2 async runtime
async runtime 要解决的问题是:单线程中存在多个等待 I/O 或定时器的任务时,runtime 如何记录每个任务的暂停点、恢复条件、返回值、异常和取消状态。asyncio 是标准库中的异步 I/O 框架,它把 event loop、coroutine、Task、Future、callback、transport/protocol 和 cancellation 组合成一套调度模型。Python 3.14 的 asyncio 文档把它划分为高层 API 与低层 API;阅读 runtime 关系时,应先用高层对象建立状态链,再回到低层对象解释事件来源。
贯穿示例中,module.fetch(session) 调用 async def 函数后返回 coroutine object。这个对象保存代码、局部状态和下一次恢复的位置;它本身还没有进入 event loop。asyncio.create_task(...) 把 coroutine 包装成 Task,Task 是 Future 的子类语义对象,负责在 coroutine 完成时保存结果或异常,并让等待它的调用方继续执行。await task 表达的是当前 coroutine 把控制权交回 event loop,并登记自己依赖该 Task 的完成状态。
这条路径可以写成一个简化时序图。图中的 I/O readiness 代表事件源;在示例里 asyncio.sleep(0) 只是让出一次调度机会。
event loop 的责任是选择可推进的 callback 或 Task step,并在合适时机恢复 coroutine。它不改变 Python 函数调用的基本语义;它把“现在无法继续”的 coroutine 暂存起来,等 Future 完成、定时器到期、文件描述符可读写或 callback 被安排时再恢复。这里的动态性落在 Task 和 Future 的状态上:pending、done、cancelled、exception、result 都是 event loop 决定下一步的证据。
Future 是异步结果槽。普通函数直接返回结果,异步调用经常先返回一个代表未来结果的对象。Task 是绑定 coroutine 的 Future;低层 I/O、线程池回调、transport/protocol 也可能通过 Future 把外部事件接回 coroutine 世界。读 asyncio 代码时,看到 await something 应先判断 something 最终如何变成 awaitable:它可能是 coroutine object、Task、Future,或实现 __await__ 的对象。判断完成后,再看谁负责把它标记为 done。
cancellation 是异步 runtime 的控制流边界。调用 task.cancel() 会请求向 coroutine 注入 CancelledError,下一次恢复时 coroutine 会在等待点附近收到这个异常。取消不是立即销毁 frame;它沿 coroutine 的异常路径推进,finally、async with 的 __aexit__ 和 AsyncExitStack 注册的清理动作仍会运行。工程上处理 cancellation 时,应把它视为一种正常控制流状态:业务代码可以在清理后继续传播,也可以在明确边界内转换为其它结果。
backpressure 是异步标准库必须表达的资源压力。它指生产速度超过消费或传输能力时,调用方要在某个 await 点停下来,让下游恢复容量。asyncio 的 stream API 中,写入侧通常通过 drain() 表达等待缓冲区降低压力;transport/protocol 低层 API 中,pause/resume 相关回调表达生产者和消费者之间的速率协调。backpressure 的判断点是“哪个对象保存缓冲状态,哪个 await 或 callback 让上游暂停”。没有这个判断,异步代码容易把内存峰值、连接拥塞和任务堆积误判成 event loop 性能问题。
transport/protocol 展示了 asyncio 的低层适配模式。transport 表示连接、缓冲和写入关闭等传输能力;protocol 表示连接建立、数据到达、连接丢失等回调接口。高层 stream API 把这组对象包装成更接近文件读写的接口。标准库在这里使用 adapter 分层:底层用 callback 响应事件,高层用 coroutine 和 await 组织流程。读源码时,如果看到 stream、server、subprocess、socket 之间来回转换,应先确认当前层级是在处理事件源、缓冲状态还是 coroutine 语义。
异常传播要沿 Task 状态观察。fetch() 内部抛出的异常会存入 Task;await task 的位置重新抛出该异常。未被等待的 Task 如果异常完成,event loop 可能在日志中报告“Task exception was never retrieved”一类问题。这个现象不是异常丢失,而是异常结果保存在 Task 中,调用方没有读取。排查异步异常时,稳定顺序是:定位创建 Task 的位置,定位谁 await 或收集它,确认取消和超时是否改变异常类型,最后确认 cleanup 是否在异常路径上完成。
asyncio.run() 是高层入口,负责创建 event loop、运行主 coroutine、关闭异步生成器和默认 executor 等收尾动作。它适合程序顶层入口;库代码通常暴露 coroutine,让调用方在自己的 loop 中调度。这个边界来自所有权:顶层程序拥有 event loop 生命周期,库函数拥有局部 awaitable 的语义。把 loop 生命周期交给库代码会破坏调用方对并发环境的控制,尤其是 GUI、服务端框架、测试框架和嵌入式运行环境。
对于贯穿示例,完整异步判断顺序是:fetch(session) 创建 coroutine object;create_task 把 coroutine 交给当前 running loop;await task 让 run_plugin 暂停;event loop 恢复 Task;fetch 在 sleep(0) 处让出控制权;Task 完成后把结果唤醒回 run_plugin。如果执行中发生取消,取消异常会进入 fetch 或 run_plugin 的等待点,并继续触发 AsyncExitStack 的退出路径。
71.3 context manager abstraction
context manager abstraction 要解决的问题是:资源获取成功后,退出动作应在正常返回、异常、取消和多资源组合中保持确定顺序。contextlib 把这类模式转成可组合对象:@contextmanager 和 @asynccontextmanager 用 generator 或 async generator 表达一次进入和一次退出;ExitStack 与 AsyncExitStack 在运行时动态注册多个 cleanup,并按后进先出顺序执行。Python 3.14 的 contextlib 文档集中展示了这些工具如何围绕 with、async with 和退出回调工作。
贯穿示例中的 open_session(plugin_name) 是一个 async generator-based context manager。调用 open_session(...) 得到的对象支持异步 context manager 协议;yield session 前的代码对应进入阶段,yield 交出的值绑定给调用方,finally 中的代码对应退出阶段。它把资源生命周期压缩到一个函数形态中,同时保留 async with 所需的 __aenter__ 和 __aexit__ 行为。
生成器式 context manager 的关键限制是一进一出。下面的同步版本更容易观察这个边界。yield 前后并非普通业务分支,而是 context manager 的两个阶段。
from contextlib import contextmanager
@contextmanager
def open_marker(name):
state = {"name": name, "closed": False}
try:
yield state
finally:
state["closed"] = True
with open_marker("demo") as marker:
marker["value"] = 1
这段代码中,open_marker("demo") 返回 context manager object,with 进入时运行到 yield state,body 执行完后恢复 generator,并执行 finally。如果 body 抛出异常,异常会被送回 generator 的 yield 点,finally 仍然执行;如果 context manager 的退出逻辑选择抑制异常,它要通过协议返回值表达。contextlib 的工具把这些协议细节包装起来,让资源所有者把清理写在同一个函数中。
AsyncExitStack 处理的是资源数量和类型在运行时才确定的情况。贯穿示例里只有一个 session,但真实插件系统可能按配置打开多个连接、临时目录、锁、订阅和超时控制。把多个 async with 静态嵌套会让代码结构受资源数量控制。AsyncExitStack 让代码在循环、条件分支和工厂函数中动态注册退出动作,最后统一按后进先出的顺序收束。
async def run_many(module_names):
async with AsyncExitStack() as stack:
sessions = []
for module_name in module_names:
session = await stack.enter_async_context(open_session(module_name))
sessions.append(session)
return [session["plugin"] for session in sessions]
这个例子说明 AsyncExitStack 保存的是退出责任。每次 enter_async_context 成功后,栈里都会多一个退出回调。后续某个资源进入失败时,已经进入成功的资源仍由栈清理。退出顺序使用后进先出,是因为后进入的资源经常依赖先进入的资源;先释放内层资源可以减少悬挂引用和半关闭状态。这个顺序与嵌套 with 的退出顺序一致。
context manager 的异常语义需要和 asyncio cancellation 结合看。取消会以异常形式进入 coroutine,async with 的退出协议仍会接收异常信息。AsyncExitStack 会按注册顺序调用异步退出动作,每个退出动作都可以 await。清理本身如果发生异常,会改变调用方观察到的最终异常;因此资源清理代码应尽量把必要状态写入明确位置,把可恢复错误转换成可诊断信息,并让真正的关闭失败保留异常链。
contextlib 还提供 closing、aclosing、suppress、nullcontext 等小型 adapter。它们的共同点是把不完全符合某个协议的对象包装成协议对象,或把条件分支包装成统一资源路径。例如某个对象只有 close() 方法,没有 __enter__ 和 __exit__,closing(obj) 可以把它接入 with;某个函数在有资源时进入 context manager,在没有资源时使用空操作,nullcontext 可以让调用方保持同一条控制流。
从 runtime 角度看,context manager 抽象的核心是“所有权注册”。资源创建点负责把退出动作交给 with、ExitStack 或 AsyncExitStack;调用方 body 只使用进入阶段交出的对象;退出阶段统一接收异常状态并执行清理。排查资源泄漏时,检查顺序是:先找资源获取点,再找退出责任是否注册成功,再看异常、返回、取消路径是否经过退出协议,最后看清理动作是否幂等或具备重复调用保护。
贯穿示例中,open_session 的资源状态完全由 context manager 控制。run_plugin 不直接设置 closed,也不把关闭动作散落在多个 return 分支中。AsyncExitStack 让 run_plugin 在创建 Task、等待结果、处理异常和取消时共享同一条 cleanup 路径。标准库在这里给出的工程结论是:当一段代码需要把正常路径和异常路径统一起来,应优先寻找 context manager 协议或 ExitStack 形态,把分支里的手写清理集中成单一退出责任。
71.4 stdlib design pattern
标准库常见设计模式可以从一个问题出发理解:如何让用户代码看到稳定、简洁的接口,同时让 runtime 内部保留可扩展的对象路径。importlib、asyncio、contextlib 都采用了这个方向。它们没有把所有细节暴露为全局函数参数,而是把变化点放在 protocol、duck typing、context manager、iterator、adapter、cache 和 wrapper 中。
protocol 是标准库表达能力边界的常用方式。importlib 中 finder 的 find_spec 和 loader 的 exec_module 让导入系统接受不同来源的模块;asyncio 中 awaitable、Future、Task、transport/protocol 让异步系统接入不同事件来源;contextlib 中 __enter__、__exit__、__aenter__、__aexit__ 让资源系统接入不同生命周期。protocol 的作用是把“对象长什么样”改成“对象能完成哪些操作”。
duck typing 是 protocol 的使用方式。标准库经常检查对象是否提供某个方法、是否可 await、是否可迭代、是否可作为 context manager,继承关系只在需要明确类型层级时参与判断。这样设计能降低接入成本,同时把错误推迟到能力使用点。工程判断时,应寻找实际调用的方法和异常位置:对象缺少 find_spec、__await__ 或 __aexit__ 时,错误会在标准库尝试使用能力时出现。
context manager 是标准库表达资源边界的稳定形态。文件、锁、临时目录、decimal context、数据库事务、网络连接和异步 session 都可以用进入/退出协议描述。它把资源所有权从业务流程中独立出来:业务代码在 body 中使用资源,退出协议处理释放和异常。contextlib 的价值在于把“已有对象接入协议”和“多个资源动态组合”都变成标准问题。
iterator 和 async iterator 是标准库表达流式数据的常用形态。导入系统扫描 path entry、asyncio stream 读取数据、文件对象逐行读取,都体现了“每次给出一个结果,耗尽时发出结束信号”的模型。同步迭代用 StopIteration 结束,异步迭代用异步协议表达等待和结束。读源码时,看到循环驱动的标准库代码,应先定位谁产生下一项、谁保存当前位置、谁负责结束信号。
adapter 用来连接两套接口。contextlib.closing 把只有 close() 的对象接入 with;asyncio stream API 把 transport/protocol 包装成 coroutine 友好的 reader/writer;importlib.import_module() 把底层 __import__() 语义包装成更直接的返回值。adapter 的判断点是输入接口和输出接口各自是什么,中间是否保存状态、转换异常或改变所有权。
cache 用来维持对象身份、降低重复查找和表达运行期发现结果。sys.modules 缓存模块对象,finder 可能缓存目录扫描结果,event loop 的 ready queue 和 scheduled callbacks 保存待执行工作,标准库中许多 wrapper 会缓存转换结果。cache 的工程边界是失效策略:模块缓存依赖模块名,finder 缓存依赖 path 状态,任务队列依赖事件循环时间和 I/O readiness。排查“状态已经变了但程序仍使用旧结果”时,应先确认是哪一层 cache 保存了旧对象。
wrapper 用来把底层对象变成语义更明确的对象。Task 包装 coroutine 并增加调度状态;contextmanager decorator 包装 generator 并增加 __enter__ / __exit__;import loader 包装文件、字节码、extension module 或其它来源并提供统一执行入口。wrapper 的效果是把散落的步骤集中到对象方法中,使调用方按协议使用,而内部保留具体实现差异。
这些模式可以归纳为一套阅读顺序。遇到一个标准库模块时,先找用户可见入口,例如 import_module、asyncio.run、create_task、asynccontextmanager。再找入口返回或创建的 runtime object,例如 module、coroutine、Task、context manager object。然后找该对象依赖的 protocol method,例如 find_spec、exec_module、__await__、__aexit__。接着找状态保存位置,例如 sys.modules、Future result、Task exception、ExitStack callback list。最后看失败路径如何传播异常、取消或清理状态。
把这套顺序应用到贯穿示例,可以得到完整判断。run_plugin("plugins.weather") 先通过 importlib 把模块名转成 module object,模块顶层执行只建立 fetch 绑定;再调用 fetch(session) 创建 coroutine object,create_task 把它纳入 event loop;然后 AsyncExitStack 保存 session 的退出责任;最后 await task 读取 Task 的结果或异常,并在离开 async with 时触发清理。三个标准库模块各自管理一段状态,同时通过普通对象引用连接起来。
本章最终建立的理解是:标准库不是一组孤立工具函数,而是把 Python runtime 中已有的对象、协议、namespace、frame 暂停点和资源退出协议组合成稳定 API。导入问题先找模块缓存和 loader 执行边界;异步问题先找 Task/Future 状态和 event loop 恢复条件;资源问题先找退出责任注册和异常路径。掌握这三个判断点,就能把更多标准库模块放回同一套 runtime 关系中阅读。
最小自检任务
阅读下面代码,判断三个问题:plugins.weather 模块代码在什么时候执行,fetch(session) 的返回值在什么时候真正推进,session["closed"] 在正常返回和取消路径上由谁设置。
async def main():
result_task = asyncio.create_task(run_plugin("plugins.weather"))
return await result_task
答案要点
plugins.weather 的模块顶层代码在 run_plugin 内部执行 importlib.import_module("plugins.weather") 时由 import system 定位、创建模块对象并执行。执行模块顶层代码会绑定 fetch 函数对象;此时还没有运行 fetch 函数体。
module.fetch(session) 调用 async def 函数后返回 coroutine object,asyncio.create_task(...) 把这个 coroutine 包装成 Task 并交给当前 event loop。函数体的推进发生在 event loop 调度 Task step 时;await result_task 让 main 暂停并等待 Task 的结果或异常。
session["closed"] 由 open_session 的 finally 设置。这个退出动作通过 AsyncExitStack.enter_async_context(...) 注册到异步退出栈。正常返回时,离开 async with AsyncExitStack() 会执行 cleanup;取消路径上,取消以异常形式进入等待点,async with 的退出协议仍会触发 AsyncExitStack 已注册的清理动作。判断边界是:资源获取成功后退出责任是否已经注册;如果进入资源前就失败,对应资源没有退出责任。
本章知识点总结
- 导入入口:
importlib.import_module()把字符串模块名转成 module object,并复用底层 import 语义。 - 模块缓存:
sys.modules保存模块名到 module object 的映射,影响循环导入、重复导入和 reload 观察结果。 - 导入计划:
ModuleSpec记录模块名、loader、origin 和 package path,是 finder 与 loader 的交接对象。 - 执行边界:导入模块会执行顶层代码并建立 namespace 绑定,函数体要等到后续调用才执行。
- 缓存失效:
invalidate_caches()通知 finder 清理发现结果,它不替调用方更新已保存对象引用。 - 协程对象:调用
async def函数会创建 coroutine object,进入 event loop 后才按调度恢复执行。 - Task 状态:Task 包装 coroutine,保存结果、异常或取消状态,并唤醒等待它的 coroutine。
- 事件循环:event loop 根据 callback、Future 完成、定时器和 I/O readiness 决定哪个任务可以继续推进。
- 取消路径:cancellation 通过异常进入 coroutine,清理逻辑仍沿
finally、async with和退出栈执行。 - 背压判断:backpressure 要定位缓冲状态和让上游暂停的 await 或 callback。
- 资源注册:context manager 把资源获取与退出责任绑定,
ExitStack和AsyncExitStack支持运行时动态组合。 - 退出顺序:退出栈按后进先出清理资源,保持与嵌套
with相同的生命周期顺序。 - 适配模式:adapter 把已有对象接入目标协议,wrapper 把底层对象包装成语义更明确的对象。
- 阅读顺序:读标准库抽象时,先找用户入口,再找 runtime object、protocol method、状态保存位置和失败路径。