Tokio 取消任务:异步代码不能只会 spawn
一、能并发启动,不代表能正确停止
学习 Tokio 时,tokio::spawn很容易带来成就感:任务可以并发跑起来,程序看起来很像生产工具。但真正写 CLI、服务或 Agent 工具时,任务能否被取消同样重要。用户按 Ctrl+C、请求超时、上游失败、配置变更,都需要让后台任务有序停止。
如果只会 spawn,不会取消,程序会出现悬挂请求、文件句柄不释放、进度条卡住和退出不干净等问题。异步编程不是把同步代码丢进任务里,而是设计任务生命周期。什么时候开始,什么时候停止,失败如何传播,都要清楚。
我最早写一个文件批量处理工具时,遇到过这样一个问题。用户拖了 200 个文件进去,跑到一半发现选错了目录,按 Ctrl+C 想重来。但程序没停,后台任务还在逐个打开文件写日志。终端看起来死了,实际上 CPU 还在跑。最后只能 kill -9,丢了一半中间文件。就是那一次,我第一次意识到异步任务不能只会启动,不会叫停。
二、任务生命周期:启动、等待、取消、清理
flowchart TD A[启动任务] --> B[执行异步操作] B --> C{完成或取消} C -->|完成| D[返回结果] C -->|取消| E[释放资源] E --> F[通知调用方]Tokio 中常见取消方式包括select!等待取消信号、使用JoinHandle::abort、通过 channel 通知任务退出,或使用CancellationToken。不同方式适合不同场景。强行 abort 简单,但清理机会少;协作式取消更优雅,但需要任务内部定期检查信号。
对于网络请求、文件处理和模型流式输出,协作式取消更可控。任务可以在安全点停止,关闭输出、刷新日志、释放临时文件。系统级工具写到后面,优雅退出会比想象中重要。
三、代码示例:用 select 等待取消信号
下面是一个简化示例:任务同时等待工作完成和取消通知。
use tokio::sync::watch; use tokio::time::{sleep, Duration}; async fn worker(mut shutdown: watch::Receiver<bool>) { loop { tokio::select! { _ = shutdown.changed() => { if *shutdown.borrow() { break; } } _ = sleep(Duration::from_millis(200)) => { println!("working..."); } } } println!("worker stopped"); }这个例子很小,但表达了一个重要习惯:任务不是无限跑,它应该知道何时退出。真实项目中,可以把shutdown传给文件扫描、HTTP 流读取和插件执行等模块。这样顶层收到 Ctrl+C 后,能把退出信号传下去。
如果使用JoinHandle::abort,要意识到任务可能在任意 await 点停止。对于需要写完临时文件、提交状态或释放锁的任务,最好使用协作式取消。能温柔停下的,就别硬拔电源。
生产环境实战经验
在做文件扫描工具时,我遇到过一个坑。扫描任务用JoinHandle::abort取消后,文件句柄没有释放。原因是底层调用的第三方库在 Drop 实现里才关闭句柄,但 abort 不会执行 Drop。后来改成了:扫描循环里定期检查CancellationToken,并且在退出路径显式 flush 缓冲区。改动不大,但文件句柄泄漏导致的"文件被占用"错误再也没出现过。
四、错误传播:后台任务失败不能沉默
另一个常见坑是 spawn 后不管JoinHandle。任务内部 panic 或返回错误,主程序完全不知道。对于重要任务,应收集 handle,并在退出前等待结果。后台任务失败应该记录日志,必要时让主流程失败。
超时也属于取消。可以用tokio::time::timeout包住外部请求,但超时后要确认底层任务是否真的停止。某些库在 future 被 drop 后会取消请求,某些场景还需要额外关闭资源。不要以为 timeout 返回错误就万事大吉。
一个真实的失败案例
有一次我在异步下载工具里用timeout包住 HTTP 请求,超时后只打印了日志,没调用连接的close()。结果底层连接池里积了十几个假连接,后续所有请求都排在它们后面等待。监控上看 QPS 降到个位数,排查了两天才定位到。从那以后,超时路径我都会显式释放资源,尤其是网络连接和文件描述符。
写 AI CLI 时,流式响应尤其需要取消。用户按 Ctrl+C 后,应停止读取网络流,恢复终端状态,输出简短提示。否则终端体验会很难受。异步代码的体面,往往体现在退出路径上。
五、总结
Tokio 异步编程不能只会spawn,还要设计任务取消、资源清理和错误传播。协作式取消、超时控制、JoinHandle 管理和 Ctrl+C 处理,是系统级工具的基本功。任务能开始,也要能停下。