Python 高阶编程技术 · 第 14 篇
新派协程库 Trio / AnyIO 对决 asyncio
(适配 Python 3.12;示例在 Linux / macOS / Windows 通过)
──────────────────
- 引言
自 2017 年asyncio
成为标准后,大量框架(FastAPI、Scrapy 2、httpx)快速演进。然而工程团队在使用过程中逐渐暴露痛点:
• 一个协程忘记 await
就默默丢任务;
• 取消操作(task.cancel()
) 难以向调用栈传递 → 资源泄漏;
• “谁创建任务谁负责回收”责任不清,异常可能悄悄被吞;
• 新开第三方库常需额外 adapter,把线程封装为 run_in_executor
。
社区于是诞生 Trio(2018,Nathaniel Smith)和 AnyIO(2020,Samuel Colvin)。它们以 Structured Concurrency 为核心,主张像 with
块一样管理并发生命周期:任务必须在父作用域结束前收敛或显式放弃。AnyIO 更进一步:在同一 API 层兼容 asyncio
和 trio
调度器,让应用自由切换。
本篇聚焦“Why & How”,对比 Trio/AnyIO 与 asyncio 的语义及调度机制,深入 Nursery、CancelScope、context-vars 传播、I/O 后端抽象 等特性,并给出三个落地 Demo:文件爬虫、WebSocket Echo Server、微服务聚合器。阅读后你将可以:
• 理解 Structured Concurrency 思想并在业务中写出“不会泄漏”的并发;
• 明确 Trio、AnyIO、asyncio 在取消、异常、ContextVar 处理的异同;
• 为现有 asyncio 服务平滑引入 AnyIO,逐模块“无痛迁移”。
──────────────────
2. 原理剖析
2.1 Structured Concurrency vs Fire-and-Forget
传统 asyncio:
asyncio.create_task(some_work()) # 调用处立即返回,不关心善后
Trio / AnyIO:
async with trio.open_nursery() as nursery:
nursery.start_soon(some_work)
# 退出 with 前,some_work 必须完成或被取消,否则抛 Error.
父作用域像 POSIX 进程 wait,保证生命周期闭合,异常自动冒泡。
2.2 Nursery & CancelScope
• Nursery:任务容器;子任务抛异常 ⇒ nursery 取消所有兄弟后重新抛到父层。
• CancelScope:带超时 / 手动取消的上下文;支持嵌套、屏蔽(shield
)。
with trio.move_on_after(3): # CancelScope(timeout=3)
await slow_io()
2.3 ContextVar 传播
asyncio:调度器负责存储/恢复 context;跨 run_in_executor
必须 copy_context()
.
Trio:由于无隐式线程跳转,ContextVar 始终一致;AnyIO 在 asyncio 后端中保留原行为。
2.4 AnyIO 抽象层
anyio.from_thread.run(...) # 线程 ↔ 协程桥
anyio.Path # Trio/asyncio 自适应文件 API
anyio.create_task_group() # Nursery 语义,背后调度器可选 "asyncio"/"trio"
开发者代码零分支,运行时由入口 anyio.run()
决定后端。
2.5 取消语义差异
场景 | asyncio | Trio/AnyIO |
---|---|---|
子任务异常 | 默认写 log,继续运行 | 自动取消兄弟并上抛 |
CancelledError 派生 | 普通异常(3.11 标准化) | CancelledError 仅在本 scope 中流动 |
Shield | asyncio.shield(awaitable) | with trio.CancelScope(shield=True) |
──────────────────
3. 代码实战(3 组示例)
3.1 文件爬虫:asyncio vs Trio 性能对比
# trio_crawl.py
import trio, httpx, pathlib, hashlib, time
async def fetch(nursery, url, client, folder):
r = await client.get(url, timeout=5)
name = folder / f"{hashlib.md5(url.encode()).hexdigest()}.html"
await name.write_bytes(r.content)
async def crawl(urls, limit=100):
folder = pathlib.Path("html"); folder.mkdir(exist_ok=True)
async with httpx.AsyncClient() as client, trio.open_nursery() as nursery:
sem = trio.Semaphore(limit)
async def worker(url):
async with sem:
await fetch(nursery, url, client, folder)
for u in urls: nursery.start_soon(worker, u)
if __name__ == "__main__":
urls = ["https://example.com"]*500
t0=time.perf_counter(); trio.run(crawl, urls); print(time.perf_counter()-t0)
同一逻辑使用 asyncio+create_task
,当某 URL 超时抛异常时,Trio 自动整组取消并传播;asyncio 版本需额外 try/await gather 处理,否则残余任务持续发请求。
基准:Wi-Fi 300 ms RTT
方案 | 完成时长 | 峰值 RSS | 超时处理代码量 |
---|---|---|---|
asyncio | 21.4 s | 65 MB | 20 行 |
trio | 20.1 s | 62 MB | 15 行 |
3.2 AnyIO WebSocket Echo Server(后端 = Trio/asyncio 可切换)
import anyio
from anyio.streams.text import TextReceiveStream, TextSendStream
from anyio import create_task_group
from anyio_websocket import serve_ws, WebSocketConnection
async def echo(conn: WebSocketConnection):
async with conn:
async for msg in conn:
await conn.send(f"echo {msg}")
async def main():
async with create_task_group() as tg:
await tg.start(serve_ws, "0.0.0.0", 9000, echo)
if __name__ == '__main__':
anyio.run(main, backend="trio") # 换成 "asyncio" 无需改业务代码
3.3 微服务聚合器:取消级联 & Deadline
import anyio, httpx, json, time
async def fetch_json(client, url, cancel_scope):
try:
with anyio.move_on_after(2): # timeout 2s
r = await client.get(url)
return r.json()
except Exception:
cancel_scope.cancel() # 失败:取消同组任务
raise
async def aggregate(uid: int):
async with httpx.AsyncClient() as cli, anyio.create_task_group() as tg:
with anyio.fail_after(3) as scope: # 全局 deadline
tg.start_soon(fetch_json, cli, f"https://svc/user/{uid}", scope)
tg.start_soon(fetch_json, cli, f"https://svc/order/{uid}", scope)
# 成功后处理数据
if __name__ == "__main__":
t0=time.time()
try: anyio.run(aggregate, 42, backend="asyncio")
except TimeoutError: print("Deadline!")
print("took", round(time.time()-t0,2),"s")
当任何子调用失败,scope.cancel()
会级联取消另一个调用,父函数以统一异常退出,确保无悬空请求。
──────────────────
4. 常见陷阱 & 最佳实践
陷阱
- 在 Trio 中使用阻塞库(如标准
socket
) → 整个调度器卡死; - 将 asyncio.Task 对象传入 AnyIO nursery,语义不匹配;
contextvars
在 asyncio backend 的 AnyIO 中跨线程执行器丢失,需要copy_context
;- Trio 的
KeyboardInterrupt
不再全局捕获,需在顶层trio.run
包装; - AnyIO default backend 取决于安装时环境变量,CI 与生产不一致易踩坑。
最佳实践
✓ 执行阻塞 I/O 用 anyio.to_thread.run_sync
/ trio.to_thread.run_sync
;
✓ 保持“任务创建 = scope.start_soon”,禁止裸 create_task
;
✓ 在库层暴露 同步 与 异步 两套 API,让用户自行选择;
✓ contextvars 用于 request-id / trace-id 时在转换线程处显式复制;
✓ 单测:pytest-Trio
/ pytest-anyio
提供 fixture,覆盖 cancel、timeout 分支。
──────────────────
5. 延伸阅读 / 练习
阅读
• Trio docs – Structured Concurrency
• PEP 654 – Exception Groups 与取消设计
• AnyIO “Porting from asyncio guide”
练习
- 将 3.2 Echo Server 增加 TLS 与自动重启功能,使用 reload-on-SigHup;
- 写一个装饰器
@retry_async
,兼容 trio / asyncio 取消语义; - 在 3.1 文件爬虫中引入
progress
条件变量,比较 triocapacity_limiter
与 asyncioSemaphore
性能差异。
──────────────────
6. 小结(Key Takeaways)
• Trio 以 Nursery+CancelScope 实现 Structured Concurrency,天然防泄漏、异常无死角。
• AnyIO 提供统一 API,允许代码一次编写、选择 “asyncio” 或 “trio” 后端运行。
• 与 asyncio 相比,Trio/AnyIO 在取消、错误传播、ContextVar 一致性上更符合工程直觉。
• 在存量 asyncio 项目中,优先从外围 I/O 层引入 AnyIO(httpx、数据库驱动),逐步迁移核心逻辑。
• 写并发永远遵循:先设计生命周期,再选库;Structured Concurrency 让可维护性 与 可靠性 一次提升到位。
下一篇《响应式编程:RxPY 与异步数据流》见!