
1. 一个事故现场asyncio 的任务失控到底有多常见先说一个我至今想起来还头皮发麻的线上事故。某个内部服务负责批量抓取第三方接口数据代码里到处都是asyncio.create_task把任务扔出去美其名曰异步收批。某天凌晨一个子任务抛了异常调用链上没有任何人接住重试队列越积越多等发现的时候数据库已经塞满了重复数据。事后查日志核心问题根本不是接口挂没挂而是 asyncio 允许我创建一个任务之后既不等待它、也不需要为它的失败负责——这种自由在语言层面看起来很美在生产环境里却是一颗定时炸弹。这也是我后来花了大段时间研究 Trio 和 Nursery 的直接原因。在 Python 异步编程这个圈子里asyncio 几乎是默认答案但它的默认行为并不像文档看起来那么安全。Trio 用Nursery托儿所这个词来命名任务作用域不是玩梗而是正面修正 asyncio 从设计上就遗留的结构性缺陷。这篇文章想聊的就是我从 asyncio 切到 Trio、再带着新视角回看 asyncio 的过程中对这两种异步编程哲学的理解——重点落在 Nursery 到底魔在哪里以及你在自己的项目里能不能用上这套思路。1.1 create_task 之后的裸奔fire-and-forget 的真实代价asyncio.create_task的语义非常直白把一个协程包装成任务丢给事件循环去调度然后立刻返回一个Task对象。问题在于拿到了这个对象之后要不要await它asyncio 完全不管。你完全可以写出下面这种代码而且它运行时没有任何语法报错import asyncio async def ping(): await asyncio.sleep(1) print(ping done) async def main(): asyncio.create_task(ping()) print(main done) asyncio.run(main())运行结果是main done先打印出来然后ping done在事件循环关闭前姗姗来迟。如果ping()里的逻辑要跑 10 秒而main()早就返回了asyncio.run会在关闭事件循环时强制取消这个残留任务。可如果你的create_task发生在一个长期存活的进程里比如 FastAPI 的一个请求处理函数中任务会继续在后台偷偷运行——请求早就返回给客户端了任务却还挂着。这还不是最难受的。真正难受的是你没有任何办法回答这三个问题这个任务到底跑完没有它出错了吗如果出错了错误去哪了事件循环知道这个任务存在但它不负责替你观察结果。你只能靠asyncio.all_tasks()去翻全局任务列表像在深夜的停车场里挨个找车看哪辆车的引擎还在响。我把这种行为叫做任务脱缰。任务本身是异步的但它的生命周期管理却是裸奔的。写的时候很爽排错的时候想哭。1.2 异常被吞掉之后Task exception was never retrieved如果你让一个任务自己抛异常又不去 await 它asyncio 会在任务被垃圾回收时打印一句警告Task exception was never retrieved。代码长这样import asyncio async def boom(): raise RuntimeError(boom) async def main(): asyncio.create_task(boom()) await asyncio.sleep(0.1) asyncio.run(main())那句警告翻译成人话就是有个任务挂了但没人认领这个错误所以我只能告诉你一声然后把它扔了。在短期脚本里你至少还能在终端看到警告在长期运行的服务里这个警告可能根本不会出现在你日常关注的日志中于是变成一次静默的部分失败——某些数据没写到某些外部调用没成功但你无从追查。很多人会拿asyncio.gather来补救tasks [asyncio.create_task(fetch(i)) for i in range(6)] try: results await asyncio.gather(*tasks) except RuntimeError: # 第一个任务挂了但其他任务还在跑 ...表面上看问题解决了异常被接住了。可gather的行为是遇到第一个异常就立刻往上抛并不会取消剩下的任务。它们还在事件循环里继续跑像一群没人接站的旅客。你得自己再写一段清理逻辑把还没完成的任务逐个cancel()再await一遍确认它们真的停了。这个手动收尸的过程就是 asyncio 把责任转嫁给程序员的典型体现。如果忘记做后果就是文章开头那个事故的翻版。2. 两种哲学的分水岭任务该由谁负责asyncio 的这些坑不是某个 API 设计失误而是它的底层世界观决定的。Trio 的作者 Nathaniel Smith 在文档里反复强调一句话the caller is responsible for the callees lifetime——调用方要对被调方的生命周期负责。这句话是整个结构化并发Structured Concurrency思想的基石也是 asyncio 和 Trio 最根本的分歧点。2.1 asyncio 的默认世界观任务创建即是释放asyncio 的默认规则是任务一旦create_task它就属于事件循环了。事件循环是一个扁平的大后台所有任务都是它的孩子彼此之间没有父子责任链。父协程创建完任务使命就算完成了任务的后续死活、异常去向全看有没有别人碰巧去 await 它。这就像你把孩子送进了一个超大福利院福利院承诺会给孩子饭吃但没人承诺替你盯着孩子是否平安。你说这福利院有错吗其实没错它只是把监护责任交还给了你——但它又没给你任何强制机制来履行这份责任。asyncio.Task对象可以被随手丢弃创建者没有任何义务保留它。语言层面完全合法这就是问题所在。调试这种代码时常见的场景是你怀疑有任务泄漏于是asyncio.all_tasks()打出来一堆不知道从哪来的任务然后你得顺着print堆栈去猜是谁创建的。这种侦探式排查占据了 asyncio 从业者的大量时间而且往往查到最后发现就是自己当初随手 create 忘了管。2.2 结构化并发callee 的生命周期由 caller 兜底Trio 换了一个底层世界观任务不能在没有看护者的地方私自繁殖。任何一个开枝散叶的异步操作都必须挂在某个明确的作用域下面作用域退出时里面所有任务必须给出一个明确的下场——要么正常结束要么被取消要么把异常交出来。这个作用域就是Nursery。Trio 用它命名恰好点出了语义一个托儿所接收孩子任务托儿所关门之前必须等所有孩子被接走或者被安置妥当一个都不能留在外面。对应的代码形态是把作用域画成一个缩进的代码块async with trio.open_nursery() as nursery: nursery.start_soon(fetch, 1) nursery.start_soon(fetch, 2)这段代码在缩进块结束的地方划了一条纪律线走到这里时fetch(1)和fetch(2)要么已经跑完要么已经被取消要么它们的异常已经抛到当前这个async with的上下文中。没有中间态没有还在后台跑着的模糊地带。2.3 Nursery 的三个承诺完整、取消、异常汇聚Trio 的 Nursery 之所以让人感觉稳是因为它在设计上给出了三条硬承诺全部由运行时强制执行完整性Completenessnursery 代码块退出时所有子任务已经结束。退出动作会阻塞等待哪怕你忘了写任何await运行时也会替你等着。取消Cancellation只要任何一个子任务抛出异常或者外部比如超时、父作用域被取消发出取消信号nursery 会自动取消所有还在运行的子任务并且等待它们响应取消之后才退出。异常汇聚Propagation子任务的异常不会丢失也不会只报第一个。多个子任务同时失败时异常会被打包成异常组抛到父协程一个不落。这三条承诺和 asyncio 的经典写法对比差距一目了然维度asyncio 经典写法Trio Nursery任务归属创建后挂在事件循环上无明确归属任务必然属于某个 nursery完成等待靠手动 await/gather退出 async with 时自动等待失败处理gather 抛第一个异常其他任务继续跑任一失败触发整组取消异常去向Task exception was never retrieved汇聚到父上下文中重新抛出超时控制wait_for 包一层容易漏move_on_after / fail_after 作用域化数据通道asyncio.Queue 需手工管理哨兵值memory channel 随作用域自动关闭我后来看 asyncio 代码总是下意识地把这三条承诺当验收标准这段代码里的任务能不能回答它结束了吗、出错了吗、归谁管这三个问题能回答才算合格。3. 拆开 Nursery 看内部它凭什么能把任务管住光有理念不够得知道 Nursery 在机制层面是怎么实现的。这一节我们把它拆开看。3.1 async with 的魔法进入即创建作用域退出即等待回收Nursery 的用法非常简单但简单背后藏着不少细节async def main(): async with trio.open_nursery() as nursery: nursery.start_soon(worker, a) nursery.start_soon(worker, b) print(both workers done or cancelled)async with进入时Trio 内部做了三件事创建一个任务容器、创建一个取消作用域CancelScope、初始化子任务计数器。start_soon每被调用一次计数器加一对应的协程开始运行时它被登记在这个 nursery 名下。真正关键的是退出阶段。走到async with的缩进末尾时Trio 不会直接返回而是进入一个等待状态检查子任务计数器是否归零。如果还有子任务在跑它会挂起等待如果当前上下文是因为某个子任务抛了异常才往下走的它会先取消所有还在跑的子任务等它们全部响应取消之后再把异常抛出去。这段等待逻辑是运行时替你写的不是让你手动补的。这就是完整承诺的实现方式——你不是被要求做事而是被结构性地保证。3.2 取消不是杀进程而是协作式信号很多人一听取消任务就以为是像kill -9一样把任务杀掉。Python 的异步模型里做不到这件事也不需要做到。取消的本质是在任务下一次到达某个安全检查点时往它内部抛一个CancelledError在 Python 3.8 之后它继承自BaseException普通except Exception是接不住的。asyncio 和 Trio 都用协作式取消但两者的检查点概念有微妙差异。asyncio 里几乎所有await都可能变成取消注入点注入的时机相对随意Trio 则把这类位置统称为checkpoint比如trio.sleep()、trio.lowlevel.checkpoint()、channel 的 send/receive。Trio 明确告诉你取消只会在这些点发生而且每个 checkpoint 都有机会触发调度。这带来的实际好处是你能更准确地预测取消什么时候生效。写清理代码时你知道只要await一个 checkpoint就一定会回应取消信号。asyncio 的取消更像随缘有时候await asyncio.sleep(0)能触发有时候一个已完成的 future 的 await 直接短路跳过任务就对取消无感了。Trio 的另一个狠招是CancelledError不允许被吞掉。如果你写except BaseException: pass把取消信号吃了新版本 Trio 的严格模式会直接判这个任务为拒不配合取消最终触发硬错误而不是让整个程序无限等待。asyncio 里吞掉CancelledError的经典后果是wait_for超时后任务依然在跑你查半天查不出原因Trio 选择从机制上杜绝这种死锁式的沉默。3.3 异常汇聚与多错误BaseExceptionGroup 的来龙去脉Nursery 的异常传播是最能体现魔力的地方。想象一个场景nursery 里有 6 个子任务其中 3 个同时失败了。asyncio 的gather只会把第一个异常抛给你Trio 会把 3 个异常打包成一个异常组抛出来。Trio 的早期版本里这个类型叫MultiError。Python 3.11 通过 PEP 654 把ExceptionGroup/BaseExceptionGroup变成了标准库类型之后新版本 Trio 直接改用标准类型两者语义基本一致。处理方式也升级了可以用except*语法按类型拆开try: async with trio.open_nursery() as nursery: nursery.start_soon(fetch, 1) nursery.start_soon(fetch, 2) nursery.start_soon(fetch, 3) except* RuntimeError as eg: print(f{len(eg.exceptions)} 个请求失败) except* KeyboardInterrupt: print(收到 CtrlC)这个一口气把所有子任务失败原因都交给你的行为在批量任务场景里尤其值钱。我写过的一个批量抓数服务里except* RuntimeError之后能直接拿到所有失败任务的参数和异常信息一次性上报给监控系统换成gather的话要么每次都只看到第一个错误然后修完再撞下一个要么写一堆复杂逻辑去手动收集。3.4 屏蔽、嵌套与层级CancelScope 的组合能力Nursery 不是一个孤岛。每个 nursery 内部天然携带一个取消作用域而任务内部还可以再开子 nursery形成一棵层级树async def outer(): async with trio.open_nursery() as nursery: nursery.start_soon(inner) # inner 内部还能再开 nursery async def inner(): async with trio.open_nursery() as sub_nursery: sub_nursery.start_soon(worker)外层 nursery 被取消时取消信号会顺着这棵树向下传播内层 nursery 收到信号后自动取消它的子任务然后逐层向上收敛。这种层级传播在 asyncio 里没有对应的原生概念——你要自己实现一套取消传播树很容易漏。Trio 还提供shield机制让某段代码临时屏蔽外部取消。这听起来很实用但有个著名的坑叫deadly barrier如果你在 nursery 里屏蔽了取消而外部的取消又一直在等这个任务结束就会出现外部等内部、内部屏蔽外部的死锁。Trio 对此的处理是直接报硬错误宁可 crash 也不允许死锁。这个设计哲学破釜沉舟但它换来的安全感恰恰是 asyncio 时代我们最缺的东西。4. 同一业务场景的实现对照并发抓取与超时兜底理论讲了半天不如直接上一个业务场景。假设有个任务并发抓取 6 个外部接口每个接口可能随机失败整体必须在 5 秒内返回失败时要快速收手、不留残局。先看 asyncio 版本怎么写再看 Trio 版本怎么写。4.1 先写一个 asyncio 版本再指出它的三个隐患import asyncio import random async def fetch_one(i): await asyncio.sleep(random.uniform(0.05, 0.4)) if i % 3 0: raise RuntimeError(ffetch {i} failed) return i async def main(): tasks [asyncio.create_task(fetch_one(i)) for i in range(6)] try: results await asyncio.wait_for(asyncio.gather(*tasks), timeout5) print(results) except asyncio.TimeoutError: print(整体超时) except RuntimeError: print(有请求失败) print(残留任务数:, len(asyncio.all_tasks()))这段代码有三个隐患每一个都够你喝一壶隐患一gather 只报第一个异常其他任务在后台继续跑。fetch 0失败后RuntimeError立刻抛出但fetch 3、fetch 6还没结束它们会继续占用事件循环。如果这个函数只是大系统里的一个零件这些残留任务会一路带病运行直到它们自己结束或者把错误再次暴露出来。隐患二超时和业务失败混在一起。wait_for超时抛的是asyncio.TimeoutErrorgather抛的是RuntimeError但超时触发的取消操作会把你原本想收集的任务状态搅乱。你想区分整体太慢和某个请求坏了得看异常类型还得保证取消之后的二次清理。隐患三清洗残留任务完全靠自觉。正确写法应该在异常分支里手动补一段except RuntimeError: for t in tasks: t.cancel() await asyncio.gather(*tasks, return_exceptionsTrue)这还只是一个简单场景。如果任务之间还有依赖关系、有伴随清理任务、有嵌套任务这套手动收尾代码会膨胀到比业务代码还可观。这就是我说的责任转嫁给程序员。4.2 Trio 版本nursery fail_after 的优雅收场同样的场景Trio 的写法是把超时作用域套在 nursery 外面import trio import random async def fetch_one(i): await trio.sleep(random.uniform(0.05, 0.4)) if i % 3 0: raise RuntimeError(ffetch {i} failed) return i async def main(): try: with trio.fail_after(5): async with trio.open_nursery() as nursery: for i in range(6): nursery.start_soon(fetch_one, i) print(waiting for all fetches...) except trio.TooSlowError: print(整体超时) except* RuntimeError as eg: print(f有 {len(eg.exceptions)} 个请求失败) print(走到这里时子任务要么结束、要么已被取消)这个版本的行为非常简单fail_after(5)给整个 scope 挂了一个 5 秒的 deadlinenursery 里任何一个子任务失败nursery 会先取消所有兄弟任务等它们响应取消之后把异常组抛出来如果 5 秒到了还有任务没结束Trio 取消整个 scope 内的任务然后抛TooSlowError。无论走哪条路走到最后的print时事件循环上已经没有任何残留任务了。fail_after还有个兄弟叫move_on_after。区别在于fail_after超时会抛异常move_on_after超时只是静默退出作用域你可以事后用 scope 的cancelled_caught属性判断是否被超时打断with trio.move_on_after(5) as scope: async with trio.open_nursery() as nursery: ... if scope.cancelled_caught: print(超时但某些任务可能已经拿到了部分结果)这个超时和失败的区分在 asyncio 里要靠异常类型硬猜在 Trio 里是两种不同的控制流写起来心里有数得多。4.3 从 queue 到 memory channel生产者-消费者的所有权语义Nursery 的魔力不止停留在任务本身它还延伸到了任务之间的数据传递。asyncio 里常见的生产者-消费者模型是这样的一个任务往asyncio.Queue里放数据一个任务往外取队列本身没有生命周期你得手动往队列里塞一个结束哨兵值来通知消费者退出。Trio 用memory channel替代 queue核心差异是 channel 有所有权它和创建它的作用域绑定作用域结束时自动关闭。看一段实际代码from trio import open_memory_channel async def producer(send_channel): async with send_channel: for i in range(10): await send_channel.send(i) # 退出 async with 后send_channel 自动关闭 async def consumer(receive_channel): async with receive_channel: async for item in receive_channel: print(item) async def main(): send_channel, receive_channel open_memory_channel(0) async with trio.open_nursery() as nursery: nursery.start_soon(producer, send_channel) nursery.start_soon(consumer, receive_channel)生产者自然结束channel 自动关闭消费者的async for收到关闭信号后优雅退出如果消费者提前退出生产者下一次send会抛出异常这个异常会立刻传导到 nursery让整个任务组知道数据没送出去。没有哨兵值没有用手工 flag没有默默积压的数据。channel 的生命周期和任务的生命周期绑定在一起谁也别想偷偷甩锅。5. 迁移决策与实用建议Trio 到底值不值得学聊到这儿你可能已经被 Nursery 的理念打动了但现实是asyncio 依然是 Python 生态的事实标准。这里给你一些实际的迁移和选型建议。5.1 生态现实asyncio 是默认但 Trio 思想已被吸收Python 官方其实早就看到了 asyncio 的问题。Python 3.11 引入了asyncio.TaskGroup它的行为和 Trio 的 Nursery 几乎一模一样import asyncio async def main(): async with asyncio.TaskGroup() as tg: tg.create_task(fetch_one(1)) tg.create_task(fetch_one(2))TaskGroup 提供了退出屏障、失败取消、异常聚合这三个核心承诺配合标准库的except*语法基本就是 asyncio 版本的 Nursery。如果你只能待在 asyncio 生态里至少应该把asyncio.TaskGroup当成默认底座而不是继续create_task裸奔。更值得说的是TaskGroup 的设计直接沿袭了 Trio 开启的思路。官方在文档里明说这是一个structured concurrency风格的 API。这说明什么说明 Tigo 的哲学已经从小众框架的偏门理念变成了标准库吸收的主流方案。你现在学 Nursery学的是一整套已经被 Python 官方背书过的思想。5.2 用不了 Trio 的时候anyio 和 Python 3.11 TaskGroup 的折中如果你的项目必须依赖某个 asyncio-only 的第三方库比如某些数据库驱动切换 Trio 的阻力会很大。这时候最实用的折中方案是anyio——它在 asyncio 和 Trio 两套后端之上抽象了一层任务组 APIimport anyio async def main(): async with anyio.create_task_group() as tg: for i in range(6): tg.start_soon(fetch_one, i) print(all done)你依然跑在 asyncio 后端但代码的编写方式已经变成了结构化并发风格。anyio 还提供了move_on_after、fail_after、to_thread等对应能力可以平滑地把 asyncio 代码改造成 nursery 风格。很多知名库比如 httpx内部就是用 anyio 做的兼容层所以两边生态的边界比前几年模糊了不少。5.3 什么时候选 Trio什么时候老实留在 asyncio我的建议很简单新项目、第三方依赖可控、没有非用不可的 asyncio-only 库优先 Trio。它的运行时保证更严谨代码写起来也更短心智负担更低。如果项目重度依赖 FastAPI、特定异步 ORM、或者已有大量 asyncio 代码留在 asyncio 没问题但强制自己用 TaskGroup 或 anyio。另外哪怕你不打算在生产环境用 Trio我也强烈建议拿它练练手。理由很朴素Trio 的文档和 API 设计把取消、异常、生命周期这些异步编程里最含糊的概念讲成了清晰且可验证的规则。你一旦理解了这些规则回头看 asyncio 代码时会瞬间发现以前那些查不到原因的挂起莫名其妙的异常丢失到底是怎么发生的。我个人现在的做法是能上 Trio 的个人项目和内部工具一律上 Trio给客户交付的服务如果绕不开 asyncio 生态就把 TaskGroup 当默认底座并且绝不允许团队里出现裸奔的create_task。学 Trio 最大的收获不是多会了一个框架而是脑子里从此对一个异步任务结束了吗、出错了吗、归谁管这三个问题有了条件反射式的答案。这套答案才是 Nursery 真正的魔力所在。