Python 3.15.0b1 特性冻结意味着今年的大版本轮廓已经敲定。社区讨论的焦点集中在延迟导入(lazy imports)和 Tachyon 性能分析器上——确实重磅,但与此同时,一批更"安静"的改动也悄悄进了标准库。它们不会改变你写 Python 的根本方式,却能在日常编码中省掉不少麻烦。下面挑几个值得留意的,逐个展开。
Asyncio TaskGroup 终于有了体面的取消机制
结构化并发是 Python 异步编程这几年的核心方向,TaskGroup 在 3.11 引入后已经成了替代 gather 的首选。但有一个痛点一直没解决:当 TaskGroup 内某个任务抛异常,整个组会被取消,可取消行为是"硬中断"——任务里的 finally 块虽然会执行,但你很难区分"正常结束"和"被组取消"两种情况,清理逻辑容易写错。
3.15 给 TaskGroup 加了更优雅的取消语义:取消时抛出的是专门的 CancelledError 子类,任务可以在 except 里捕获后做差异化清理,而不是一刀切地 try/finally。
看一个实际场景——并发请求多个下游服务,其中一个失败后需要把其余请求的结果缓存到回收站,而不是直接丢弃:
import asyncio
async def fetch_service(name: str, delay: float) -> str:
"""模拟调用下游服务,随机延迟后返回结果"""
await asyncio.sleep(delay)
if name == "payment":
raise RuntimeError("payment service down")
return f"{name}: ok"
async def main():
results_bin = [] # 正常结果收集
salvage_bin = [] # 被取消任务的抢救区
try:
async with asyncio.TaskGroup() as tg:
for svc, d in [("inventory", 0.3), ("payment", 0.5), ("shipping", 0.4)]:
task = tg.create_task(fetch_service(svc, d))
# 为每个任务注册回调:被取消时抢救部分信息
def on_done(t: asyncio.Task):
if t.cancelled():
salvage_bin.append(f"{svc}: cancelled, will retry later")
elif exc := t.exception():
salvage_bin.append(f"{svc}: failed with {exc!r}")
else:
results_bin.append(t.result())
task.add_done_callback(on_done)
except* RuntimeError as eg:
# eg 是 ExceptionGroup,包含组内所有 RuntimeError
print(f"部分任务失败: {eg.exceptions}")
print(f"正常结果: {results_bin}")
print(f"抢救区: {salvage_bin}")
asyncio.run(main())
运行结果:
部分任务失败: (RuntimeError('payment service down'),)
正常结果: ['inventory: ok']
抢救区: ['payment: failed with RuntimeError("payment service down")', 'shipping: cancelled, will retry later']
关键变化在于:shipping 任务被组取消后,回调仍然能拿到 cancelled() 状态,你可以据此做差异化处理——比如把取消的请求放进重试队列,而不是和正常结果混在一起。这在 3.14 及更早版本里需要用更笨的 try/finally 加全局标志位才能勉强实现。
except* 与 TaskGroup 的配合更紧密
3.15 对 except* 语法做了几处微调,让它和 TaskGroup 的取消场景更贴合:
CancelledError现在在ExceptionGroup中有更明确的归类,不会和无名异常混在一起。except* CancelledError可以单独捕获所有被取消的任务异常,而except* RuntimeError只管业务异常,互不干扰。
这意味着你可以写这样的结构:
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(job_a())
tg.create_task(job_b())
tg.create_task(job_c())
except* CancelledError as cancelled_group:
log.warning(f"被取消的任务数: {len(cancelled_group.exceptions)}")
schedule_retry(cancelled_group.exceptions)
except* RuntimeError as error_group:
alert_team(error_group.exceptions)
取消和错误各走各的通道,日志、告警、重试逻辑都能精确控制。
其他低调但实用的改动
除了异步领域,3.15 还有几处小改动值得留意:
typing 模块的 TypeIs
TypeIs 是 TypeGuard 的改进版。TypeGuard 只保证窄化后的类型在 if 块内成立,但不影响 else 分支;TypeIs 则双向窄化——if 块里是 T,else 块里自动排除 T。写类型窄化函数时逻辑更自然:
from typing import TypeIs
def is_non_empty_str(val: str) -> TypeIs[str]:
return len(val) > 0
def process(value: str) -> None:
if is_non_empty_str(value):
# 这里 value 被窄化为非空 str
print(value[0]) # 安全,不会 IndexError
else:
# 这里 value 被窄化为空 str
print("got empty string")
如果你之前用 TypeGuard 写过窄化函数,升级时可以逐个替换,IDE 的类型推断会立刻变得更准确。
os.path 模块路径处理的小优化
3.15 对 os.path 中若干函数的边界情况做了修正,比如 normpath 对连续斜杠和 .. 跳跃的处理更符合 POSIX 规范。这些改动不影响正常用法,但如果你写过依赖路径规范化细节的安全检查逻辑(比如防止路径穿越),值得跑一遍测试确认行为一致。
traceback 更紧凑
异常回溯的默认格式做了微调,冗余帧(比如 asyncio 内部调度帧)在默认输出中被折叠,你看到的 traceback 更贴近你自己的代码。调试生产日志时少翻几行无关堆栈,体验直接提升。
升级前的检查清单
3.15 的这些低调改动不像延迟导入那样需要大规模重构,但仍有几件事值得提前准备:
- 异步代码审查:如果你大量使用
TaskGroup,检查现有的取消清理逻辑——CancelledError子类的引入可能让之前的try/finally加标志位方案变得多余,可以简化。 TypeGuard→TypeIs:逐个替换窄化函数的返回类型声明,跑一遍 mypy/pyright 确认双向窄化符合预期。- 路径安全测试:如果项目有基于
os.path.normpath的路径穿越防护,在 3.15 环境下跑回归测试。 - 日志格式适配:
traceback折叠后,如果你有解析堆栈帧的监控脚本,需要适配新格式。
这些改动不大,但叠加起来就是日常编码体验的实质性提升——少写几行 workaround,少踩几个边界坑,类型推断更准确,异步清理更可控。3.15 不只是延迟导入和 Tachyon 的版本,也是这些"小修小补"累积出质变的版本。