PEP 806 – 具有精确异步标记的同步/异步混合上下文管理器
- 作者:
- Zac Hatfield-Dodds <zac at zhd.dev>
- 发起人:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- 讨论至:
- Discourse 帖子
- 状态:
- 草案
- 类型:
- 标准跟踪
- 创建日期:
- 2025年9月5日
- Python 版本:
- 3.15
- 发布历史:
- 2025年5月22日, 2025年9月25日
摘要
Python 允许 with
和 async with
语句在单个语句中处理多个上下文管理器,只要它们分别是同步或异步的。当同步和异步上下文管理器混合时,开发者必须使用深度嵌套的语句或使用有风险的变通方案,例如过度使用 AsyncExitStack
。
因此,我们建议允许 with
语句在单个语句中同时接受同步和异步上下文管理器,方法是使用 async
关键字来标记单独的异步上下文管理器。
此更改消除了不必要的嵌套,提高了代码可读性,并在不降低异步代码显式性的情况下改善了人体工程学。
动机
现代 Python 应用程序经常需要通过同步和异步上下文管理器的混合来获取多个资源。虽然全同步或全异步情况下允许单个语句包含多个上下文管理器,但两者的混合会导致“末日阶梯”
async def process_data():
async with acquire_lock() as lock:
with temp_directory() as tmpdir:
async with connect_to_db(cache=tmpdir) as db:
with open('config.json', encoding='utf-8') as f:
# We're now 16 spaces deep before any actual logic
config = json.load(f)
await db.execute(config['query'])
# ... more processing
这种过度的缩进阻碍了上下文管理器的使用,尽管它们具有理想的语义。请参阅“被拒绝的想法”部分,了解当前的变通方案及其缺点。
有了这个 PEP,函数可以这样写
async def process_data():
with (
async acquire_lock() as lock,
temp_directory() as tmpdir,
async connect_to_db(cache=tmpdir) as db,
open('config.json', encoding='utf-8') as f,
):
config = json.load(f)
await db.execute(config['query'])
# ... more processing
这种紧凑的替代方案避免了在同步和异步上下文管理器之间每次切换时强制进行新的缩进级别。同时,它只使用现有的关键字,甚至比我们当前的语法更精确地使用 async
关键字来区分异步代码。
我们不建议废弃 async with
语句,并且确实主张它继续用于单行语句,以便“async”是每个打开异步上下文管理器的行的第一个非空白标记。
尽管如此,我们的提案允许 with async some_ctx()
,重视一致的语法设计,而不是强制执行单一的代码风格,我们预计这将由风格指南、linter、格式化程序等处理。有关进一步讨论,请参见此处。
实际影响
这些增强功能解决了 Python 开发者日常遇到的痛点。我们调查了一个行业代码库,发现有超过一万个函数至少包含一个异步上下文管理器。其中 19% 的函数也包含同步上下文管理器。作为参考,异步函数包含同步上下文管理器的频率大约是包含异步上下文管理器的三分之二。
39% 的同时包含 with
和 async with
语句的函数可以立即切换到建议的语法,但这只是一个宽松的下限,因为避免了同步上下文管理器并使用了“被拒绝的想法”中列出的变通方案。根据检查随机函数样本,我们估计,如果此 PEP 被接受,包含任何上下文管理器的异步函数中,有 20% 到 50% 将使用 with async
。
在更广泛的生态系统中,我们预计使用率会更低,可能在 5% 到 20% 的范围:被调查的代码库使用 Trio 进行结构化并发,并且还广泛使用上下文管理器来缓解 PEP 533 和 PEP 789 中讨论的问题。
基本原理
混合同步/异步上下文管理器在现代 Python 应用程序中很常见,例如异步数据库连接或 API 客户端以及同步文件操作。当前的语法强制开发者在深度嵌套的代码或容易出错的变通方案(如 AsyncExitStack
)之间做出选择。
这个 PEP 通过基于现有模式的最小语法更改来解决这个问题。通过允许使用 async
标记单个上下文管理器,我们保持了 Python 对异步代码的显式方法,同时消除了不必要的嵌套。
作为语法糖的实现确保了零运行时开销——新语法解糖为开发者今天编写的相同嵌套 with
和 async with
语句。这种方法不需要新的协议,不需要更改现有上下文管理器,也不需要理解新的运行时行为。
规范
with (..., async ...):
语法解糖为一系列上下文管理器,与当前的多上下文 with
语句相同,只是带有 async
关键字前缀的那些使用 __aenter__
/ __aexit__
协议。
仅修改 with
语句;async with async ctx():
是语法错误。
ast.withitem
节点获得一个新的 is_async
整型属性,遵循 ast.comprehension
上的现有 is_async
属性。对于 async with
语句项,此属性始终为 1
。对于常规 with
语句中的项,当 async
关键字存在时,此属性为 1
,否则为 0
。这使得 AST 能够精确地表示哪些上下文管理器应该使用异步协议,同时保持与现有 AST 处理工具的向后兼容性。
向后兼容性
此更改完全向后兼容:唯一可观察到的区别是以前引发 SyntaxError
的某些语法现在可以成功执行。
实现上下文管理器的库(标准库和第三方库)无需修改即可与新语法配合使用。直接处理源代码的库和工具需要进行少量更新,就像任何新语法一样。
如何教授此内容
我们建议在 async with
之后立即或同时引入“混合上下文管理器”。例如,教程可能涵盖:
- 基本上下文管理器:从单个
with
语句开始 - 多个上下文管理器:显示当前的逗号语法
- 异步上下文管理器:引入
async with
- 混合上下文:“用
async
标记每个异步上下文管理器”
被拒绝的想法
变通方案:as_acm()
包装器
很容易实现一个辅助函数,它将同步上下文管理器包装在异步上下文管理器中。例如
@contextmanager
async def as_acm(sync_cm):
with sync_cm as result:
await sleep(0)
yield result
async with (
acquire_lock(),
as_acm(open('file')) as f,
):
...
这是我们为几乎所有代码推荐的变通方案。
然而,在某些情况下,回调到异步运行时(即执行 await sleep(0)
)以允许取消是不可取的。另一方面,省略 await sleep(0)
将破坏传递性属性,即语法上的 await
/ async for
/ async with
总是回调到异步运行时(或引发异常)。虽然今天很少有代码库强制执行此属性,但我们发现它对于防止死锁是不可或缺的,因此更喜欢为生态系统提供更清晰的基础。
变通方案:使用 AsyncExitStack
AsyncExitStack
提供了一个功能强大、低级别的接口,允许显式进入同步和/或异步上下文管理器。
async with contextlib.AsyncExitStack() as stack:
await stack.enter_async_context(acquire_lock())
f = stack.enter_context(open('file', encoding='utf-8'))
...
然而,AsyncExitStack
引入了显著的复杂性和潜在的错误——很容易违反上下文管理器语法使用所保证的属性,例如“后进先出”的顺序。
变通方案:基于 AsyncExitStack
的辅助函数
我们还可以实现一个 multicontext()
包装器,它避免了直接使用 AsyncExitStack
的一些缺点
async with multicontext(
acquire_lock(),
open('file'),
) as (f, _):
...
然而,这个辅助函数破坏了 as
子句的局部性,这使得意外地错误分配 yielded 变量变得容易(如代码示例所示)。它还需要通过类似标签联合的方式(例如重载运算符,使 async_ @ acquire_lock()
起作用)来区分同步和异步上下文管理器,或者猜测如何处理同时实现同步和异步上下文管理器协议的对象。最后,它在异常处理方面存在容易出错的语义,这导致 contextlib.nested() 被弃用,取而代之的是多参数 with
语句。
语法:允许 async with sync_cm, async_cm:
本提案的早期草案在混合上下文管理器时,当至少有一个异步上下文管理器时,对整个语句使用 async with
# Rejected approach
async with (
acquire_lock(),
open('config.json') as f, # actually sync, surprise!
):
...
要求异步上下文管理器可以保持语法/调度器链接,但代价是对未来的代码更改设置了隐式约束。如果某个上下文管理器恰好是最后一个异步上下文管理器,那么删除其中一个上下文管理器可能会导致运行时错误!
显式优于隐式。
语法:禁止单行 with async ...
我们提议的语法可以受到限制,例如,仅将 async
放置在带括号的多上下文 with
语句中行的第一个标记。这确实是我们建议的使用方式,我们预计大多数使用会遵循此模式。
虽然可以选择编写 async with ctx():
或 with async ctx():
可能会因歧义而造成一些小困惑,但我们认为通过语法强制执行首选样式会使 Python 更难学习,因此我们更喜欢简单的语法规则加上社区使用约定。
举例来说,我们认为以下代码示例中,在哪个点(如果有)应该禁止使用该语法并不明显
with (
sync_context() as foo,
async a_context() as bar,
): ...
with (
sync_context() as foo,
async a_context()
): ...
with (
# sync_context() as foo,
async a_context()
): ...
with (async a_context()): ...
with async a_context(): ...
致谢
感谢 Rob Rolls 提出 with async
。还要感谢在 PyCon 2025 sprints、Discourse 和工作中与我们讨论这个问题和可能解决方案的许多其他人。
版权
本文档置于公共领域或 CC0-1.0-Universal 许可证下,以更宽松者为准。
来源:https://github.com/python/peps/blob/main/peps/pep-0806.rst