Rust in Everything
基于 hyper 1.12.0

取舍汇总

主线位置:两条主线的总账。前三条取舍决定主线的起点和边界(运行时从哪来、accept 循环和连接池归谁、服务怎么被调用),中间两条在服务端主线的「读 → 调用 → 写」里(请求怎么排队、body 怎么交接),后两条落在客户端主线的出错路径和两条主线共用的配置上。
flowchart TB
  d1[不依赖运行时] --> c1["没设 timer 只在运行时暴露"]
  c1 --> f1["hyper-util rt::tokio"]
  d2["移出 Server 和 Client"] --> c2[accept 循环自己写]
  c2 --> f2["hyper-util / axum"]
  d3["Service 去掉 poll_ready"] --> c3["与 tower 不兼容"]
  c3 --> f3[TowerToHyperService]
  d4["HTTP/1 一连接一 future"] --> c4[连接内没有并发]
  c4 --> f4["HTTP/2 一流一任务"]
  d5[body 通道一个槽] --> c5[每块一次交接]
  c5 --> f5[自适应读大小]
  d6[只重发没写出的] --> c6[IncompleteMessage]
  d7[默认值不稳定] --> c7[升级可能改行为]

每一节的格式相同:做了什么决定,换来了什么,代价是什么,代价由谁来补。机制都在别的页讲过,这里只给结论和链接;引到的数字都来自那些页的实验,最后一节的实验是本页自己跑的。

不依赖运行时

决定:hyper 的公开 API 里没有 tokio。IO 是 rt::Read / rt::Write,spawn 是 Executor,计时是 Timer;都由使用者在 Builder 上交进来,timer 可以不交。

换来的:

  • 可以接别的运行时,以后还能接完成式 IO。rt/io.rs 开头的注释把后者说成全部理由:「Frankly, the last point is the entire reason we're doing this. We want to have forwards-compatibility with an eventually stable io-uring runtime.」
  • 不再有 0.14 那种「隐式依赖 tokio 计时器」的配置。路线图 「Runtime woes」 抱怨 runtime 特性「isn't additive」,超时和 keep-alive 选项「implicitly relies on Tokio's timer context. This can be quite confusing.」(见源码地图的冷知识)

代价:

  • 多一层适配。每个 tokio 的 socket 要包一层 TokioIo;HTTP/2 里 h2 0.4.20 仍然要 tokio 的 IO trait,hyper 内部再用 Compat 转回去(见 IO trait)。Timer 是 trait 对象,每次 sleep 返回一个 Pin<Box<dyn Sleep>>。
  • 「没给 timer」成了只在运行时才暴露的配置错误,而且三种表现各不相同:HTTP/2 keep-alive 走到 sleep 时 panic You must supply a timer.;HTTP/1 显式设了 header_read_timeout 在 serve_connection 时 panic;HTTP/1 默认的 30 秒超时静默失效,那条 warn! 只在 --cfg hyper_unstable_tracing 下存在。三种都在 Executor 与 Timer 里跑过:默认配置、没 timer 的连接 35 秒后还开着。
  • rt 的 trait 并没有让 hyper 彻底离开 tokio:Cargo.toml 里还有只开 sync 的 tokio,客户端请求通道和升级用它的 mpsc / oneshot(见 IO trait)。「不依赖运行时」指的是不依赖 reactor、调度器和计时器。

谁来补:hyper-util 的 rt::tokio(TokioIo、TokioExecutor、TokioTimer)把三样都接上。但 timer 要不要交,仍由组装连接的那个人决定:hyper-util 的 auto::Builder::new 不设,axum::serve 也不设,见本页最后一节。

1.0 把 Server、Client 和连接池移出核心

