优雅停机
主线位置:服务端主线的终点。每条连接的
Connectionfuture 平时一直在「读请求 → 调服务 → 写响应 → 等下一个请求」里转;停机时要让每条连接做完手上的请求再结束,并且让整个服务器知道「所有连接都结束了」。前一半是 hyper 的graceful_shutdown,后一半是 hyper-util 的GracefulShutdown。
sequenceDiagram participant M as accept 循环 participant G as GracefulShutdown participant F as 连接任务 participant C as Connection M->>G: watch(conn) G-->>F: 包好的 future,持有 rx M->>F: tokio::spawn M->>G: shutdown() G->>F: tx.send,changed() 完成 F->>C: graceful_shutdown() C-->>F: 做完当前请求后 Ready F-->>G: drop rx G-->>M: tx.closed() 完成
一条连接上的 graceful_shutdown
单条连接怎么「优雅地」结束,hyper 已经提供了,hyper-util 只是去调它。三种连接各不相同:
| 连接 | graceful_shutdown 做什么 | 详见 |
|---|---|---|
http1::Connection | 关掉 keep-alive。空闲连接立刻关;正在处理的请求照常做完,响应带上 connection: close,然后结束 | 服务端 Builder 与 Connection |
http2::Connection | 调 h2 的 graceful_shutdown,发 GOAWAY;已经开始的流做完,连接再关 | HTTP/2 服务端 |
auto::Connection | 已经判断出协议的,转给上面两种之一;还在偷看前 24 个字节的,置 cancelled,下次 poll 返回 Cancelled 错误 | auto:一个端口两个协议 |
HTTP/2 这一行:hyper 的 http2::Connection::graceful_shutdown 转到 proto::h2::Server::graceful_shutdown,握手还没完成就记一个 close_pending,握手后再补发。h2 0.4.20 的 go_away_gracefully 照 RFC 7540 的建议做:先发一个最后流 ID 为 StreamId::MAX(2^31-1)、错误码 NO_ERROR 的 GOAWAY,再发一个 PING,注释说「We take the advice of waiting 1 RTT literally, and wait for a pong before proceeding.」,等到 PONG 才继续往下关。
三种都有一个共同点:调用只改状态、立刻返回,连接要继续被 poll 才会走到结束。HTTP/1 的忙连接要等服务返回响应、响应写完;这个时间 hyper 不管,由你的服务决定。
GracefulShutdown:一个 watch 通道
多条连接时,要有人通知每条连接,再等它们都结束。GracefulShutdown 只有一个字段:
pub struct GracefulShutdown {
tx: watch::Sender<()>,
}
tokio 的 watch 通道(见 oneshot、watch、broadcast、Notify)在这里用了两个能力:发送端 send 一次,所有接收端的 changed() 都完成,用来「通知」;发送端的 closed() 等到所有接收端都 drop,用来「等待」。值本身是 (),没有意义。
new 建通道时把接收端直接丢掉,只留发送端;每个接收端都由 watch 时 subscribe 出来。shutdown 就两步:
pub async fn shutdown(self) {
let Self { tx } = self;
// signal all the watched futures about the change
let _ = tx.send(());
// and then wait for all of them to complete
tx.closed().await;
}
它按值拿走 self,调用之后不能再 watch 新连接。count 返回 tx.receiver_count(),也就是还活着的接收端个数。
watch:给连接包一层
GracefulShutdown::watch 先 subscribe 一个接收端(经由 watcher()),再交给 Watcher::watch:
pub fn watch<C: GracefulConnection>(self, conn: C) -> impl Future<Output = C::Output> {
let Watcher { mut rx } = self;
GracefulConnectionFuture::new(conn, async move {
let _ = rx.changed().await;
// hold onto the rx until the watched future is completed
rx
})
}
cancel 这个 async 块等 changed(),完成后把 rx 原样返回。返回它而不是 drop 它,是为了让接收端活到连接结束,shutdown 里的 tx.closed() 才不会提前完成。GracefulConnectionFuture::poll:
fn poll(self: Pin<&mut Self>, cx: &mut task::Context<'_>) -> Poll<Self::Output> {
let mut this = self.project();
if this.cancelled_guard.is_none() {
if let Poll::Ready(guard) = this.cancel.poll(cx) {
this.cancelled_guard.set(Some(guard));
this.conn.as_mut().graceful_shutdown();
}
}
this.conn.poll(cx)
}
每次被 poll,先看信号到了没有。到了,就把返回的 rx 存进 cancelled_guard、调一次连接的 graceful_shutdown;之后 cancelled_guard 不再是 None,不会重复调用。不管有没有信号,都接着 poll 连接本身,所以 graceful_shutdown 之后同一次 poll 里连接就开始往结束走,前面说的「调完还要继续 poll」在这里自动满足。
连接结束时,任务返回,GracefulConnectionFuture 被 drop,里面的 rx(在 cancel 里或 cancelled_guard 里)跟着 drop。最后一个 rx 消失,tx.closed() 完成,shutdown() 返回。整个「等待所有连接」就是 watch 通道的接收端计数,没有额外的计数器或 JoinSet。
能 watch 什么
watch 要求连接实现 GracefulConnection。这个 trait 继承一个私有的 Sealed,外面实现不了,hyper-util 给了四个实现:hyper 的 http1::Connection、http2::Connection,hyper-util 的 auto::Connection、auto::UpgradeableConnection。
hyper 的 http1::UpgradeableConnection(.with_upgrades() 的返回值)不在其中。private 模块里给它写了 Sealed,GracefulConnection 却没有实现。拿它去 watch,rustc 1.99.0 报(截掉了后面列出的其余实现):
error[E0277]: the trait bound `UpgradeableConnection<TokioIo<TcpStream>, _>: GracefulConnection` is not satisfied
--> src/main.rs:25:31
|
25 | tokio::spawn(graceful.watch(conn));
| ----- ^^^^ the trait `GracefulConnection` is not implemented for `UpgradeableConnection<TokioIo<TcpStream>, ServiceFn<_, _>>`
| |
| required by a bound introduced by this call
只用 HTTP/1 又要支持升级,可以改用 auto::Builder::new(...).http1_only() 加 serve_connection_with_upgrades,不过 auto 一章实测过,升级入口下 http1_only 不起作用。也可以像下面 axum 那样自己写 select!。
Watcher:为什么不是 Clone
GracefulShutdown 不能 Clone,结构体上方有一行注释「Purposefully not Clone, see watcher() method for why.」。要在别的任务里 watch,用 watcher 先拿一个 Watcher,把它送过去:
// Internal: this function exists because `Clone` allows footguns.
// If the `tx` were cloned (or the `rx`), race conditions can happens where
// one task starting a shutdown is scheduled and interwined with a task
// starting to watch a connection, and the "watch version" is one behind.
pub fn watcher(&self) -> Watcher {
let rx = self.tx.subscribe();
Watcher { rx }
}
subscribe 出来的接收端把当前版本当作「已看过」。如果在 send 之后才 subscribe,这个接收端永远等不到 changed(),它包着的连接就收不到停机信号,而 shutdown() 还在等它。watcher(&self) 和 shutdown(self) 的签名排除了这种顺序:只要还能调 watcher,shutdown 就还没被调用;Watcher 在 shutdown 之前就已经 subscribe 好了,晚一点再 watch 也收得到信号。
Watcher 是代替 Clone 加进来的hyper-util 的 PR #136 想直接给 GracefulShutdown 实现 Clone,理由是「This allows users to use GracefulShutdown in other functions.」它没有被合并。取代它的是 #182:「This allows creating an owned type that can be passed to another task before watching the connection. I also included some of the "why" in code comments, why it cannot just be Clone.」上面 watcher() 前的那段注释和结构体上的「Purposefully not Clone」就是这个 PR 加的。第一个包含它的发布是 v0.1.12,CHANGELOG 写的是「Add server::graceful::Watcher type that can be sent to watch a connection in another task.」同一版还加了 count()。
实验:五条连接,一次 shutdown
一个 auto 服务端接受五条连接,都交给同一个 GracefulShutdown:
idle h1:HTTP/1 客户端做完一个请求,连接空闲在 keep-alive 里;busy h1:/slow正在处理,服务要睡 300 ms;idle h2:HTTP/2 客户端做完一个请求,连接空闲;silent:TCP 连上,一个字节都不发,停在ReadVersion;stream h1:/stream的响应 body 每 100 ms 发一块、永不结束,像 SSE。
然后调 shutdown(),最多等 1 秒。时间从调用 shutdown() 算起。
hyper = { version = "=1.12.0", features = ["client", "server", "http1", "http2"] }
hyper-util = { version = "=0.1.21", features = ["server-auto", "server-graceful", "tokio"] }
http-body-util = "=0.1.5"
bytes = "1"
futures = "0.3"
tokio = { version = "=1.54.1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
use std::convert::Infallible;
use std::time::{Duration, Instant};
use bytes::Bytes;
use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full, StreamBody};
use hyper::body::{Frame, Incoming};
use hyper::client::conn::{http1 as client_h1, http2 as client_h2};
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto;
use hyper_util::server::graceful::GracefulShutdown;
use tokio::net::{TcpListener, TcpStream};
type B = BoxBody<Bytes, Infallible>;
// milliseconds since shutdown() was called
static START: std::sync::OnceLock<Instant> = std::sync::OnceLock::new();
fn ms() -> u128 {
START.get().map(|t| t.elapsed().as_millis()).unwrap_or(0)
}
async fn handler(req: Request<Incoming>) -> Result<Response<B>, Infallible> {
match req.uri().path() {
"/slow" => {
tokio::time::sleep(Duration::from_millis(300)).await;
Ok(Response::new(Full::new(Bytes::from("slow done")).boxed()))
}
"/stream" => {
// never ends: one chunk every 100 ms, like SSE
let s = futures::stream::unfold(0u32, |n| async move {
tokio::time::sleep(Duration::from_millis(100)).await;
Some((Ok::<_, Infallible>(Frame::data(Bytes::from(format!("tick {n}\n")))), n + 1))
});
Ok(Response::new(StreamBody::new(s).boxed()))
}
_ => Ok(Response::new(Full::new(Bytes::from("ok")).boxed())),
}
}
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let graceful = GracefulShutdown::new();
let labels = ["idle h1", "busy h1", "idle h2", "silent", "stream h1"];
let (accepted_tx, mut accepted_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
// accept exactly five connections, then stop accepting
let accept = async {
let builder = auto::Builder::new(TokioExecutor::new());
for label in labels {
let (stream, _) = listener.accept().await.unwrap();
let conn = builder.serve_connection(TokioIo::new(stream), service_fn(handler));
let conn = graceful.watch(conn.into_owned());
tokio::spawn(async move {
let r = conn.await.map_err(|e| e.to_string());
println!("{:>5} ms server: {label:<9} ended {r:?}", ms());
});
accepted_tx.send(()).unwrap();
}
};
let clients = async {
// idle h1: one request, then keep the connection open
let io = TokioIo::new(TcpStream::connect(addr).await.unwrap());
let (mut idle1, conn) = client_h1::handshake::<_, Empty<Bytes>>(io).await.unwrap();
tokio::spawn(conn);
idle1.send_request(Request::get("/").body(Empty::new()).unwrap()).await.unwrap();
accepted_rx.recv().await;
// busy h1: /slow is in flight when shutdown starts
let io = TokioIo::new(TcpStream::connect(addr).await.unwrap());
let (mut busy, conn) = client_h1::handshake::<_, Empty<Bytes>>(io).await.unwrap();
tokio::spawn(conn);
let slow = tokio::spawn(async move {
let res = busy.send_request(Request::get("/slow").body(Empty::new()).unwrap()).await.unwrap();
let conn_hdr = res.headers().get("connection").cloned();
let body = res.into_body().collect().await.unwrap().to_bytes();
println!("{:>5} ms client: busy h1 got {body:?}, connection: {conn_hdr:?}", ms());
});
accepted_rx.recv().await;
// idle h2
let io = TokioIo::new(TcpStream::connect(addr).await.unwrap());
let (mut idle2, conn) = client_h2::handshake::<_, _, Empty<Bytes>>(TokioExecutor::new(), io).await.unwrap();
tokio::spawn(async move {
let r = conn.await;
println!("{:>5} ms client: idle h2 conn ended {r:?}", ms());
});
idle2.send_request(Request::get("http://x/").body(Empty::new()).unwrap()).await.unwrap();
accepted_rx.recv().await;
// silent: TCP connected, not a single byte sent
let silent = TcpStream::connect(addr).await.unwrap();
accepted_rx.recv().await;
// stream h1: response body never ends
let io = TokioIo::new(TcpStream::connect(addr).await.unwrap());
let (mut st, conn) = client_h1::handshake::<_, Empty<Bytes>>(io).await.unwrap();
tokio::spawn(conn);
let res = st.send_request(Request::get("/stream").body(Empty::new()).unwrap()).await.unwrap();
accepted_rx.recv().await;
let reader = tokio::spawn(async move {
let mut body = res.into_body();
let mut n = 0;
while let Some(Ok(_)) = body.frame().await {
n += 1;
}
println!("{:>5} ms client: stream h1 body ended after {n} chunks", ms());
});
(idle1, idle2, silent, slow, reader)
};
let ((), keep) = tokio::join!(accept, clients);
tokio::time::sleep(Duration::from_millis(50)).await;
println!("count() = {}, calling shutdown()", graceful.count());
START.set(Instant::now()).unwrap();
match tokio::time::timeout(Duration::from_secs(1), graceful.shutdown()).await {
Ok(()) => println!("{:>5} ms shutdown() returned", ms()),
Err(_) => println!("{:>5} ms shutdown() still waiting after 1 s, giving up", ms()),
}
drop(keep);
}
M2 Max、rustc 1.99.0、--release,其中一次的输出:
count() = 5, calling shutdown()
0 ms server: silent ended Err("Cancelled")
0 ms server: idle h1 ended Ok(())
0 ms client: idle h2 conn ended Ok(())
0 ms server: idle h2 ended Ok(())
249 ms server: busy h1 ended Ok(())
249 ms client: busy h1 got b"slow done", connection: Some("close")
1002 ms shutdown() still waiting after 1 s, giving up
连跑 3 次,结论相同;变化的只有 0 ms 那几行的先后(另两次 idle h1 排在 silent 前面)和末尾几个毫秒(248 或 249、1002 或 1003)。
count() = 5:五个被 watch 的连接,五个接收端。- 三条空闲连接 0 ms 结束。HTTP/1 的空闲连接直接关;HTTP/2 发 GOAWAY,没有在途的流,服务端和客户端两边都以
Ok(())结束;silent停在ReadVersion,被cancel后返回Cancelled错误,这是auto在这个状态下唯一能做的事。 busy h1约 250 ms 后结束。/slow照常做完(300 ms 减去调用shutdown前已经过去的时间),客户端拿到完整的slow done,响应头多了connection: close,然后连接关闭。在途请求没有被打断。stream h1一直不结束。它的graceful_shutdown已经调过了,但 HTTP/1 的语义是「做完当前响应再关」,而这个响应的 body 永远写不完。它的接收端一直活着,shutdown()就一直等,直到外面的timeout放弃。
ReadVersion 的连接曾经让停机永远挂住auto::Connection::graceful_shutdown 最初在 ReadVersion 状态什么都不做(ConnStateProj::ReadVersion { .. } => {})。hyper 的 issue #3576 报告 axum 的优雅停机示例按 Ctrl+C 后一直不退出,直到关掉 Chrome 才结束:「I found that when Google Chrome initiates a request, it creates two connections. After the request is completed, only one connection is closed, but the other connection remains active」。Chrome 预先开了一条连接却还没发字节,这条连接停在 ReadVersion,收不到停机信号。
hyper-util 的 #101(「This PR fixes graceful shutdown hanging when it's in the ReadVersion state due to open IOs.」)给 ReadVersion 加了 cancelled 字段和 cancel(),graceful_shutdown 在这个状态改为调 read_version.cancel(),于是有了上面实验里的 Err("Cancelled")。第一个包含它的发布是 v0.1.4。
shutdown 要配超时
实验里的 stream h1 代表一类连接:SSE、长轮询、大文件下载。它们的「当前请求」要很久才完,或者永远不完。升级成 WebSocket 之后的连接不在此列:UpgradeableConnection 在升级那一刻就以 Ok(()) 结束,被 watch 的 future 已经完成,shutdown() 既不通知它也不等它,它会在进程退出时被直接切断(实验见优雅停机一直不结束)。GracefulShutdown 没有超时参数,只能自己在外面套一层。hyper-util 仓库的示例 examples/server_graceful.rs 就是这么写的:
tokio::select! {
_ = graceful.shutdown() => {
eprintln!("Gracefully shutdown!");
},
_ = tokio::time::sleep(Duration::from_secs(10)) => {
eprintln!("Waited 10 seconds for graceful shutdown, aborting...");
}
}
超时之后,剩下的连接任务还在运行时里。示例接着从 main 返回,运行时关闭时 drop 掉所有任务,连接随之断开。不打算退出进程的话,要自己留着这些任务的句柄(JoinSet 或者 AbortHandle)去 abort 它们。
还有两件事要在 accept 循环里做:调 shutdown() 之前停止 accept(示例里 drop(listener) 再 break),否则新连接还会进来;以及每条新连接都要先 watch 再 spawn,漏掉的连接不在计数里,停机时既不会收到信号,也不会被等。
axum 的 with_graceful_shutdown
axum 0.8.9 的 serve(...).with_graceful_shutdown(signal)(axum/src/serve/mod.rs)没有用 GracefulShutdown,自己用两个 watch 通道实现同一件事:
signal_tx/signal_rx:一个任务等用户给的signal,完成后 drop 掉signal_rx。accept 循环在select!里等signal_tx.closed(),到了就停止 accept;每条连接的任务也在select!里等signal_tx.closed(),到了就调conn.as_mut().graceful_shutdown(),然后接着 poll 连接。close_tx/close_rx:每个连接任务持有一个close_rx的克隆,结束时 drop;accept 循环退出后 drop 自己的那份,再close_tx.closed().await等所有连接任务结束。
信号的方向和 hyper-util 相反(hyper-util 是发送端 send、接收端等 changed(),axum 是接收端 drop、发送端等 closed()),但「通知所有连接」和「等所有连接」都落在 watch 通道上,结构一样。axum 的连接用的是 auto::Builder::serve_connection_with_upgrades,所以前面那些行为都适用:ReadVersion 里的连接以 Cancelled 结束,SSE 之类的长连接让 with_graceful_shutdown 返回的 future 一直不完成。axum 也没有内置超时,同样要自己套。
shutdown(),直接 drop 掉 GracefulShutdown,空闲连接会怎样?一条 HTTP/1 连接做完一个请求后空闲着(客户端保留 SendRequest,连接不会因客户端而关)。100 ms 后 drop 掉 GracefulShutdown,不调 shutdown()。
use std::convert::Infallible;
use std::time::{Duration, Instant};
use bytes::Bytes;
use http_body_util::{Empty, Full};
use hyper::body::Incoming;
use hyper::client::conn::http1 as client_h1;
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use hyper_util::server::graceful::GracefulShutdown;
use tokio::net::{TcpListener, TcpStream};
async fn handler(_: Request<Incoming>) -> Result<Response<Full<Bytes>>, Infallible> {
Ok(Response::new(Full::new(Bytes::from("ok"))))
}
#[tokio::main]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let graceful = GracefulShutdown::new();
let client = tokio::spawn(async move {
let io = TokioIo::new(TcpStream::connect(addr).await.unwrap());
let (mut tx, conn) = client_h1::handshake::<_, Empty<Bytes>>(io).await.unwrap();
let conn = tokio::spawn(conn);
tx.send_request(Request::get("/").body(Empty::new()).unwrap()).await.unwrap();
// keep `tx` alive: the connection stays idle in keep-alive
let r = conn.await.unwrap();
drop(tx);
r
});
let (stream, _) = listener.accept().await.unwrap();
let conn = http1::Builder::new().serve_connection(TokioIo::new(stream), service_fn(handler));
let server = tokio::spawn(graceful.watch(conn));
tokio::time::sleep(Duration::from_millis(100)).await;
let t = Instant::now();
drop(graceful); // never call shutdown()
match tokio::time::timeout(Duration::from_secs(1), server).await {
Ok(r) => println!("server conn ended after {} ms: {:?}", t.elapsed().as_millis(), r.unwrap()),
Err(_) => println!("server conn still open after 1 s"),
}
match tokio::time::timeout(Duration::from_millis(100), client).await {
Ok(r) => println!("client conn ended: {:?}", r.unwrap()),
Err(_) => println!("client conn still open"),
}
}
依赖同上一个实验(这个程序不需要 server-auto)。服务端连接会在 1 秒内结束吗?
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
server conn ended after 0 ms: Ok(())
client conn ended: Ok(())
会,而且是立刻。Watcher::watch 里写的是 let _ = rx.changed().await;:发送端被 drop 时,changed() 返回 Err(tokio 卷 watch 一节的实验里那行 after drop(tx): changed() = Err(RecvError(()))),let _ 把错误丢掉,async 块照样完成,GracefulConnectionFuture 当作收到了信号,调 graceful_shutdown。空闲的 HTTP/1 连接于是立刻关闭,客户端的连接任务也以 Ok(()) 结束。
所以 drop 掉 GracefulShutdown 等于「通知所有连接停机,但不等它们」。如果你把它放在 accept 循环所在的函数里,函数因为别的原因提前返回(比如 ? 传出一个 accept 错误),所有连接都会开始停机。
小结
- 单条连接的
graceful_shutdown由 hyper 提供:HTTP/1 关 keep-alive,HTTP/2 发 GOAWAY;auto在ReadVersion状态里让连接以Cancelled结束(hyper-util #101,v0.1.4)。调用之后连接要继续被 poll。 GracefulShutdown只有一个watch::Sender<()>:watch给每条连接subscribe一个接收端,shutdown先send再等closed(),即等所有接收端被 drop;count()就是接收端个数。GracefulConnectionFuture每次 poll 先看信号,到了就调一次graceful_shutdown并把rx留到连接结束,再 poll 连接。- 能 watch 的只有
http1::Connection、http2::Connection、auto::Connection、auto::UpgradeableConnection;hyper 的http1::UpgradeableConnection不行。 - 不能
Clone,跨任务用watcher(),避免在send之后才subscribe(hyper-util #182,v0.1.12)。 - 长连接让
shutdown()永远不返回,必须自己套超时;drop 掉GracefulShutdown也会触发所有连接停机,只是不等。axum 的with_graceful_shutdown是同样的 watch 通道结构,同样没有超时。