为什么要关心停机
很多人写异步服务时,把注意力都放在“怎么启动”,很少认真想“怎么结束”。
但线上真正麻烦的,往往是停机阶段:
- Kubernetes 滚动发布,Pod 收到
SIGTERM - 服务还在处理请求,但新流量已经切走
- 后台任务没停干净,日志和指标都丢了
- 数据库连接突然断掉,导致半成功半失败
如果退出流程没有设计好,服务看起来可用,实际上很难运维。
Tokio 默认不会帮你解决一切
Tokio 的运行时很好用,但它不会自动替你处理这些问题:
- 谁来监听退出信号
- 如何通知所有任务停止
- 正在跑的任务是立即取消,还是等它收尾
- 超时之后要不要强制退出
这些都需要业务自己定义。
一个基础模型
一个比较稳妥的思路是分三步:
- 接收退出信号
- 广播 shutdown 事件
- 等待任务收尾,必要时超时强退
先看一个简化版结构:
use tokio::signal;
use tokio::sync::broadcast;
use tokio::time::{timeout, Duration};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let (shutdown_tx, _) = broadcast::channel::<()>(16);
let server_handle = tokio::spawn(run_http_server(shutdown_tx.subscribe()));
let worker_handle = tokio::spawn(run_background_worker(shutdown_tx.subscribe()));
signal::ctrl_c().await?;
println!("received shutdown signal");
let _ = shutdown_tx.send(());
let _ = timeout(Duration::from_secs(10), async {
let _ = server_handle.await;
let _ = worker_handle.await;
}).await;
Ok(())
}
这段代码表达的核心思想很重要:
- 主任务只负责生命周期控制
- 具体业务任务通过订阅 shutdown 信号决定何时退出
- 主流程统一等待,避免任务泄漏
HTTP 服务如何优雅退出
如果你用的是 axum 或 hyper,通常都支持 graceful shutdown 钩子。
use axum::{routing::get, Router};
use tokio::sync::broadcast;
async fn run_http_server(mut shutdown_rx: broadcast::Receiver<()>) {
let app = Router::new().route("/health", get(|| async { "ok" }));
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
.await
.expect("bind failed");
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx.recv().await;
})
.await
.expect("server exited with error");
}
收到退出信号后,服务会停止接收新连接,但会尽量处理完当前请求。这一点对 API 服务非常关键,不然客户端会看到大量随机失败。
后台任务不能只靠 abort
很多人第一反应是:停机时直接 handle.abort() 不就完了?
能用,但不要默认这样做。
原因很简单:
- 任务可能正在刷批量数据
- 任务可能持有锁
- 任务可能要把缓冲区日志落盘
更稳的做法是让 worker 主动检查 shutdown 信号。
use tokio::sync::broadcast;
async fn run_background_worker(mut shutdown_rx: broadcast::Receiver<()>) {
loop {
tokio::select! {
_ = shutdown_rx.recv() => {
println!("worker shutting down");
flush_metrics().await;
break;
}
_ = do_one_job() => {}
}
}
}
async fn do_one_job() {
// 模拟处理一个任务
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
async fn flush_metrics() {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
这种模式的关键点在于:取消不是中断点无处不在,而是你自己定义安全退出点。
在 Kubernetes 里要配合使用
应用自己支持优雅停机还不够,部署层也得配合。
典型流程是:
- Pod 收到
SIGTERM - Readiness 变失败,不再接新流量
- 应用开始 shutdown
- 在
terminationGracePeriodSeconds内收尾
如果你代码里要 15 秒才能退出,但 Pod 的宽限时间只给了 5 秒,最终还是会被 SIGKILL。
这不是 Tokio 的问题,是整个发布链路配置不一致。
常见坑点
1. 只停 HTTP,不停后台协程
很多服务看起来“退出成功”,其实只是 HTTP 监听结束了,内部 spawn 出来的任务还在跑。
2. 没有超时保护
优雅停机不是无限等待。应该给一个合理超时,超时后直接结束进程,避免发布卡死。
3. 信号传播链不完整
主任务知道要退出,不代表数据库 worker、消息消费协程、批处理协程也知道。
4. 把 shutdown 当成异常路径
其实退出流程是服务生命周期的一部分,应该像启动流程一样认真设计和测试。
总结
Rust + Tokio 很适合写长期运行的服务,但“异步写得出来”和“服务可运维”完全是两回事。
优雅停机的本质不是某个 API,而是明确回答这几个问题:
- 谁发出停止信号
- 谁负责广播
- 哪些任务需要收尾
- 最多允许等多久
这些问题想清楚了,服务上线之后会省很多麻烦。
真正成熟的后端服务,不只是跑得快,也要停得稳。