Rust in Everything
基于 hyper-util 0.1.21

优雅停机

主线位置:服务端主线的终点。每条连接的 Connection future 平时一直在「读请求 → 调服务 → 写响应 → 等下一个请求」里转;停机时要让每条连接做完手上的请求再结束,并且让整个服务器知道「所有连接都结束了」。前一半是 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 也收得到信号。

📝NOTE
冷知识: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 放弃。
📝NOTE
冷知识:停在 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 也没有内置超时,同样要自己套。

❗IMPORTANT
动脑筋:不调 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 通道结构,同样没有超时。

💬 关联讨论

发起讨论
加载中...