
Tokio 优雅关闭处理 SIGTERM 时怎么让在飞的任务安全着陆标签技术、Tokio、Rust、信号处理、最佳实践一、CtrlC 不能解决一切直到有一次我写的 WebSocket 转发服务部署到服务器上。运维同事告诉我你的服务在被 kill 的时候活跃连接全部断掉了而且日志里的处理完成记录丢了将近一半。我第一反应等一下难道 Docker 或者 systemd 不会等任务执行完再杀吗答案是不会。容器编排系统Kubernetes、Docker Compose或者进程管理器systemd发送 SIGTERM 信号之后会等一个 Grace Period默认通常是 30 秒如果进程还没退出就直接 SIGKILL 暴力杀掉了。在这 30 秒内你的程序必须自己把手上的活收尾然后主动退出。什么都不做的话所有正在执行的任务都会被丢弃。这篇文章就是我从CtrlC 程序员到会处理信号的程序员的一步。还是那句话我自学出身写得不对的地方欢迎大家指正。二、先理解信号流程和 Tokio 的运行时模型在写代码之前我需要搞清楚两件事操作系统发 SIGTERM 之后我的程序怎么感知到Tokio 的运行时里正在执行的任务是什么样的状态。sequenceDiagram participant K8s as Kubernetes / Docker participant OS as 操作系统 participant App as Rust 程序 (main) participant RT as Tokio 运行时 participant T1 as 任务 A (处理请求) participant T2 as 任务 B (写日志) K8s-OS: 发送 SIGTERM 信号 OS-App: 触发信号处理器 Note over App: 进入优雅关闭流程 App-App: 设置 shutdown_flag true App-App: 通知所有组件开始收尾 par 并行等待任务完成 App-T1: 等待任务 A 完成 T1--App: 任务 A 完成 and App-T2: 等待任务 B 完成 T2--App: 任务 B 完成 end App-App: 清理资源、刷新缓冲区 App-OS: 进程退出 (exit 0) Note over K8s,OS: 如果超时未退出 → SIGKILL图上有个关键点信号处理和任务等待是并行的。你不能在信号处理函数里直接等任务完成因为那样会阻塞。正确的做法是设置一个标志让所有任务都响应这个标志然后主流程等待所有任务上报我收好了。三、动手写一个能优雅关闭的 Tokio HTTP 服务下面是我写的一个示例用的是axum框架 tokio运行时。核心逻辑分三步注册信号处理器监听 SIGTERM / SIGINT关闭时停止接收新请求等待已有请求处理完清理资源后退出。use axum::{routing::get, Router}; use std::net::SocketAddr; use tokio::signal; use tokio::sync::watch; use tokio::time::{sleep, Duration}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; /// 全局关闭标志所有任务都检查这个标志 /// AtomicBool 允许跨线程安全地读写 struct AppState { shutdown_flag: AtomicBool, // watch channel 用于通知所有监听者 shutdown_tx: watch::Senderbool, } #[tokio::main] async fn main() { // 初始化日志系统 tracing_subscriber::fmt::init(); // 创建 watch channel用于广播该下班了信号 let (shutdown_tx, shutdown_rx) watch::channel(false); // 共享状态 let state Arc::new(AppState { shutdown_flag: AtomicBool::new(false), shutdown_tx: shutdown_tx.clone(), }); // 构建路由一个普通请求 一个模拟慢请求 let app Router::new() .route(/health, get(health_check)) .route(/slow, get(slow_handler)) .with_state(state.clone()); let addr: SocketAddr 0.0.0.0:3000.parse().unwrap(); let listener tokio::net::TcpListener::bind(addr).await.unwrap(); tracing::info!(服务启动在 {}, addr); // 关键用 axum 的 graceful shutdown 能力 // serve 接收连接with_graceful_shutdown 在信号触发时停止接收新连接 axum::serve(listener, app) .with_graceful_shutdown(shutdown_signal(shutdown_tx, shutdown_rx)) .await .unwrap(); tracing::info!(服务已安全退出); } /// 健康检查接口如果正在关闭返回 503 async fn health_check( state: axum::extract::StateArcAppState, ) - (axum::http::StatusCode, static str) { if state.shutdown_flag.load(Ordering::Relaxed) { ( axum::http::StatusCode::SERVICE_UNAVAILABLE, 正在关闭中, ) } else { (axum::http::StatusCode::OK, OK) } } /// 模拟一个需要 5 秒才能处理完的慢请求 /// 这个接口用来验证收到关闭信号时已经在飞的任务能否安全着陆 async fn slow_handler( state: axum::extract::StateArcAppState, ) - static str { tracing::info!(开始处理慢请求...); // 分 5 步模拟一个长任务每步之间检查是否该停了 for step in 1..5 { // 检查关闭标志 if state.shutdown_flag.load(Ordering::Relaxed) { tracing::warn!(收到关闭信号在第 {} 步中断, step); return 请求被中断; } // 模拟每一步耗时操作 sleep(Duration::from_secs(1)).await; tracing::info!(慢请求步骤 {}/5 完成, step); } tracing::info!(慢请求处理完成); 慢请求处理完成 } /// 优雅关闭信号处理器 /// 监听 SIGTERM 和 CtrlC触发关闭流程 async fn shutdown_signal( shutdown_tx: watch::Senderbool, mut shutdown_rx: watch::Receiverbool, ) { // 等待第一个关闭信号CtrlC 或 SIGTERM let ctrl_c async { signal::ctrl_c() .await .expect(无法注册 CtrlC 处理器); }; #[cfg(unix)] let terminate async { signal::unix::signal(signal::unix::SignalKind::terminate()) .expect(无法注册 SIGTERM 处理器) .recv() .await; }; #[cfg(not(unix))] let terminate std::future::pending::()(); // 谁先来就先响应谁 tokio::select! { _ ctrl_c { tracing::info!(收到 CtrlC 信号开始优雅关闭); } _ terminate { tracing::info!(收到 SIGTERM 信号开始优雅关闭); } } // 第一步通知所有监听者 —— 下班了别再接新活了 let _ shutdown_tx.send(true); tracing::info!(等待当前请求处理完毕最多 25 秒...); // 第二步等待一段时间让现有任务收尾 // 这里的超时时间应该小于容器编排的 terminationGracePeriodSeconds match tokio::time::timeout(Duration::from_secs(25), async { // 持续检查直到确认没有活跃任务为止 // 实际项目中可以接入 metrics 或任务计数器 let mut rx shutdown_rx; // 这里只是示意实际可以用 JoinSet 或计数器 sleep(Duration::from_secs(5)).await; }) .await { Ok(_) tracing::info!(所有任务已处理完毕), Err(_elapsed) tracing::warn!(等待超时强制退出可能有未完成的任务), } }这段代码里我最想强调的几点1.with_graceful_shutdown怎么工作的axum::serve(...).with_graceful_shutdown(...)在收到关闭信号后做两件事停止接收新的 TCP 连接。新的请求进不来了。等待所有已有连接处理完毕。已经在处理的请求不会被中断。这个机制和 Nginx 的 graceful shutdown 逻辑类似。它不会暴力中断而是不再接新活把手上的活干完。2. 慢任务的检查点设计看slow_handler函数我把一个长任务拆成了多个步骤每步之间检查shutdown_flag。这是一种协作式的关闭机制。// 协作式关闭任务主动检查标志自己决定何时安全退出 for step in 1..5 { if state.shutdown_flag.load(Ordering::Relaxed) { // 这里的中断是安全的因为任务知道自己处理到哪一步了 return 请求被中断; } // 继续处理... }协作式和抢占式的区别是这样的flowchart LR subgraph 协作式[协作式关闭推荐] A1[任务运行] -- A2{检查关闭标志} A2 --|否| A1 A2 --|是| A3[任务主动收尾] A3 -- A4[安全退出] end subgraph 抢占式[抢占式关闭危险] B1[任务运行中] -- B2[外部强制取消] B2 -- B3[资源未释放] B3 -- B4[数据丢失 / 连接残留] end style A4 fill:#c8e6c9 style B4 fill:#ffcdd23. 时间差的计算容器编排平台通常配置一个terminationGracePeriodSecondsK8s 默认 30 秒。你的程序必须在收到信号后的这个时间内退出。所以代码里的等待时间这里是 25 秒必须留有一些余量确保有足够时间做最后的资源清理。四、进阶用 JoinSet 精确跟踪所有任务上面的代码里等待任务完成的部分其实比较拍脑袋只能 sleep 一段时间然后假设任务都完成了。更好的做法是用tokio::task::JoinSet跟踪每个任务的生命周期。use tokio::task::JoinSet; /// 一个更精确的关闭管理器 /// 把所有 spawn 出去的任务都登记到 JoinSet 里 /// 关闭时等它们全部完成 struct ShutdownManager { tasks: JoinSet(), shutdown_flag: ArcAtomicBool, } impl ShutdownManager { fn new() - Self { Self { tasks: JoinSet::new(), shutdown_flag: Arc::new(AtomicBool::new(false)), } } /// 登记一个新任务 fn spawnF(mut self, task: F) where F: std::future::FutureOutput () Send static, { self.tasks.spawn(task); } /// 关闭通知所有任务然后等它们全部完成 async fn shutdown(mut self, timeout: Duration) - bool { tracing::info!(发送关闭信号给 {} 个活跃任务, self.tasks.len()); // 第一步设置关闭标志 self.shutdown_flag.store(true, Ordering::SeqCst); // 第二步等待所有任务完成带超时 let result tokio::time::timeout(timeout, async { // 逐个等待每个任务结束 while let Some(result) self.tasks.join_next().await { match result { Ok(()) tracing::debug!(一个任务正常完成), Err(e) tracing::error!(一个任务出错: {}, e), } } tracing::info!(所有已登记任务均已结束); }) .await; match result { Ok(()) true, // 全部正常完成 Err(_) { tracing::warn!( 超时仍有 {} 个任务未完成强制退出, self.tasks.len() ); // 注意JoinSet 在 drop 时会 abort 所有未完成的任务 // 所以需要确保 abort 不会导致数据损坏 false } } } }使用JoinSet的好处是可观测。你能清楚地知道还有多少任务在运行谁完成了谁超时了。这对接入监控和告警帮助很大。五、总结回顾从CtrlC 程序员到会处理信号的程序员这个过程几点心得SIGTERM 不是你按 CtrlC。它是一个协商信号不是终止命令。你的程序有权在收到信号后整理现场再退出。with_graceful_shutdown是好东西但只解决了不接新活的问题。已经在飞的任务如何处理需要你自己设计。协作式关闭优于抢占式。任务自己知道在哪个检查点退出最安全强杀容易出问题。JoinSet 让你对任务有掌控力。不知道有多少任务在跑就没有办法做出可靠的关闭决策。超时是必须的。不管你写得多么周全总可能有 bug 导致任务卡死。留一个兜底的超时机制这是对系统的责任。这些都是我在实际项目里踩过的坑分享出来希望对大家有帮助。我才 23 岁学编程时间不长有讲得不对的地方欢迎留言交流。