为什么要关心停机

很多人写异步服务时,把注意力都放在“怎么启动”,很少认真想“怎么结束”。

但线上真正麻烦的,往往是停机阶段:

  • Kubernetes 滚动发布,Pod 收到 SIGTERM
  • 服务还在处理请求,但新流量已经切走
  • 后台任务没停干净,日志和指标都丢了
  • 数据库连接突然断掉,导致半成功半失败

如果退出流程没有设计好,服务看起来可用,实际上很难运维。

Tokio 默认不会帮你解决一切

Tokio 的运行时很好用,但它不会自动替你处理这些问题:

  • 谁来监听退出信号
  • 如何通知所有任务停止
  • 正在跑的任务是立即取消,还是等它收尾
  • 超时之后要不要强制退出

这些都需要业务自己定义。

一个基础模型

一个比较稳妥的思路是分三步:

  1. 接收退出信号
  2. 广播 shutdown 事件
  3. 等待任务收尾,必要时超时强退

先看一个简化版结构:

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 服务如何优雅退出

如果你用的是 axumhyper,通常都支持 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 里要配合使用

应用自己支持优雅停机还不够,部署层也得配合。

典型流程是:

  1. Pod 收到 SIGTERM
  2. Readiness 变失败,不再接新流量
  3. 应用开始 shutdown
  4. terminationGracePeriodSeconds 内收尾

如果你代码里要 15 秒才能退出,但 Pod 的宽限时间只给了 5 秒,最终还是会被 SIGKILL

这不是 Tokio 的问题,是整个发布链路配置不一致。

常见坑点

1. 只停 HTTP,不停后台协程

很多服务看起来“退出成功”,其实只是 HTTP 监听结束了,内部 spawn 出来的任务还在跑。

2. 没有超时保护

优雅停机不是无限等待。应该给一个合理超时,超时后直接结束进程,避免发布卡死。

3. 信号传播链不完整

主任务知道要退出,不代表数据库 worker、消息消费协程、批处理协程也知道。

4. 把 shutdown 当成异常路径

其实退出流程是服务生命周期的一部分,应该像启动流程一样认真设计和测试。

总结

Rust + Tokio 很适合写长期运行的服务,但“异步写得出来”和“服务可运维”完全是两回事。

优雅停机的本质不是某个 API,而是明确回答这几个问题:

  • 谁发出停止信号
  • 谁负责广播
  • 哪些任务需要收尾
  • 最多允许等多久

这些问题想清楚了,服务上线之后会省很多麻烦。


真正成熟的后端服务,不只是跑得快,也要停得稳。