首页/文章/Rust
Rust

Tokio 异步服务优雅停机:取消、排空与任务收尾

用 CancellationToken、有限队列和 JoinHandle 管理 Rust 异步服务的关闭过程,并说明强制超时的边界。

线上进程收到 SIGTERM 时,如果立即退出,正在处理的请求或队列任务可能被中断;如果永远等待所有任务完成,停机又可能卡住。优雅停机需要明确顺序:停止接收新工作、通知任务收尾、等待有限时间,最后再强制退出。

下面用 Tokio 有界 mpsc 队列演示一个 worker 的生命周期。它适用于理解控制流,不是持久队列:进程被强杀时,内存里的消息仍会丢失。

关闭流程

sequenceDiagram
    participant OS as SIGTERM / Ctrl-C
    participant Main as Main task
    participant W as Worker
    participant Q as Bounded channel
    OS->>Main: signal received
    Main->>W: CancellationToken.cancel()
    W->>Q: close receiver
    W->>Q: drain buffered jobs
    W-->>Main: task completed
    Main->>Main: wait until deadline
    Main->>W: abort only after timeout

Tokio 的官方指南把停机拆成三个阶段:发现停机信号、通知程序各部分、等待它们完成。CancellationToken 适合把同一个关闭信号传给多个任务;JoinHandle 让主任务能观察子任务是否已结束。

Worker:停止接收后排空缓冲区

use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

async fn process(job: String) {
    // 实际服务在这里调用领域逻辑,并在成功后确认任务。
    println!("processing {job}");
}

async fn worker(mut rx: mpsc::Receiver<String>, shutdown: CancellationToken) {
    loop {
        tokio::select! {
            biased;
            // 先检查关闭信号,队列持续有消息时也优先进入收尾。
            _ = shutdown.cancelled() => {
                rx.close(); // 拒绝后续 send,但允许读取已缓冲消息
                while let Some(job) = rx.recv().await {
                    process(job).await;
                }
                break;
            }
            job = rx.recv() => match job {
                Some(job) => process(job).await,
                None => break,
            }
        }
    }
}

这里在收到关闭信号后,先 close() 再把已缓冲消息处理完。Tokio 的 Receiver::recv 是 cancellation-safe 的:如果 select! 的其他分支先完成,不会悄悄取走一条消息。

有界 channel 的容量是背压的一部分。生产者执行 send().await 时,队列满了会等待;不要为了追求吞吐量改成无界队列,否则突发流量可能把进程内存耗尽。

主任务:设置等待上限

以下示例可放入 Tokio 二进制项目。Cargo 依赖需要启用 Tokio 的 macros、rt-multi-thread、signal、sync、time 功能,以及 tokio-util 的 rt 功能。

use std::time::Duration;
use tokio::{signal, sync::mpsc, time::timeout};
use tokio_util::sync::CancellationToken;

async fn shutdown_signal() -> std::io::Result<()> {
    #[cfg(unix)]
    {
        let mut terminate = signal::unix::signal(signal::unix::SignalKind::terminate())?;
        tokio::select! {
            result = signal::ctrl_c() => result,
            _ = terminate.recv() => Ok(()),
        }
    }
    #[cfg(not(unix))]
    {
        signal::ctrl_c().await
    }
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let (_tx, rx) = mpsc::channel::<String>(128);
    let shutdown = CancellationToken::new();
    let worker_shutdown = shutdown.clone();
    let mut worker_task = tokio::spawn(worker(rx, worker_shutdown));

    tokio::select! {
        result = shutdown_signal() => result?,
        result = &mut worker_task => {
            result?;
            return Ok(());
        }
    }

    shutdown.cancel();
    match timeout(Duration::from_secs(20), &mut worker_task).await {
        Ok(result) => result?,
        Err(_) => worker_task.abort(),
    }
    Ok(())
}

在真实服务中,_tx 会由请求处理器或任务生产者持有。收到关闭信号后,还应先让 HTTP server 停止接受新连接,再通知后台 worker。超时后的 abort() 是兜底动作;需要记录哪些任务未完成,便于告警与恢复。

常见踩坑

  1. 只发取消信号,不等待任务。 子任务可能还没完成 flush、ack 或状态回写,主程序就退出了。
  2. 把取消当成事务回滚。 任务在外部服务已产生副作用后被取消,不能自动撤销;依赖幂等键或补偿操作。
  3. 没有关闭队列接收端。 关闭后仍允许生产者继续入队,排空过程就没有清晰终点。
  4. 对所有任务一刀切立即 abort。 网络读写、锁获取和自定义 future 的取消安全性不同;检查每个 .await 被中断后的状态。
  5. 把内存 channel 当作可靠消息系统。 进程崩溃或强制超时会丢失未持久化的消息。需要跨重启恢复时,使用持久队列或数据库任务表,并在处理成功后 ack。
  6. 忽略运行环境的信号约定。 容器编排通常发送 SIGTERM 并有固定宽限期,服务内超时必须短于平台的最终强杀时间。

实践验证

先让 process 执行一个可观测的延迟工作,启动服务后发送 Ctrl-C,确认新任务停止进入、队列中的任务完成、主任务在截止时间内退出。再模拟一个永不结束的任务,确认超时会触发告警并走强制收尾路径。不要仅凭本地 Ctrl-C 测试就宣称生产停机可靠。

参考资料

文章反馈

如果这篇文章对你有帮助,欢迎发邮件交流。

通过邮箱联系 ↗继续阅读 →