决定:hyper 只留连接级的 server::conn 和 client::conn。路线图 「Focus on the Connection level」:「the higher-level Client and Server have stability and complexity problems. Therefore, for hyper 1.0, the main API will focus on the "lower-level" connection types. The Client and Server helpers will be moved to hyper-util.」(#2932、#2941,见 hyper 解决什么问题)

换来的:稳定的 API 面最小。路线图列出的问题,Accept trait「too easy to get wrong」、MakeService<&IO>「The bounds are an assault on the eyes」、Connect 设计「isn't stable」,都不再是 hyper 1.x 的 semver 承诺,可以在 hyper-util 的 0.1 里继续改。

代价:

  • 起一个服务要自己写 accept 循环,每条连接 spawn 一个任务;连接数上限、TLS 握手放在哪个任务里、停机时等谁,都要自己决定。hyper 不 accept,也就不替你限制连接数。
  • 接手的 hyper-util 还是 0.1。路线图的 FAQ 明说它「explicitly less stable」,建议「not publicly expose your dependency on hyper-util」。
  • 配置散在两三层里。auto::Builder 逐个转发子 Builder 的选项,0.1.21 漏了 max_header_size(见 auto);axum::serve 在每条连接的任务里自己 Builder::new(TokioExecutor::new()),一个 hyper 选项都不暴露(axum 0.8.9 src/serve/mod.rs)。

谁来补:hyper-util 的 auto、graceful、legacy Client 和 连接池;再往上是 axum 和 reqwest。用得越高层越省事,能调的 hyper 选项也越少。

Service 去掉 poll_ready,call 改取 &self

决定:hyper 自己的 Service 只有 call(&self)。路线图在未决问题里写:「It's not clear that the backpressure is something needed at the Server boundary, thus meaning we should remove poll_ready from hyper.」(#2920);&self 来自 #3223,理由在源码注释里:「It prepares the way for async fn」「It's clearer that Services can likely be cloned.」

换来的:

  • 调用方不用守 poll_ready 的规则,call 随时可调。HTTP/2 的连接任务每收到一个流就同步调一次 call,中间不需要等就绪。
  • &self 让服务可以被共享调用,源码注释的说法是「a Service can concurrently handle multiple outstanding requests at once」,并为将来的 async fn 铺路。
  • hyper 不必跟着 tower 的 Service 走。路线图同一处提到 tower 当时自己也没定下「if poll_ready is the best way to handle backpressure」。

代价:

  • 服务边界上没有背压。hyper 只在两处限流:同一条 HTTP/1 连接上靠 Conn 的读写状态串行(Dispatch 的 Server::poll_ready 只是另一道保险,见 Dispatcher),以及你的 accept 循环接多少连接。
  • 和 tower 的 Service 不是同一个 trait,axum 的 Router 交给 hyper 之前要转换。
  • &self 的反方理由也写在 #3040 里:有每连接状态的服务「Not being &mut would require internal synchronization.」

谁来补:hyper-util 的 TowerToHyperService,每个请求把 tower 服务克隆一份,交给 Oneshot 先 poll_ready 再 call(见 Service 与 Body)。tower 的背压因此还在,只是挪进了响应 future:请求已经读完、解析完,才轮到 poll_ready。

📝NOTE
没有蠢问题

问: hyper 不看 poll_ready,那用 ServiceBuilder 包在整个服务外面、再经 TowerToHyperService 交给 hyper 的 ConcurrencyLimit,还能限住并发吗?

答: 能,限的是「同时在处理函数里的请求数」。TowerToHyperService::call 克隆服务,ConcurrencyLimit 的 Clone 共享同一个信号量、许可从「未取得」开始,Oneshot 在响应 future 里等许可。限不住的是读:许可不够时,HTTP/1 连接已经读完这个请求头,HTTP/2 的流也已经收下,只是 future 停在 poll_ready。要在读之前挡住,得在 accept 循环里限连接数,或者交给前面的代理。克隆后的副本不继承就绪状态这件事,tower 卷的在本站中:中间件栈讲过。

HTTP/1 一条连接一个 future,HTTP/2 一个流一个任务

决定:HTTP/1 连接上的所有请求都在 Connection 这一个 future 里处理,http1::Builder 连执行器字段都没有;HTTP/2 的服务端每收到一个流,就把 service.call 返回的 future 包成 H2Stream 交给执行器(见 Executor 与 Timer 的 execute 调用表)。

换来的:

  • HTTP/1 没有任务切换、没有锁,读、调用、写都在同一个任务里。http1::Builder::serve_connection 的约束里没有 Send,要不要 Send 取决于你怎么跑这个 Connection。
  • HTTP/2 一条连接上并发:HTTP/2 服务端的实验里三个各睡 200ms 的请求一共约 200ms。

代价:

  • HTTP/1 连接内没有并发。Dispatcher 的实验里同样三个请求用 pipelining 一次写过去,/b 要等 /a 的响应写完才被调用,总共约 600ms。一个慢请求拖住同一条连接上后面的所有请求(见常见坑)。
  • HTTP/1 不能单独取消一个请求。lib.rs 的「Cancel safety」:drop 一个在途的请求 future「closes the underlying TCP connection」(见 hyper 解决什么问题)。这是协议没有请求 ID 决定的,hyper 选了关连接而不是读完扔掉。
  • HTTP/2 要一个执行器,future 要满足执行器的约束,用 TokioExecutor 就得是 Send + 'static。约束写成 sealed 的 Http2ServerConnExec,用户只能通过实现 Executor 得到它。
  • poll_loop 自己数圈:每次 poll 最多转 16 圈再让出,因为 hyper 不能假定运行时有协作预算。

谁来补:要连接内并发就用 HTTP/2;auto 让一个端口两种都接。HTTP/1 的串行同时也是背压:下一个请求头不被读,内存就不涨。

body 通道只有一个槽

决定:HTTP/1 的 Incoming 背后是 body/chan.rs 里只有一个数据槽的通道;Dispatcher 先问槽空不空,再从 socket 读(见 Incoming 与 body 通道)。

换来的:内存有界。服务端从 socket 读出的 body 最多比处理函数多一块;处理函数不读,连接就不读,TCP 窗口把压力推回给客户端。

代价:

  • 每一块都要一次交接(存进槽、唤醒对端)。1.12.0 的 #4168 把原来的几个独立通道合成一个 Arc,PR 给的微基准是一次数据交接从 229 ns 降到 147–155 ns。
  • 处理函数 drop 了没读完的 body,hyper 只再解码一步:读完了连接复用,读不完就关连接。body 较大、处理函数又不读,客户端的这条连接就没法复用(客户端一侧的同类问题见常见坑)。

谁来补:块的大小不固定,随读写缓冲的自适应读策略从 8 KiB 翻倍增长。Incoming 那页的实验里 256 KiB 的 body 只交接了 6 次。HTTP/2 不用这个通道,背压靠 h2 的流控窗口。

legacy Client 只重发没写出的请求

决定:retry_canceled_requests 默认开,但只重发 hyper 原样还回来的请求,并且只在从池里复用来的连接上。Builder 文档:「retry requests that get disrupted before ever starting to write」(见 legacy Client)。

换来的:不会重复发送。请求只要开始往 socket 里写,就可能已经被服务端执行,hyper-util 不替你判断它是否幂等,一律不重发。

代价:重试只覆盖很窄的一段时间。legacy Client 那页模拟服务端关掉空闲连接,每种设置 200 轮,开着重试的三次运行里仍有 167、195、194 次以 IncompleteMessage 失败。报错原文是「connection closed before message completed」(见常见坑)。

谁来补:调用方。要么让客户端池的空闲超时短于服务端的 keep-alive 超时,要么对幂等请求自己重试,比如在 tower 一侧加 Retry。连接池的 idle_timeout 在取出时总会检查,不需要 timer(见连接池)。

默认值不算稳定

决定:lib.rs 的「Stability」一节把默认值排除在 semver 之外:「Behavior from default options is not stable. hyper reserves the right to add new options that are enabled by default which might alter the behavior, for the purposes of protection.」四个连接级 Builder 的文档开头也都有一句「The default values of options are not considered stable.」

换来的:能在小版本里收紧不安全的默认值,保护那些从不调选项的用户。HTTP/1 的 header_read_timeout 就是在 1.0.0 改成默认 30 秒的(提交 8bf26d1e,见 Executor 与 Timer 的冷知识)。

代价:升级小版本可能改变行为。依赖默认值的程序,行为由 hyper 的版本决定,不由你的代码决定;而且默认值是在 hyper-util、axum 这些中间层里被间接选定的。

谁来补:使用者自己把关心的选项显式写出来。hyper 的提交信息也这么建议,见下面的冷知识。反过来,新加的保护性选项不一定默认打开:1.12.0 新加的 max_header_size 默认未设,仍由 max_buf_size 兜底(见服务端 Builder 与 Connection)。

📝NOTE
冷知识:v1.3.0 一次收紧了两个 HTTP/2 默认值

#3622(「Default HTTP/2 max_header_list_size to 16kb」)的说明是:「The RFC doesn't specify a default. h2 has an "emergency" default that's quite high. It makes sense for hyper to pick a safer default to protect users who don't think about it.」提交信息补了两句:之前 hyper「still set a high limit of 16mb」;以及「The defaults in hyper are not part of the public API stability promise. Users are encouraged to set options themselves.」

同一版里的 #3538 把客户端的 initial_max_send_streams 从 usize::MAX(h2 的默认)改成 100,理由是 nghttp2 和 Go 的 x/net/http2 都用这个值。两个改动第一次出现在 v1.3.0,CHANGELOG 把它们列在「Bug Fixes」下,而不是破坏性变更。今天 proto/h2 的服务端和客户端里,这个默认值都写成 1024 * 16; // 16kb,客户端的 DEFAULT_INITIAL_MAX_SEND_STREAMS 是 100。

两个取舍叠在一起:axum::serve 下的慢客户端

第一条取舍让 timer 成了可选项,没给时默认的 30 秒读头超时静默失效。第二条取舍把「一个端口两个协议」交给了 hyper-util 的 auto,它在交给 hyper 之前先读最多 24 个字节判断协议,这一步的 ReadVersion::poll 里只有 poll_read,没有计时器,header_read_timeout 也管不到(见 auto)。

axum::serve 把两条叠在了一起:每条连接 auto::Builder::new(TokioExecutor::new()),不设 timer,走 serve_connection_with_upgrades,而且不暴露任何 hyper 选项。下面的程序比较两个服务端:一个是 axum::serve;另一个是手写的同样的 accept 循环,只多了一行 builder.http1().timer(TokioTimer::new())。每边四个客户端:连上什么都不发;发半个请求头;发一个完整请求,收到响应后空闲;发 HTTP/2 前言的前 16 个字节。最多等 35 秒。

axum = "=0.8.9"
hyper-util = { version = "=0.1.21", features = ["tokio", "server-auto", "service"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread", "net", "io-util", "time"] }
futures = "0.3"
use std::net::SocketAddr;
use std::time::{Duration, Instant};

use axum::{routing::get, Router};
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use hyper_util::server::conn::auto;
use hyper_util::service::TowerToHyperService;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};

fn app() -> Router {
  Router::new().route("/", get(|| async { "ok" }))
}

// axum::serve: auto::Builder::new(TokioExecutor::new()), no timer
async fn axum_server() -> SocketAddr {
  let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
  let addr = listener.local_addr().unwrap();
  tokio::spawn(async move { axum::serve(listener, app()).await.unwrap() });
  addr
}

// same accept loop by hand, but HTTP/1 gets a TokioTimer
async fn timer_server() -> SocketAddr {
  let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
  let addr = listener.local_addr().unwrap();
  tokio::spawn(async move {
    loop {
      let (stream, _) = listener.accept().await.unwrap();
      tokio::spawn(async move {
        let mut builder = auto::Builder::new(TokioExecutor::new());
        builder.http1().timer(TokioTimer::new());
        let svc = TowerToHyperService::new(app());
        let _ = builder.serve_connection_with_upgrades(TokioIo::new(stream), svc).await;
      });
    }
  });
  addr
}

async fn client(server: &str, addr: SocketAddr, name: &str, send: &'static [u8]) -> String {
  let start = Instant::now();
  let mut tcp = TcpStream::connect(addr).await.unwrap();
  tcp.write_all(send).await.unwrap();
  let mut got = Vec::new();
  let mut buf = [0u8; 1024];
  let end = loop {
    let left = Duration::from_secs(35).saturating_sub(start.elapsed());
    match tokio::time::timeout(left, tcp.read(&mut buf)).await {
      Ok(Ok(0)) => break format!("closed at {:.1}s", start.elapsed().as_secs_f64()),
      Ok(Ok(n)) => got.extend_from_slice(&buf[..n]),
      Ok(Err(e)) => break format!("{e} at {:.1}s", start.elapsed().as_secs_f64()),
      Err(_) => break "still open at 35s".to_string(),
    }
  };
  format!("{server:<6} {name:<16} {end:<18} got {} bytes", got.len())
}

#[tokio::main]
async fn main() {
  const NOTHING: &[u8] = b"";
  const HALF_HEAD: &[u8] = b"GET / HTTP/1.1\r\nHost: x\r\n";
  const IDLE: &[u8] = b"GET / HTTP/1.1\r\nHost: x\r\n\r\n";
  const HALF_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n";

  let a = axum_server().await;
  let t = timer_server().await;
  let results = futures::future::join_all([
    client("axum", a, "nothing", NOTHING),
    client("axum", a, "half head", HALF_HEAD),
    client("axum", a, "1 req then idle", IDLE),
    client("axum", a, "half preface", HALF_PREFACE),
    client("timer", t, "nothing", NOTHING),
    client("timer", t, "half head", HALF_HEAD),
    client("timer", t, "1 req then idle", IDLE),
    client("timer", t, "half preface", HALF_PREFACE),
  ])
  .await;
  for r in results {
    println!("{r}");
  }
}

M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:

axum   nothing          still open at 35s  got 0 bytes
axum   half head        still open at 35s  got 0 bytes
axum   1 req then idle  still open at 35s  got 118 bytes
axum   half preface     still open at 35s  got 0 bytes
timer  nothing          still open at 35s  got 0 bytes
timer  half head        closed at 30.0s    got 0 bytes
timer  1 req then idle  closed at 30.0s    got 118 bytes
timer  half preface     still open at 35s  got 0 bytes
  • axum 一侧四条全开着。半个请求头和空闲的 keep-alive 连接本该被 30 秒的默认超时关掉,没有 timer,超时根本没设。连上不发字节和半个前言则停在 ReadVersion,还没进 hyper。
  • 补上 timer 只救回两条。半个请求头和空闲连接在 30.0 秒被关掉:G 一个字节就让 ReadVersion 判成 HTTP/1,之后归 hyper 的 header_read_timeout 管,它同时也是 keep-alive 连接的空闲上限。另外两条仍然开着:连接停在 ReadVersion,http1().timer(...) 管不到那里。

所以只改一边不够。timer 补的是第一条取舍的代价;ReadVersion 没有超时是第二条取舍的代价,hyper-util 0.1.21 没有给这一步提供时限,要么自己在外面限时,要么让前面的代理挡住慢连接。不需要明文 HTTP/2 时,http1_only 能跳过这一步,但它在 serve_connection_with_upgrades 下不生效(见 auto)。用 axum::serve 时这些都改不了,只能自己写 accept 循环。这个坑的完整复现和修法见常见坑:header_read_timeout 不生效,本站的实际情况(生产环境前面有 Pingora 网关)见在本站中:服务端路径。

客户端主线上也有同样两条叠在一起的情况:「只重发没写出的请求」加上「服务端的 keep-alive 超时比客户端池的 idle_timeout 短」,就是 IncompleteMessage。

小结

  • 不依赖运行时,换来接别的运行时和完成式 IO 的余地;代价是 TokioIo 这层适配,以及「没设 timer」在运行时才暴露,HTTP/1 的默认超时还是静默失效。
  • 1.0 移出 Server / Client,稳定 API 面最小;accept 循环、连接池交给 hyper-util 和 axum,选项也被它们间接选定。
  • Service 去掉 poll_ready、改取 &self;与 tower 不兼容,由 TowerToHyperService 每请求克隆加 Oneshot 补上,背压挪进响应 future。
  • HTTP/1 一条连接一个 future,没有任务切换,代价是连接内串行、取消一个请求就关连接;HTTP/2 一个流一个任务换来并发,代价是执行器和 Send。
  • body 通道一个槽,内存有界;交接次数靠自适应读大小压下来,没读完的 body 会让连接关闭。
  • legacy Client 只重发没写出的请求,不会重复执行,代价是服务端关空闲连接时多数请求以 IncompleteMessage 失败。
  • 默认值不算 semver 的一部分,hyper 可以为安全收紧它们;关心的选项要自己写出来。
  • axum::serve 下慢客户端一直挂着,是「timer 可选」和「ReadVersion 没有超时」叠出来的:补 timer 只救回进了 hyper 的那两种连接。

💬 关联讨论

发起讨论
加载中...