Hyper 与 Tower:工业级 HTTP 栈
第14章 keep-alive、半关闭与超时矩阵
14.1 连接的"生存线"
前面两章我们把 HTTP/1 的 wire format 和 Dispatcher 状态机读完了。在理想情况下一切都很干净——客户端发请求、服务端回响应、Dispatcher 循环推动一切。
但真实世界没那么干净。真实世界里:
- 客户端发了一半的 header 就挂了——TCP 不会主动告诉你,数据永远不来。
- 客户端发了
Connection: close但随即又发了一个新请求——你应该按协议关连接,但请求里的数据要不要处理? - NAT 中间件 30 分钟没看到流量就静默丢连接——你的 keep-alive 连接看起来正常但下次 write 就
ECONNRESET。 - 客户端慢到每秒只发 1 个字节(slow loris 攻击)——TCP 看来是"正常连接",但永远读不完 header。
- 服务器刚发完 response 想复用连接,结果客户端的 socket 已经在对方那边 close 了。
这些边界在 HTTP 协议规范里有零碎的描述,但把它们转成高效、正确、不让生产环境崩的代码,靠的是工业级 HTTP 库的累积经验。Hyper 通过 http1::Builder 暴露的一组配置项,就是这些经验的结晶:
// hyper-1.9.0/src/server/conn/http1.rs:71-84
pub struct Builder {
h1_parser_config: httparse::ParserConfig,
timer: Time,
h1_half_close: bool,
h1_keep_alive: bool,
h1_title_case_headers: bool,
h1_preserve_header_case: bool,
h1_max_headers: Option<usize>,
h1_header_read_timeout: Dur,
h1_writev: Option<bool>,
max_buf_size: Option<usize>,
pipeline_flush: bool,
date_header: bool,
}
这一章我们把每个配置背后的"防什么、怎么防、代价是什么"讲清楚。这些不是"锦上添花"——这是你上线 hyper-based 服务必须理解的生产基线。
14.1.1 Builder::new() 的 12 个字段缺省值
打开 hyper-1.9.0/src/server/conn/http1.rs:234-247、看 Builder::new 里的缺省配置:
Self {
h1_parser_config: Default::default(),
timer: Time::Empty,
h1_half_close: false,
h1_keep_alive: true,
h1_title_case_headers: false,
h1_preserve_header_case: false,
h1_max_headers: None,
h1_header_read_timeout: Dur::Default(Some(Duration::from_secs(30))),
h1_writev: None,
max_buf_size: None,
pipeline_flush: false,
date_header: true,
}
这 12 个字段的缺省值构成了 hyper 的工程哲学心电图——每一个值背后都有一个"为什么是这个而不是那个"的推理:
h1_keep_alive: true——符合 HTTP/1.1 规范。关掉 keep-alive 是可选项、开是默认。h1_half_close: false——EOF 就是连接终止、只有少数基础设施需要例外。h1_title_case_headers: false——header name 默认小写、符合 HTTP/2 规范(虽然 HTTP/1 不强制)、不折腾。h1_preserve_header_case: false——不保留原始 case、省掉额外记录(开启后每个请求要多建一张记录原始大小写的HeaderCaseMap,hyper-1.9.0/src/proto/h1/role.rs:234)。h1_max_headers: None——None而不是Some(100)的原因是 §14.5.1 讲的——None 走smallvec_inline!的常量栈数组(100 个槽);Some(n)走运行时构造的smallvec![..; n],文档标注这条路径约有 5% 的性能下降。语义上相同的数字、走的是两条不同的构造路径。h1_header_read_timeout: 30 秒——直接写死一个数、不留 None。这是对 Slowloris 的默认防御——但只在装了Timer时生效(§14.8.2)。h1_writev: None——auto 模式、运行时探测。max_buf_size: None——走默认的 ~400KB。Some 才是用户覆盖。pipeline_flush: false——实验性功能默认关。date_header: true——RFC 7231(现 RFC 9110 §6.6.1)要求有时钟的 origin server 带Date、所以默认开。
这种"大部分保守、只在必要处主动防御"的缺省组合、就是 hyper 能在"开箱即用能跑、深度配置也行"两种用户间平衡的秘密。真实的生产事故教训就压缩在这 12 行里。
14.1.2 Dur 类型的两态表达:Default / Configured(外加 Time::Empty)
h1_header_read_timeout 的类型不是 Option<Duration>、而是 Dur——一个自定义的两态枚举。看 hyper 内部定义:
// hyper-1.9.0/src/common/time.rs:21-24(略去 cfg / derive 属性,行尾注释为笔者所加)
pub(crate) enum Dur {
Default(Option<Duration>), // 框架默认值
Configured(Option<Duration>), // 用户显式设置
}
再配上 timer: Time 字段的 Time::Empty(没设 timer)/ Time::Timer(..),一共能区分出四种组合。
为什么不直接用 Option<Duration>?——因为需要区分:
- 用户未显式配置(使用框架默认 30 秒)——
Dur::Default(Some(30s)) - 用户显式 set None(明确关闭 timeout)——
Dur::Configured(None) - 用户显式 set 30s——
Dur::Configured(Some(30s))
Option<Duration> 表达不出 1 和 3 的区别——两者都是 Some(30s),可没装 timer 时,框架默认值应该悄悄作罢,用户亲手配的值却不该被悄悄吞掉。
Dur 两态让缺 timer 时的处理策略清晰:Default 是框架替你填的,缺 timer 就放弃;Configured 是你明确要的,缺 timer 就报错;显式 Configured(None) 则是你明确不要 timeout,缺不缺 timer 都不报错。这在 check 方法里有精确的实现:
// hyper-1.9.0/src/server/conn/http1.rs:469-475 的调用点
self.timer.check(self.h1_header_read_timeout, "header_read_timeout")
check 内部同时看 timer 是否存在、Dur 是什么状态、返回最终生效的 Duration 或 panic(Default(Some) + 没 timer 只 warn 并返回 None、Configured(Some) + 没 timer 才 panic,hyper-1.9.0/src/common/time.rs:70-85)。两态 + 专用 check是 hyper 应对"配置组合爆炸"的工程办法——用类型系统把每种组合的正确行为钉住。
14.2 Keep-Alive:协议 + 工程的重叠
14.2.1 协议层:什么时候允许 keep-alive
HTTP/1.1 规定:默认 keep-alive(连接默认复用)。HTTP/1.0 规定:默认每请求一连接(除非 Connection: keep-alive 明确开启)。
判断一条连接是否应该 keep-alive,hyper 在 role.rs 里做了大量工作:
// 逻辑简化版
let mut keep_alive = version == Version::HTTP_11; // HTTP/1.1 默认 true
if let Some(conn_hdr) = headers.get(CONNECTION) {
if conn_hdr.contains("close") {
keep_alive = false;
} else if conn_hdr.contains("keep-alive") {
keep_alive = true; // HTTP/1.0 显式开启
}
}
这些决策在 Server / Client 的 parse 函数里做,结果写进 Conn::state::keep_alive: KA——我们在第 12 章讨论过 KA::Busy / Idle / Disabled 三态,以及 &= false 如何永久 disable。
14.2.2 工程层:h1_keep_alive 开关
Builder 上有一个粗暴的开关:
// hyper-1.9.0/src/server/conn/http1.rs:265-268
pub fn keep_alive(&mut self, val: bool) -> &mut Self {
self.h1_keep_alive = val;
self
}
默认 true——符合 HTTP/1.1 协议。设为 false 时,serve_connection 会在构造 Conn 后立刻 conn.disable_keep_alive()——整个连接一开始就设为 Disabled,一次请求完就关。
什么时候设为 false?三种场景:
- 短命代理:反向代理把请求转发给 upstream,一次就完。Hyper 接客户端如果是 short-lived 场景,也许关掉 keep-alive 更简单。
- 调试:排查 connection-reuse 相关的 bug 时,关掉 keep-alive 让每次请求走新连接,排查条件更干净。
- 资源受限:每条连接有 struct 开销、有 buffer、还被 tokio 计数——如果你的业务每连接只做一次请求,关 keep-alive 避免无谓的 idle 连接占资源。
绝大多数生产服务保持默认 true。
14.2.3 Conn::new 和 disable_keep_alive 的初始化时序
看 serve_connection 里第一步做了什么:
let mut conn = proto::Conn::new(io);
...
if !self.h1_keep_alive {
conn.disable_keep_alive();
}
Conn::new(io) 先创建一个新连接对象、默认 KA::Busy(允许 keep-alive,hyper-1.9.0/src/proto/h1/conn.rs:57)——这是 HTTP/1.1 的规范行为。然后在用户关了 keep_alive 时、调 disable_keep_alive() 把 KA 变成 Disabled。
为什么不能在 Conn::new 里直接传参数控制 KA?——因为 Conn 是 hyper 内部的核心状态机、客户端 Conn 和服务端 Conn 共用一份代码(第 12 章讲过)。两者在 keep_alive 默认上有微妙差异:
- 服务端:默认允许、除非配置关
- 客户端:默认允许、除非对端
Connection: close或者本地配置
如果 Conn::new(io, config) 接受一个 config 参数、服务端和客户端都要定义自己的 config type、外加给每个字段挑默认值——API 复杂度飙升。Conn::new 保持极简(只接 io)、所有配置都通过后续 setter 调(set_timer / disable_keep_alive / set_max_buf_size)、客户端 / 服务端各自按照自己的语义装配——代码量小、语义清晰。
这种"构造函数极简 + 多个 setter"的模式(而不是"单个接受所有参数的构造函数")对于有很多可选配置的类型非常合适。注意 Conn 的这些 setter 返回 (),不能链式调用(hyper-1.9.0/src/proto/h1/conn.rs:92-131);对外的 http1::Builder 才是返回 &mut Self 的链式写法。alternative 是用 Default::default() + struct update syntax——但那要求 Conn 能先造出一个完整的默认值,而它持有的 io 根本没有默认值可言。
14.2.4 graceful_shutdown:让当前请求跑完再关
// hyper-1.9.0/src/server/conn/http1.rs:137-139
pub fn graceful_shutdown(mut self: Pin<&mut Self>) {
self.conn.disable_keep_alive();
}
graceful_shutdown 的实现只有一行——把连接 KA 标成 Disabled。这意味着:
- 当前正在处理的请求继续处理(若调用时连接正空闲、没有请求在途,则直接关闭,见下文
Conn::disable_keep_alive)。 - response 写完后,try_keep_alive 发现 KA 是 Disabled——不再 reset 状态机,走
Closed路径。 poll_shutdown被调用,TCP 半关闭(发 FIN)。- Dispatcher 结束。
这是部署场景的基石——滚动发布 / rolling upgrade 里你需要"让 pod 不再接新请求、正在处理的跑完、然后退出"。通过:
- 收到 SIGTERM。
- 把 server 的 accept 关掉(不再接新 TCP 连接)。
- 对所有 live connection 调
graceful_shutdown。 - 等所有 connection future 结束(调用后仍要继续 poll 这个
Connection,关闭才能走完,hyper-1.9.0/src/server/conn/http1.rs:127-136 的文档注释)。 - 进程退出。
graceful_shutdown 的实现简单到让人惊讶——一行代码——但它给了上层极强的生命周期控制能力。第 3、4 步不必手写:hyper-util 的 hyper_util::server::graceful::GracefulShutdown(feature server-graceful)用 watch(conn) 包住每条连接,shutdown().await 一次性通知所有被包住的连接调 graceful_shutdown、并等它们全部结束(hyper-util 仓库 src/server/graceful.rs:42-72)。
14.2.5 Pin<&mut Self> 作为 graceful_shutdown 的 receiver
graceful_shutdown 的签名特殊:
pub fn graceful_shutdown(mut self: Pin<&mut Self>) {
self.conn.disable_keep_alive();
}
self: Pin<&mut Self> 而不是 &mut self——这是因为 Connection 本身实现了 Future,调用方手里拿到的往往已经是一个 pin 住的 future(比如在 select! 里和关停信号赛跑);receiver 写成 Pin<&mut Self>,就能在不解除 pin 的前提下直接调用。
Pin<&mut Self> 是 Rust 手写 Future 的典型 receiver——允许你借用 self 的内部字段、但不允许 move 或 swap。hyper 大量用这个——Connection::poll 就是典型的 Pin<&mut Self> receiver。
Conn::disable_keep_alive(hyper-1.9.0/src/proto/h1/conn.rs:876-884)内部要么在连接空闲时直接 state.close()、要么在有请求在途时 state.disable_keep_alive() 把 KA 置成 Disabled——这是安全的、因为它只修改 state 字段、不 move Conn 本身。Pin 允许 "访问和修改 pinned type 的字段"、只要不 move 整个类型。
用户使用时需要先 pin 住 Connection:
use std::pin::pin;
let mut conn = pin!(http.serve_connection(io, svc));
// 收到关停信号后(通常在 select! 的另一条分支里)
conn.as_mut().graceful_shutdown();
pin! 宏把 Connection pin 到栈上、.as_mut() 产生一个 Pin<&mut Connection>、然后才能调 graceful_shutdown。(I、S 都是 Unpin 时 Connection 本身也是 Unpin——它把 service future 和 body 都放在 Pin<Box<..>> 里(hyper-1.9.0/src/proto/h1/dispatch.rs:26、49);用 TcpStream + service_fn 实测可以直接 Pin::new(&mut conn)。)
这种 API 要求对 Rust 新手不友好——"为什么非要 Pin?"是学习 async Rust 的必经之路。但一旦理解了、就会明白这是在跟 Future::poll 的 receiver 保持一致:同一个对象既被 poll、又被调方法,两处都用 Pin<&mut Self> 才接得上。
14.3 Half-Close:容易忽视的边界
// hyper-1.9.0/src/server/conn/http1.rs:257-260
pub fn half_close(&mut self, val: bool) -> &mut Self {
self.h1_half_close = val;
self
}
默认 false。设为 true 时:
Clients can chose to shutdown their write-side while waiting for the server to respond. Setting this to
truewill prevent closing the connection immediately ifreaddetects an EOF in the middle of a request.
14.3.1 什么是 half-close
TCP 是全双工连接——读写两个方向独立。调用 shutdown(SHUT_WR) 可以只关闭写方向,保留读方向。客户端可以:
- 发请求(写 header + body)。
shutdown(SHUT_WR)——写方向 FIN,告诉服务端"我发完了,但我还想读响应"。- 读响应。
- 收到完整响应后关闭 socket。
这是 HTTP/1 的一个合法(但少见)用法。HTTP spec 并没有禁止——但许多 HTTP server 会把 "读到 EOF" 解释成"连接关了,我也关",即使读到的是半关信号。
14.3.2 h1_half_close 的作用
默认情况下 hyper 读到 EOF 就认为连接结束。但如果 h1_half_close = true,hyper 会知道"这只是对端写完了,我还得把响应写完"——继续处理请求并回响应。
这个选项在大多数 Web 场景下没用——浏览器、curl、reqwest 都不做 half-close。但在某些网关/负载均衡软件(HAProxy 的旧版本、某些自研 proxy)会用 half-close 来优化资源回收——那时候后端必须开 half_close 配合。
默认关的合理性:大多数情况下,读到 EOF 就是对端完全断开,继续处理是浪费。选择性开启应付特定基础设施。
14.3.3 set_allow_half_close 对 poll_read 的 EOF 处理分叉
h1_half_close 这个 Builder 字段最终只走一条路落地:http1::Builder::serve_connection 里 if self.h1_half_close { conn.set_allow_half_close(); }(hyper-1.9.0/src/server/conn/http1.rs:459),set_allow_half_close 只是把 state.allow_half_close 置 true(hyper-1.9.0/src/proto/h1/conn.rs:150-152)。而 allow_half_close 在 Conn 里只被一处逻辑读取(另一处是 Debug 实现,hyper-1.9.0/src/proto/h1/conn.rs:992)——第 12 章 §12.9.1 讲过的 mid_message_detect_eof(hyper-1.9.0/src/proto/h1/conn.rs:491-508):
// hyper-1.9.0/src/proto/h1/conn.rs:495-497
if self.state.allow_half_close || !self.io.read_buf().is_empty() {
return Poll::Pending;
}
这个函数在"请求已经读完、响应还没写完"(is_mid_message() 为真、又无 head / body 可读)的时候被 poll_read_keep_alive 调用——默认行为是主动从 socket 读一次:读到 EOF 就 close_read() 并报 Error::new_incomplete(),整条连接随之关闭、响应写不出去。开了 allow_half_close,hyper 在这个阶段根本不去探测 EOF、直接返回 Pending——对端已经 shutdown(SHUT_WR) 也无妨,响应照常写完。
两种行为的差异仅仅是 "要不要在写响应期间主动探测对端 EOF"——
- 默认:探测 → 读到 EOF → 当作对端全关 → 连接关闭、响应丢弃
- half_close:不探测 → 响应写完 → 连接回到 idle、下一轮读请求头时读到 EOF,再关连接
这个分支为什么要用户显式开?——因为默认情况下 "EOF = 对端全关" 的假设在绝大多数场景成立、及早发现能省掉写一个没人收的响应。半关是个罕见特性、用户如果没信心遇到它、不开可以避免"错把半关当全关放走合法请求"的难以调试 bug。
is_mid_message() 的检查很关键——如果 EOF 发生在 请求之间(Reading::Init + Writing::Init、对端先关)、走的是 require_empty_read(debug_assert!(T::is_client()),只对 client 生效,hyper-1.9.0/src/proto/h1/conn.rs:458-488)或 poll_read_head 的 EOF 路径、不管 allow_half_close 如何都是合法的关连接。半关只在"请求已读完、响应未写完"的窗口里有意义。这是 HTTP 语义和 TCP 语义的对齐:HTTP 请求是以 header + body 为单位、半关只在 body 之后发生才有意义。
14.4 header_read_timeout:抵御 Slowloris
14.4.1 Slowloris 攻击
2009 年披露的经典 DoS 技术——客户端建立 TCP 连接,非常慢地发送请求头(每秒 1 字节、每 10 秒 1 字节)。server 认为这是一个"正在发请求的 client",维持连接和 buffer 直到 request 完整。
攻击者开成百上千个这样的慢连接,很快占光 server 的连接池。每连接一进程/线程的 Apache prefork 模型在这方面曾经惨败。Nginx 和 hyper 都有防御。
14.4.2 serve_connection 里的 "配置落盘" 顺序
打开 hyper-1.9.0/src/server/conn/http1.rs:444-493、看 serve_connection 如何把 Builder 上的配置翻译成 Conn 的状态变更:
// 简化:省略 where 约束与末尾构造 Dispatcher 的三行,writev 分支写得更紧凑
pub fn serve_connection<I, S>(&self, io: I, service: S) -> Connection<I, S> {
let mut conn = proto::Conn::new(io);
conn.set_h1_parser_config(self.h1_parser_config.clone());
conn.set_timer(self.timer.clone());
if !self.h1_keep_alive {
conn.disable_keep_alive();
}
if self.h1_half_close {
conn.set_allow_half_close();
}
if self.h1_title_case_headers {
conn.set_title_case_headers();
}
if self.h1_preserve_header_case {
conn.set_preserve_header_case();
}
if let Some(max_headers) = self.h1_max_headers {
conn.set_http1_max_headers(max_headers);
}
if let Some(dur) = self.timer.check(
self.h1_header_read_timeout, "header_read_timeout",
) {
conn.set_http1_header_read_timeout(dur);
};
if let Some(writev) = self.h1_writev {
if writev { conn.set_write_strategy_queue(); }
else { conn.set_write_strategy_flatten(); }
}
conn.set_flush_pipeline(self.pipeline_flush);
if let Some(max) = self.max_buf_size {
conn.set_max_buf_size(max);
}
if !self.date_header {
conn.disable_date_header();
}
// ...
}
这段有三个设计上的讲究:
① "Some-then-apply" 模式——几乎所有配置都是 if self.field_is_not_default { conn.set_xxx(value) }。Conn 本身有内建默认、只在用户显式覆盖时调 setter。这就让 Conn 的 new() 函数保持简单、不需要接受 10+ 个可选参数——Builder 是配置的收集器、Conn 是状态的承载者、通过 "设置/默认" 的二段进行桥接。
② timer.check(...) 的 panic 前置——check 方法在用户设置了 timeout 但没设置 timer 时直接 panic。这个检查在 serve_connection 调用时立即爆出、而不是等到第一次读请求头、要创建 timer 时才在 Time::sleep_until 里 panic(hyper-1.9.0/src/common/time.rs:44-51)——早失败原则的贯彻。错误信息会包含 "header_read_timeout" 这个字符串、告诉用户具体是哪个 timeout 缺 timer。
③ 顺序里的一条隐藏规则——disable_keep_alive 必须在 set_allow_half_close 之前?实际上无所谓顺序——每个 setter 都操作 Conn 上独立字段、互不影响。hyper 按 "keep_alive → half_close → header case → max_headers → timeout → writev → pipeline_flush → max_buf_size → date_header" 这个顺序排列、没有硬要求——大致是从"连接行为"排到"格式化细节",但这只是排列习惯,源码里没有任何顺序约束。
14.4.3 hyper 的 header_read_timeout
// hyper-1.9.0/src/server/conn/http1.rs:344-347
pub fn header_read_timeout(&mut self, read_timeout: impl Into<Option<Duration>>) -> &mut Self {
self.h1_header_read_timeout = Dur::Configured(read_timeout.into());
self
}
默认 30 秒(Duration::from_secs(30))。语义:客户端必须在 30 秒内发完整个请求头,否则连接被强制关闭。
注意这个超时只针对 header——一旦 header 读完,这个 timer 就被 drop。body 的读取由业务逻辑自己设超时(通过 Timeout 中间件)。
这个区分有道理——header 长度有上限(一般几 KB),正常客户端不可能花 30 秒发不完;body 可能是大文件上传,用户侧真的可能几分钟。把 header 和 body 的 timeout 分开让防御更精确。
计时起点是 poll_read_head 开始等请求头的那一刻,而不是收到第一个字节的时刻。对 keep-alive 连接来说,上一个响应写完、连接回到 idle 后,State::idle 会立刻安排下一轮读头(hyper-1.9.0/src/proto/h1/conn.rs:1127-1133,注释写明是为了"another header isn't received in a timely manner"时能关掉连接)——所以在装了 timer 的前提下,它同时也是 keep-alive 连接等下一个请求的空闲上限。实测(header_read_timeout(2s) + TokioTimer):每 0.5 秒发 1 字节的慢请求被以 HeaderTimeout 断开,客户端在第 2.5 秒左右发现写失败;一条连接建好后什么都不发、一个请求结束后保持沉默,都在 2.0 秒时被服务端以 HeaderTimeout 关闭;只装 timer 不改超时,沉默的 keep-alive 连接在 30.0 秒时被关。
14.4.4 set_http1_header_read_timeout 里 Conn 状态机的 timer 集成
当 header_read_timeout 生效后、Conn 会在读 header 的整个过程里持有一个 timer。伪代码:
// 进入读 header 阶段
let deadline = timer.sleep_until(Instant::now() + dur);
tokio::pin!(deadline);
loop {
select! {
// 读 header 数据
result = self.io.read() => { ... }
// timer 触发
_ = deadline.as_mut() => {
return Poll::Ready(Err(TimedOutError::HeaderRead));
}
}
}
// header 读完、timer drop
实际 hyper 实现不用 select!(太重)而是手动在 poll 里检查两个 future——但语义等价。关键点:
① Timer 是 per-connection 的——每条连接自己有一个 timer、不是全局计时。一条连接的 header 读完了、它的 timer 就被 drop、不再占资源。
② Timer 在 header 读完后被 drop——而不是 "挂着但暂停"。这样 body 阶段完全没有定时开销、符合 §14.4.3 讲的 "header timeout != body timeout" 的原则。
③ Timer fire 时返回的 Error是一个特殊类型(Error::new_header_timeout(),Kind::HeaderTimeout)——告诉上层"这不是正常的 IO 错误、是超时触发的关闭"——便于 access log 里记录"X.X.X.X - slowloris attempt"。
timer 的集成没有全局调度、没有共享计时线程——完全依赖注入的 Timer trait 实现(用 TokioTimer 时就是 tokio runtime 的时间轮)。这让 hyper 在非 tokio runtime(比如 monoio、smol)也能工作——前提是用户提供 Timer impl。
14.4.5 需要 Timer
/// Requires a [`Timer`] set by [`Builder::timer`] to take effect. Panics if `header_read_timeout`
/// is configured without a [`Timer`].
hyper 1.x 的设计原则之一是"runtime 无关"(第 1 章讨论过)——hyper 本身不绑定 tokio,不直接调 tokio::time::sleep。它通过 hyper::rt::Timer trait 抽象时钟能力,让用户注入具体实现:
use hyper_util::rt::TokioTimer;
http1::Builder::new()
.timer(TokioTimer::new())
.header_read_timeout(Duration::from_secs(30))
.serve_connection(io, svc)
.await
如果设了 header_read_timeout 但没设 timer——运行时 panic(实测在调用 serve_connection 时就 panic)。这个"配置需要先决条件"的模式是 hyper 对"runtime 无关"的代价——用户得自己负责把 timer 注入。
在 hyper-util crate 里,hyper_util::server::conn::auto::Builder(第 19 章主角)也不会替你注入 timer——auto::Builder::new(executor) 内部就是 http1::Builder::new()(hyper-util 仓库 src/server/conn/auto/mod.rs:93-104),要通过 .http1().timer(TokioTimer::new()) 自己设;不设的话默认的 30 秒 header_read_timeout 会被 Time::check 静默丢弃(只打一条 warn,而且默认编译下连这条 warn 都看不到,见 §14.8.2)。
14.4.6 Timer trait 的四个方法
hyper 的 hyper::rt::Timer trait 设计:
// hyper-1.9.0/src/rt/timer.rs:70-100(略去 doc comment,以及 Sleep 上一个 #[doc(hidden)] 的 __type_id 方法)
pub trait Timer {
fn sleep(&self, duration: Duration) -> Pin<Box<dyn Sleep>>;
fn sleep_until(&self, deadline: Instant) -> Pin<Box<dyn Sleep>>;
fn now(&self) -> Instant {
Instant::now()
}
fn reset(&self, sleep: &mut Pin<Box<dyn Sleep>>, new_deadline: Instant) {
*sleep = self.sleep_until(new_deadline);
}
}
pub trait Sleep: Send + Sync + Future<Output = ()> {}
两个必须实现的方法 + 两个有默认实现的方法:
sleep(dur)——从现在起经过 dursleep_until(deadline)——到某个具体时刻now()——当前时刻,默认Instant::now()reset(sleep, new_deadline)——重置已有 timer 的 deadline;默认实现是直接新建一个替换掉、实现者可以覆盖成原地修改
reset 方法是性能关键——HTTP/2 的 keep-alive ping(第 17 章)每次重新排期都对同一个 sleep 调 reset(hyper-1.9.0/src/proto/h2/ping.rs:453、476);如果每次都 drop 旧 timer、创建新 timer、就多一次 Box 分配和一次定时器注册。Conn::poll_read_head(第 12 章 §12.10.1)里也写了 reset 分支,但 header 读完后 h1_header_read_timeout_fut 会被置成 None(hyper-1.9.0/src/proto/h1/conn.rs:273-277),所以 HTTP/1 的正常路径上每个请求还是新建一个 sleep。hyper-util 的 TokioTimer::reset 把 dyn Sleep downcast 回 TokioSleep 后调 tokio::time::Sleep::reset、原地修改 deadline。
Pin<Box<dyn Sleep>> 的类型选择——Sleep 是动态分发的(Box dyn)、因为 hyper 把 timer 存成 Arc<dyn Timer + Send + Sync>(hyper-1.9.0/src/common/time.rs:15),trait 对象的方法没法返回各实现各自的具体 Sleep 类型、只能装箱;这样 Builder、Conn 也不必多带一个 timer 泛型参数。Box 的代价是一次堆分配每 timer——HTTP/1 下是每个请求的读头阶段一次,比起一次请求的其余开销、这个代价可接受。
hyper-util 的 TokioTimer 实现:
// hyper-util 仓库 src/rt/tokio.rs:280-302(节选)
impl Timer for TokioTimer {
fn sleep(&self, duration: Duration) -> Pin<Box<dyn Sleep>> {
Box::pin(TokioSleep {
inner: tokio::time::sleep(duration),
})
}
// ...
}
把 tokio::time::sleep 包一层、满足 trait。用户如果要用 monoio、smol——自己写类似的 wrapper 即可。这种"小 trait + 多种实现"是 Rust 抽象异构 runtime 的惯用手法——和 AsyncRead/AsyncWrite、Service/Runnable 一脉相承。
14.5 max_headers:内存 + 安全
// hyper-1.9.0/src/server/conn/http1.rs:330-333
pub fn max_headers(&mut self, val: usize) -> &mut Self {
self.h1_max_headers = Some(val);
self
}
默认 100。超过返回 431 Request Header Fields Too Large。
14.5.1 栈分配 vs 堆分配
文档里说得清楚:
Note that headers is allocated on the stack by default, which has higher performance. After setting this value, headers will be allocated in heap memory, that is, heap memory allocation will occur for each request, and there will be a performance drop of about 5%.
默认的 100 个 header slot 是栈分配——smallvec_inline。用户 override 后就变成运行时大小——走 smallvec![MaybeUninit::uninit(); cap],超过 inline 容量就落到堆上。
所以 max_headers(50) 不会比默认 100 更快:50 没超过 inline 容量,数组照样在栈上(仍是 100 个槽),只是改走运行时构造的 smallvec!——文档那句"每个请求都会堆分配",按 smallvec 的语义要 cap 超过 100 才成立(§14.5.2)。它也省不下内存。真的想省内存,是降低 max_buf_size(控制整条连接的 buffer),不是降 max_headers。
14.5.2 [httparse::EMPTY_HEADER; 100] 为什么能栈分配
§14.5.1 提到默认 100 个 header slot 是栈分配、用户 override 成超过 100 的值会切到堆。根本原因在于 Rust 数组类型的大小必须在编译期已知:
// 能栈分配:大小固定
let headers: [Header; 100] = [httparse::EMPTY_HEADER; 100];
// 不能栈分配:大小运行时决定
fn parse(max: usize) {
let headers: [Header; max] = ...; // ❌ 编译错
let headers: Vec<Header> = Vec::with_capacity(max); // ✓ 堆分配
}
hyper 源码里这段代码的分叉(hyper-1.9.0/src/proto/h1/role.rs:159-163,Server::parse 里;client 侧 :1023-1028 同构):
let mut headers: SmallVec<[MaybeUninit<httparse::Header<'_>>; DEFAULT_MAX_HEADERS]> =
match ctx.h1_max_headers {
Some(cap) => smallvec![MaybeUninit::uninit(); cap],
None => smallvec_inline![MaybeUninit::uninit(); DEFAULT_MAX_HEADERS],
};
None 分支是 smallvec_inline!——编译期常量长度、整块在栈上、而且用 MaybeUninit 跳过清零(源码注释:不清零这块栈内存在 pipeline benchmark 上省下约 5%)。Some(cap) 分支是运行时长度的 smallvec!——按 smallvec 的语义、cap 超过 inline 容量 100 时才真正 malloc;hyper 文档把这条路径笼统标成"堆分配、约 5% 性能下降"。
栈数组的优势是零分配、零 free、cache-local;堆分配的代价是一次 malloc + 一次 free + 一次 cache miss。在 parse 一个 HTTP 请求这种每连接每请求都做一次的热路径、这个差异累积起来不可忽视。
用户如果真的想省内存(从 100 降到 50)怎么办?省不下来——SmallVec 的 inline 容量是类型里写死的 DEFAULT_MAX_HEADERS,Rust 的数组长度必须是编译期常量、"运行时决定的长度"改不了 [T; N] 的大小;降到 50 的效果只是第 51 个 header 起就回 431(实测:带 50 个 header 的请求拿到 200,51 个拿到 431)。
这个设计细节告诉我们:Rust 编译期类型系统对性能有深远影响——看起来只是配置一个数字、背后却牵涉"栈 vs 堆"的路径分叉。hyper 的文档把这个事实写出来、让用户做有知情的权衡——比 nginx/apache 这种"配就完了、底层自己搞定"的 C 生态透明得多。
14.5.3 攻击场景
攻击者发一个有 10000 个 X-Evil-N: value\r\n 头的请求。没上限的话服务端会分配 10000 * 32 bytes = 320KB 的 header 数组(加上 Bytes refs 更多)。几千个这样的连接——内存就吃爆了。
max_headers = 100 在这里当了硬限——第 101 个 header 直接拒绝返回 431。配合后面讲的 max_buf_size 形成两层防线。
14.5.4 set_http1_max_headers 对 parser 行为的传导
conn.set_http1_max_headers(max_headers) 最终经 ParseContext::h1_max_headers 传到 Server::parse——在调用 httparse 之前按这个值分配 header 数组:
// hyper-1.9.0/src/proto/h1/role.rs:165-171
let mut req = httparse::Request::new(&mut []);
let bytes = buf.as_ref();
match ctx.h1_parser_config.parse_request_with_uninit_headers(
&mut req,
bytes,
&mut headers,
) {
注意 httparse 接受的是 &mut [MaybeUninit<Header>]——切片、长度由 caller 决定。hyper 在调用前先分配数组(见 §14.5.2 的 smallvec! / smallvec_inline! 分叉);header 数超过切片长度时 httparse 报 TooManyHeaders、hyper 在 hyper-1.9.0/src/error.rs:623 把它映射成 Parse::TooLarge、再由 role.rs:472 变成 431。
httparse 本身零分配——它只接受已有的 &mut [Header] 切片、从用户分配的内存里填。这是 httparse 能这么快的根本原因——所有工作都在用户提供的 buffer 里做、parser 自己不碰堆。
hyper 的 h1_max_headers 配置只是改变 hyper 这一层的数组分配方式、httparse 层无感知。用户调 100 还是 50、对 httparse 是透明的——它只知道"这里有 N 个 slot"。
这种 "parser 零分配 + caller 管内存" 是 Rust 库设计的重要模式——和 serde 的 DeserializeSeed、nom 的切片输入都一脉相承。把堆分配的决定权交给 caller、让高级用户可以用栈、普通用户可以用 Vec、都不改 parser 自己。
14.6 max_buf_size:连接级 buffer 上限
// hyper-1.9.0/src/server/conn/http1.rs:373-381
pub fn max_buf_size(&mut self, max: usize) -> &mut Self {
assert!(
max >= proto::h1::MINIMUM_MAX_BUFFER_SIZE,
"the max_buf_size cannot be smaller than the minimum that h1 specifies."
);
self.max_buf_size = Some(max);
self
}
默认约 400 KB。最小不能小于 MINIMUM_MAX_BUFFER_SIZE(8192 bytes,就等于初始读缓冲大小 INIT_BUFFER_SIZE,hyper-1.9.0/src/proto/h1/io.rs:15-18)——再小,稍大一点的请求头就塞不下。
这个值限制整条连接的读写 buffer 上限。当 buffer 超过此值:
- 读:header 还没解析完、读 buffer 就已经涨到上限——hyper 直接报
Error::new_too_large()(hyper-1.9.0/src/proto/h1/io.rs:200-206)、关连接、对应 431。 - 写:
can_buffer()返回 false——Dispatcher 转去poll_flush、不再从 body 里拉 frame(第 12 章poll_write的!self.conn.can_buffer_body()分支,hyper-1.9.0/src/proto/h1/dispatch.rs:356-357)——造成 Body::poll_frame 暂停。
这是连接级的背压阀门。没有这个阀门,恶意 client 可以:
- 发一个 10MB 的请求头(比如几十行、每行几百 KB 的 header)——读 buffer 被迫膨胀到 10MB 以上。
- 开几千条这种连接——内存爆掉。
400KB 是一个 sensible default——正常 header 撑死几十 KB,给应用的写 buffer 也够。生产服务通常不需要调,除非你需要支持极大单个 header(罕见)。
14.6.1 assert!(max >= MINIMUM_MAX_BUFFER_SIZE) 的硬下限
看 hyper-1.9.0/src/server/conn/http1.rs:373-381、max_buf_size 有一个编译时无法验证、但运行时立即 panic的下限:
pub fn max_buf_size(&mut self, max: usize) -> &mut Self {
assert!(
max >= proto::h1::MINIMUM_MAX_BUFFER_SIZE,
"the max_buf_size cannot be smaller than the minimum that h1 specifies."
);
self.max_buf_size = Some(max);
self
}
MINIMUM_MAX_BUFFER_SIZE = 8192。用户如果调 max_buf_size(4096)——立即 panic、还在 Builder 构造阶段、早到不能再早。
为什么选择 assert 而不是 Result?——因为这是显而易见的编程错误、不是运行时意外。HTTP/1 的单个请求行(request line + single header)本身可能几百字节、如果 buffer 不到 8KB、parser 根本无法正确工作——这是逻辑错误、不是"业务逻辑可以处理的失败"。
Rust 的惯例是:bug 用 panic、业务失败用 Result——这里符合 bug 的定性。用户写 max_buf_size(100) 是不可能合理的、直接让程序不能启动比让它跑起来然后无法处理请求好得多。
assert! 的 message 里说清楚了原因——"cannot be smaller than the minimum that h1 specifies"——让错误信息足够自解释。这和 §14.1.2 讨论的 Dur 两态一样、是 hyper 对"配置错误要在早期大声报出"的一以贯之的态度。
这种硬检查在 Rust 生态里很常见——类似 tokio 的 Builder::worker_threads(0) panic(assert!(val > 0, "Worker threads cannot be set to 0"),tokio-1.46.1/tokio/src/runtime/builder.rs:395-396)。拒绝让配置明显错误的程序启动、比让它跑起来再以模糊错误消息挂掉强得多。
14.6.2 set_max_buf_size 的双向生效:读和写的同一个阀门
max_buf_size 虽然是单一字段、但 Buffered::set_max_buf_size(hyper-1.9.0/src/proto/h1/io.rs:86-94)把它同时写进读策略和写 buffer、同时约束读和写两个方向:
// 读侧:hyper-1.9.0/src/proto/h1/io.rs:200-206,parse 循环里 header 尚未完整时
None => {
let max = self.read_buf_strategy.max();
let curr_len = self.read_buf.len();
if curr_len >= max {
debug!("max_buf_size ({}) reached, closing", max);
return Poll::Ready(Err(crate::Error::new_too_large()));
}
...
}
// 写侧:hyper-1.9.0/src/proto/h1/io.rs:575-582
fn can_buffer(&self) -> bool {
match self.strategy {
WriteStrategy::Flatten => self.remaining() < self.max_buf_size,
WriteStrategy::Queue => {
self.queue.bufs_cnt() < MAX_BUF_LIST_BUFFERS && self.remaining() < self.max_buf_size
}
}
}
一个阀门同时控制两个方向、让用户不需要分别设 max_read_buf 和 max_write_buf。配置体验简单、但也有代价——
代价是不能对读 / 写单独调参。如果一个服务典型请求头小(不需要大 read buf)但响应大(需要大 write buf 缓冲 body)、用户只能把 max_buf_size 调到写侧需要的大小、读侧被迫跟着宽。
这个简化的合理性——在 HTTP/1 场景、读 / 写 buf 的量级差不多(都在几十 KB 到几百 KB)。拆成两个参数增加复杂度但很少有真实收益。hyper 选了简单。HTTP/2(第 15-17 章)就不同了——流控窗口、帧 buffer、header 压缩 table 各自有独立上限——那是另一个复杂度等级的协议。
14.7 writev:vectored I/O 的取舍
// hyper-1.9.0/src/server/conn/http1.rs:361-364
pub fn writev(&mut self, val: bool) -> &mut Self {
self.h1_writev = Some(val);
self
}
三态:Some(true)(强制 vectored)、Some(false)(禁用)、None(auto,默认)。
14.7.1 vectored 是什么
writev / write_vectored 是一个系统调用——一次提交多个不连续的 buffer,内核把它们 scatter-gather 写到 socket,无需用户层先 memcpy 成一个连续 buffer。
hyper 大量用这个——response header、chunked size、body 数据、\r\n 分隔符常常是四个独立 buffer,用 writev 一次提交。
14.7.2 为什么要选 auto?TLS 的副作用
TLS(rustls、native-tls)在上层包一层加密——它收到 vectored write 后要把所有 buffer 拷贝到一个连续 buffer 里加密,然后 write 加密数据。等于 hyper 避免的 copy 被 TLS 重新做了一次——还多了一层 encrypted copy。
对 plain TCP,vectored 是纯净收益。对 TLS,vectored 往往反而更慢(因为 TLS 层不得不先合并)。
hyper 的 auto 模式:运行时探测 IO 对象是否支持 "real vectored write"——不支持就切成 flatten 写。这是通过 hyper::rt::Write::is_write_vectored()(Buffered::new 在 hyper-1.9.0/src/proto/h1/io.rs:60 调它选策略;hyper-util 的 TokioIo 把它转发给 tokio 的 AsyncWrite::is_write_vectored,hyper-util 仓库 src/rt/tokio.rs:197-199)实现。TLS 适配层只要返回 false——hyper 就不走 vectored 路径。
一般不用手动设。只有当你用自研 TLS 库、或者底层是非标准 IO,并且 benchmarks 证明 vectored 有问题时才考虑。
14.7.3 set_write_strategy_queue vs set_write_strategy_flatten
h1_writev 的 Some(true)/Some(false)/None 在 serve_connection 里的分发(hyper-1.9.0/src/server/conn/http1.rs:476-482):
if let Some(writev) = self.h1_writev {
if writev {
conn.set_write_strategy_queue(); // 强制 vectored
} else {
conn.set_write_strategy_flatten(); // 强制 flatten
}
}
// None 时不调用、Conn 内部自己决定
两种策略对应 Conn 里的两种 write 路径:
- Queue strategy:维护一个
BufList<B>(内部是VecDeque<B>,最多MAX_BUF_LIST_BUFFERS = 16段)、每次poll_flush调用poll_write_vectored一次提交所有段。 - Flatten strategy:把每个 chunk
extend_from_slice到headers这个Cursor<Vec<u8>>里、然后一次poll_write(hyper-1.9.0/src/proto/h1/io.rs:538-572 的buffer方法)。
Auto 模式(None)在 Conn 内部走一条运行时探测分支——调 self.io.is_write_vectored()、如果返回 true 就用 queue、返回 false 就用 flatten。
为什么 is_write_vectored() 是 trait 方法而不是 runtime 检测?——因为判定标准不是"系统有 writev 系统调用"(所有主流 OS 都有)、而是"这个 IO 对象是否能把 vectored 高效地传下去"。一个 TLS 流虽然底层包了 TcpStream(支持 vectored)、但它自己要把 buffer 合并加密——所以让 is_write_vectored() 返回 false、告诉上层"别给我发 vectored、反正我要合并"才是合理的实现。
这就是 Rust 的 trait system 精妙之处——实现者自己知道自己能做什么、通过 trait 方法把这个知识传给上层、上层据此选最优路径。不需要中心化的 capability 检测、不需要硬编码什么库走什么分支。
这个设计思路和《Vite 设计与实现》 第 15 章 §15.4.12 讲的 vite ensureBuiltins 跨进程协商本质相似——都是"信息的归属原则":每个组件应该只回答自己最有权威的问题。TLS 层最知道 "我能不能 vectored"、vite 服务端最知道"哪些算 builtin"——把这些知识以 trait 方法 / RPC 暴露给需要的人。
14.8 超时矩阵:生产服务必备
一个生产级 HTTP 服务的超时不是一个数字——是一张矩阵。以下是完整版:
| 层级 | 保护对象 | 典型配置 | 失效后的症状 |
|---|---|---|---|
| TCP | 已建立连接的网络可达性 | SO_KEEPALIVE、TCP_USER_TIMEOUT |
连接半死不活、文件描述符长期占用 |
| HTTP 连接 | 请求头读取与连接生命周期 | header_read_timeout(HTTP/1)、keep_alive_interval(HTTP/2)、外部 idle watchdog |
Slowloris、idle 连接堆积 |
| 请求 | 单次业务调用耗时 | tower::timeout::Timeout |
慢后端拖住 worker、p99 被放大 |
| Body | 请求/响应体读写 | http_body_util::Limited、应用层 timeout |
大 body OOM、流式响应占住资源 |
14.8.1 TCP 层
TCP_USER_TIMEOUT:socket 发出数据后多久没 ACK 就放弃(Linux 专用)。默认跟系统 TCP retries,可能 15-20 分钟。生产建议设 10-30 秒。SO_KEEPALIVE+TCP_KEEPIDLE/INTVL/CNT:NAT 连接保活。TCP_KEEPIDLE=60+KEEPINTVL=30+KEEPCNT=3大约 2 分半检测到死连接。SO_RCVTIMEO/SNDTIMEO:read/write 系统调用超时。Tokio runtime 一般不需要——自己用 timeout future 包。
14.8.2 HTTP 连接层(hyper 配置)
header_read_timeout:30 秒(默认,Dur::Default(Some(Duration::from_secs(30))),hyper-1.9.0/src/server/conn/http1.rs:242)。防 Slowloris。但它只在装了Timer时才生效:Time::check对Dur::Default遇到Time::Empty只warn!("timeout ... has default, but no timer set")后返回None(静默降级),而对显式.header_read_timeout(..)配过的Dur::Configured则直接panic!("timeout ... set, but no timer set")(hyper-1.9.0/src/common/time.rs:70-85,调用点 hyper-1.9.0/src/server/conn/http1.rs:472)——所以「没配 timer 也有 30 秒保护」是错觉,下面的实录必须先.timer(TokioTimer::new())。那条 warn 也别指望:hyper 的warn!宏只在开启tracingfeature 时才有内容(hyper-1.9.0/src/trace.rs:109-116),而这个 feature 是不稳定的,还要求RUSTFLAGS='--cfg hyper_unstable_tracing'(hyper-1.9.0/src/trace.rs:5-11)。实测:默认编译下不装 timer,一条什么都不发的连接 36 秒后仍然开着、每 0.5 秒发 1 字节(发完请求头要 33 秒多)的慢请求照样拿到200 OK,全程没有任何输出;只有打开上述 feature 并装上 tracing subscriber,才会看到WARN hyper::common::time: timeout `header_read_timeout` has default, but no timer set。keep_alive_idle_timeout:hyper 没有直接的配置——http1::Builder的全部字段见 hyper-1.9.0/src/server/conn/http1.rs:71-84,里头没有任何 idle 相关项。不过装了 timer 之后,header_read_timeout实际上兼任了 keep-alive 连接的空闲上限:连接回到 idle 就开始等下一个请求头,等不到就关(§14.4.3,实测 30 秒默认值生效)。它管不到的是没装 timer 的服务,以及"请求在途、却长时间没有数据流动"的连接(handler 挂住不回、SSE 之类的长响应)——这些要靠请求层超时或 外部 watchdog(你的业务代码或者 reverse proxy)。客户端实现(第 20 章的 connection pool)有自己的 idle timeout。- HTTP/2 的 keep-alive PING:
header_read_timeout只属于 HTTP/1。HTTP/2 连接用http2::Builder::keep_alive_interval/keep_alive_timeout(默认关闭 / 20 秒,hyper-1.9.0/src/server/conn/http2.rs:220-240):按间隔发 PING,超时收不到 ACK 就关连接;服务端连接空闲时也照发(keep_alive_while_idle: true,hyper-1.9.0/src/proto/h2/server.rs:162)。它同样依赖 timer——开了 interval 却没装 timer,握手完成建 ping 通道时会在Time::sleep里 panic(hyper-1.9.0/src/proto/h2/ping.rs:62-66、src/common/time.rs:34-38)。实测 interval、timeout 都设 1 秒,对端只发完连接前言就不再响应:服务端第 1 秒发出 PING、第 2 秒发 GOAWAY 并以KeepAliveTimedOut结束连接;不装 timer 则直接 panicYou must supply a timer.。细节见第 17 章。
为什么 hyper server 不提供专门的 idle timeout?因为"idle timeout 是部署策略问题"——proxy 层(Nginx / HAProxy / Envoy)已经有自己的 idle 策略,hyper 做会重复。自建 hyper server 直接暴露到公网的用户可以用 tower 中间件或自己轮询 live connections 来实现。
14.8.3 请求层(tower 中间件)
tower::timeout::Timeout:每请求的 wall-clock 超时。第 5 章读过源码——Timeout::call里let sleep = tokio::time::sleep(self.timeout);与 business future 赛跑(tower-0.5.3/tower/src/timeout/mod.rs:66-68)。tower::limit::rate::RateLimit:单位时间请求数(tower-0.5.3/tower/src/limit/rate/service.rs)。tower::limit::concurrency::ConcurrencyLimit:并发 in-flight 数(tower-0.5.3/tower/src/limit/concurrency/service.rs)。
14.8.4 Body 层(应用代码)
- 读 body 超时:应用代码里
tokio::time::timeout(dur, body.collect()).await?。 - 写 body 超时:基本同上,但写超时往往由 TCP 层的 write timeout 触发。
- 体积限制:
http_body_util::Limited(第 10 章)——tower-http 的RequestBodyLimit正是它的封装:req.map(|body| Limited::new(http_body_util::Limited::new(body, body_limit)))(tower-http-0.6.8/tower-http/src/limit/service.rs:60)。
14.8.5 典型配置实录
一个生产配置的示例:
use std::time::Duration;
use hyper_util::rt::TokioTimer;
let mut http = hyper::server::conn::http1::Builder::new();
http.timer(TokioTimer::new())
.header_read_timeout(Duration::from_secs(15)) // Slowloris 保护
.max_headers(50) // 默认 100(见 hyper-1.9.0/src/server/conn/http1.rs:316-333 的文档注释),这里收紧
.max_buf_size(256 * 1024) // 256KB,节约
.keep_alive(true); // 默认
加上 tower 栈:
let svc = tower::ServiceBuilder::new()
.timeout(Duration::from_secs(60)) // 每请求 60s
.layer(tower_http::limit::RequestBodyLimitLayer::new(10 * 1024 * 1024)) // body 最大 10MB(内部用 http_body_util::Limited)
.service(router);
再加上 tokio 的 TCP 层:
let socket = tokio::net::TcpSocket::new_v4()?;
socket.set_keepalive(true)?; // tokio-1.46.1/tokio/src/net/tcp/socket.rs:189;idle/interval/count 要用 socket2 的 TcpKeepalive 设
这三层合起来,构成 "连接级 → 请求级 → body 级" 三重防护。一个合格的生产 Rust HTTP server 的超时矩阵就长这样。
14.8.6 pipeline_flush 的实验性质
14.1 节列出的 Builder 字段里、有一个被标 "Experimental, may have bugs" 的(hyper-1.9.0/src/server/conn/http1.rs:392-400):
/// Aggregates flushes to better support pipelined responses.
///
/// Experimental, may have bugs.
///
/// Default is `false`.
pub fn pipeline_flush(&mut self, enabled: bool) -> &mut Self {
self.pipeline_flush = enabled;
self
}
HTTP/1.1 pipelining 允许客户端在上一个请求响应回来前连续发多个请求——服务端按顺序处理并回响应。2020 年前的浏览器基本放弃了这个特性(HTTP/2 替代它)、但某些 REST 客户端库和内网服务还在用。
pipeline_flush=true 改变了 hyper 在 pipelined 场景下的 write 行为:只要读缓冲里还有没处理完的字节(后续的 pipelined 请求已经到了),poll_flush 就先不真正 flush、把响应攒着,等读缓冲清空再一起写出(hyper-1.9.0/src/proto/h1/io.rs:267-269)——聚合多个响应一次 flush、减少 syscall 数。
为什么标实验性?——源码只在文档里写了一句 "Experimental, may have bugs",没有交代原因;从实现能看出的代价有两条:
- 读缓冲里排着的后续请求如果处理得慢,前面已经写好的响应也跟着被扣住——客户端那边可能因此超时。
- 开启后写策略被强制成 flatten(hyper-1.9.0/src/proto/h1/io.rs:78-84),
can_buffer()也不再看max_buf_size(同文件 :153-155)——§14.6 那个写侧阀门在这里失效,排队的请求越多、写缓冲涨得越大。
这种 "把实验性功能暴露出来但标注清楚" 的做法是 Rust 生态的典型——比默默合入或者偷偷砍掉都好。用户可以在需要时开起来、同时知道要承担的风险。
14.8.7 auto_date_header 的 RFC vs 性能取舍
默认 true(hyper-1.9.0/src/server/conn/http1.rs:382-390):
/// Set whether the `date` header should be included in HTTP responses.
///
/// Note that including the `date` header is recommended by RFC 7231.
///
/// Default is `true`.
pub fn auto_date_header(&mut self, enabled: bool) -> &mut Self {
self.date_header = enabled;
self
}
RFC 7231 第 7.1.1.2 节要求 server 在大多数响应里带 Date header(除非 5xx 错误或 1xx 信息性响应)。hyper 默认开、合规。
关掉有什么场景?——两种:
- 极端性能追求:不过能省的很少——读
SystemTime::now()、每秒一次的格式化,都发生在Dispatcher::poll_inner开头的T::update_date()里(hyper-1.9.0/src/proto/h1/dispatch.rs:147 → role.rs:489-491),开不开 date header 都会做;关掉省下的只是往响应头里写date:加 29 字节日期那一下(role.rs:960-964)。对纳秒级延迟的服务(HFT 转发、低延迟代理)也许仍值得。 - 代理场景:前面有 L7 proxy(CloudFront、Cloudflare、Nginx),proxy 会加自己的 Date、hyper 再加就是重复——关掉避免冗余。
hyper 对 Date 做了秒级缓存——不在 Conn 里,而是 hyper-1.9.0/src/common/date.rs 的一个 thread_local!(static CACHED: RefCell<CachedDate>):CachedDate::check() 只在 SystemTime::now() > self.next_update 时才重新 render,而 next_update 被设成「下一个整秒」(now + Duration::new(1, 0) - Duration::from_nanos(nanos))——同一秒内多个响应复用同一个字符串、避免重复格式化(hyper-1.9.0/src/common/date.rs:36-85;h1 侧调用点 hyper-1.9.0/src/proto/h1/role.rs:490 与 963)。实际性能开销低于直觉——所以这个 toggle 更多是为第 2 种代理场景准备的。
这和 §14.1.1 讲的"缺省选 RFC、选项留给例外"是一个模式——规范遵循 + 工程逃生舱。默认别人说你合规、专业用户想关时你给他关。
14.8.8 为什么 hyper 不内建 keep_alive_idle_timeout
§14.8.2 提过 hyper server 没有专门的 idle timeout(HTTP/1 等下一个请求的那段空闲只是顺带受 header_read_timeout 约束)、其余要求用户在 proxy 或业务代码里做。这个设计选择值得再深挖——因为很多用户第一眼就会问"为什么不加一个?十行代码的事"。
hyper 不做的理由有三层:
① 定义"idle"本身就有分歧——是"TCP 上 0 字节流动"?还是"没有 in-flight 请求"?还是"keep-alive 连接等下一个请求超过 N 秒"?不同 use case 的定义不同、hyper 选任一个都会让另一部分用户不爽——它只借 header_read_timeout 顺带覆盖了最后一种(§14.4.3)。
② idle 策略和部署架构强相关——有 Nginx 在前、Nginx 已经管 idle;直连到内部服务、可能不需要太严的 idle;SSE/WebSocket 的连接本来就是"长 idle"、强制关会破坏功能。hyper 作为底层库、不应该替用户做这种"取决于场景"的决策。
③ 用户实现 idle 监控并不难——维护一个 Arc<Mutex<HashMap<ConnId, LastActive>>>、每处理一个请求更新 timestamp、另一个 task 定时扫 map 关掉过期的。几十行代码、完全可控——比把逻辑塞进 hyper 更灵活。
这三条加起来就是 hyper 作为 "Rust HTTP 协议层" 的定位——它是 protocol parser + state machine、不是 full-fledged web server。full-fledged 的事(TLS 终止、idle 管理、流量限制、auth)都由上层栈(axum、tower-http、proxy)完成。hyper 坚持自己的边界、让生态可以在其上自由组合。
对比 Go net/http 的 IdleTimeout——Go 标准库要承担"一把梭"的角色、所有特性都得有。Rust 的哲学不一样——每一层只做一层的事。这也解释了为什么 Rust 生态的"完整方案"要多个 crate 组合(hyper + hyper-util + axum + tower)——对 Rust 新手门槛高、但对老手灵活度无敌。
14.9 一个典型事故模型:idle 连接堆积
把几类常见故障合成一个案例来看:
某团队的 Rust gRPC 网关部署在 K8s 上,每周三凌晨 2 点监控会报 FD 数量上涨。不是流量涨——是 ESTABLISHED 状态的 TCP 连接数从 200 上涨到 10000。客户端是 Android 设备,每个设备建一个长连接,大部分时间空闲,也没开应用层心跳。
根本原因是 NAT timeout:客户端位于移动运营商的 NAT 后面,运营商的 NAT 表 30 分钟无流量就丢 entry。客户端从 NAT 角度"消失"了,但 hyper server 不知道——它还持有这个 TCP 连接,以为是空闲 keep-alive。
解决方案有三个层次:
- Linux TCP keepalive:按系统默认参数(
tcp_keepalive_time为 7200 秒),socket 级 keepalive 要 2 小时后才开始探测——太慢,NAT 表早丢了;要用就得像 §14.8.1 那样把TCP_KEEPIDLE等参数调短。 - HTTP 层心跳:HTTP/2 的 PING(第 17 章讲)——gRPC 跑在 HTTP/2 上,hyper 服务端已经内建:配上 timer 和
http2::Builder::keep_alive_interval/keep_alive_timeout,就会定期 PING、收不到 ACK 就关连接(§14.8.2),不用改协议,只是默认没开。 - 短 idle timeout:在 server 侧每 5 分钟扫 live connections,关掉过期的——补救方案,但是 hyper server 没内建,团队最终自己写了个 watchdog。
这个事故的教训:hyper server 默认不清理空闲连接——HTTP/1 要装 timer 才让 header_read_timeout 生效,HTTP/2 要显式配 keep_alive_interval,其余情形靠 watchdog,你得自己想这件事。而客户端(如 reqwest)有 idle pool cleanup——第 20 章会读。
14.10 title_case_headers 和 preserve_header_case:两个看似多余的选项
Builder 有两个关于 header 大小写的选项:
h1_title_case_headers: false, // 写出时转换成 Title-Case (Content-Type)
h1_preserve_header_case: false, // 保留原始请求的 case
HTTP/1.1 规范:header name case-insensitive(不区分大小写)——按规范、content-type 和 Content-Type 等价。hyper 内部统一 lower case 存储、性能最优(HashMap 查找可以按小写 key 走)。
为什么还有这两个选项?——因为真实世界里有些奇葩的基础设施不遵守规范:
- 一些老旧的 server / 中间件对 header 大小写敏感——小写 header 会被视为无效。hyper 作为客户端连这类老系统、必须开
title_case_headers。 - 某些遗留的 B2B 集成(银行/电信的老系统)签名验证会把 header 大小写计入 hash——必须保留原始 case。
- 日志/审计需求:某些合规场景下需要把 header 按原样存档——
preserve_header_case允许这种保真。
这两个选项默认关——因为绝大多数 HTTP 服务都符合规范、开了白白花 CPU 做 case 转换。只有遇到老系统时才开。
这体现了 hyper 对"不丢弃兼容性"的执着——即使某个需求只有极少数用户遇到、也提供逃生舱。这种长尾兼容性是基础库能被广泛采用的关键——没有人喜欢一个"能做大部分的事、但剩下一点让你完全卡住"的库。
title_case_headers 实现上还有细节:走哪条编码路径不是每次请求重新判断配置、而是在 Conn 上标记一次、之后所有 outgoing header 都走 title-case 编码路径。不过转换本身并不省:每写一个 header 名都要逐字节把连字符后的字母转成大写(hyper-1.9.0/src/proto/h1/role.rs:1569-1590)。"启动期一次性决定"这一点,和《Vite 设计与实现》 第 15 章 §15.4.12 vite 的 ensureBuiltins、《vLLM 推理内核深度解析》 第 8 章 §8.5 vllm 在初始化时预铺平的 pad_for_cudagraph 查表是同一脉工程直觉。
14.11 与第 12 章 Dispatcher、第 10 章 http-body 的呼应
与第 12 章(Dispatcher)的呼应——本章讨论的 header_read_timeout 落到连接层就是 Conn::poll_read_head 里的 timer 检查(第 12 章 §12.10.1 展开)。本章讲配置、第 12 章讲执行——两者合起来才是完整的"slowloris 防御链"。
与第 10 章(http-body)的呼应——本章 §14.8.5 提到的 RequestBodyLimitLayer(tower-http)底层就是 http_body_util::Limited(第 10 章深入)。连接级超时(本章)+ body 级限流(第 10 章)+ 请求级 Timeout(第 5 章)构成三层防护、每一层职责清晰、在不同粒度上防御不同攻击。
与《Vite 设计与实现》专栏第 15 章的呼应——hyper 的 h1_header_read_timeout 和 vite 的 debug?.(...) 2s 都是"时间驱动的诊断/防御"——前者是硬防御(超时就关)、后者是软诊断(超时就警告)。两种使用方式不同、但都把 timer 作为观测 + 干预的一等工具。
14.12 与 Nginx / Go 的对照
Nginx:
keepalive_timeout 65;——连接 idle 65 秒后关。client_header_timeout 10s;——请求头超时。client_body_timeout 10s;——body 读超时。send_timeout 60s;——write 超时。- 全部在
nginx.conf里,一键配置。
Go net/http:
server.IdleTimeout = 120*time.Second——idle 关。server.ReadHeaderTimeout = 10*time.Second。server.ReadTimeout = 30*time.Second。server.WriteTimeout = 30*time.Second。
hyper:
- 除了
header_read_timeout(HTTP/1,装了 timer 时兼管 keep-alive 空闲)和 HTTP/2 的 keep-alive PING,其他全部靠外部 Tower 中间件或业务代码。 - 更灵活(任意组合)、学习曲线更陡(不知道的人会漏掉)。
这三者的取舍体现不同语言生态的气质——Nginx 是配置至上、Go 是内建至上、Rust 是组合至上。没有谁更好——用 Rust 做 HTTP 服务的人必须显式知道超时矩阵的存在。很多 Rust 生产事故源于"默认很宽松,没人主动去改"。
14.13 h1_parser_config: httparse::ParserConfig 的来源
Builder 第一个字段:
pub struct Builder {
h1_parser_config: httparse::ParserConfig,
...
}
httparse 是 hyper 用的 HTTP/1 解析器(第 11 章深入过)——零拷贝、极高性能。httparse::ParserConfig 允许用户调整一些严格性开关:
// hyper-1.9.0/src/server/conn/http1.rs:282-300,server Builder 暴露的两个开关
pub fn allow_multiple_spaces_in_request_line_delimiters(&mut self, enabled: bool) -> &mut Self {
self.h1_parser_config
.allow_multiple_spaces_in_request_line_delimiters(enabled);
self
}
pub fn ignore_invalid_headers(&mut self, enabled: bool) -> &mut Builder {
self.h1_parser_config
.ignore_invalid_headers_in_requests(enabled);
self
}
hyper 把 httparse::ParserConfig 整个存进 Builder、再挑出对 server 有意义的开关暴露出来("请求行分隔符允许多个空格"、"跳过而不是拒绝格式非法的 header 行")、允许高级用户微调 parser 的严格性。为什么要这么开放?——因为 HTTP/1 规范有一些"该拒绝的 malformed input" vs "必须兼容的历史畸形 input"的张力:
- 遇到老旧客户端、嵌入式设备发来的非标请求、严格 parser 会拒绝、请求进不来
- 遇到恶意 smuggling 攻击、宽松 parser 会放行、有安全风险
hyper 自己不替用户选边——把 httparse 的开关转发出来、让用户根据自己的场景调。如果对接的是现代客户端、保持默认严格;如果要接祖传设备或网关发来的请求、可能需要开某些宽松选项。
这种"把上游库的配置原样传递"是 Rust 生态一个常见模式——Builder 不重新设计一层 "hyper 的解析配置"、内部直接存 httparse::ParserConfig(字段是私有的),对外的方法只是把调用转发给 httparse 的同名开关。好处是保持一致的文档和语义——用户查 httparse 文档就能看懂、不需要学两遍。代价是绑定了上游 API——httparse 升级可能要求 hyper 跟进。这是工程上的常见权衡。
14.14 落到你键盘上
- 现在就检查你的 hyper-based 服务的配置:
timer有没有设(没有 timer、默认 30 秒的header_read_timeout是不生效的)?HTTP/2 的keep_alive_interval开了没有?tower::timeout有没有套?RequestBodyLimitLayer/Limited有没有保护 body? - 压一次 Slowloris 测试:用
slowhttptest工具对你的服务发慢请求——看header_read_timeout是否真的生效。 - 读
hyper_util::server::conn::auto::Builder——它把 http1 + http2 Builder 包在一起、executor 由你传入、timer 仍要你通过.http1().timer(..)/.http2().timer(..)设。实际生产代码应该用这个 Builder 而不是直接用 http1::Builder。
14.15 配置项背后的八条工程直觉
把本章所有源码观察压缩成 hyper 做 Builder 配置的八条工程直觉:
① 缺省值是工程经验的压缩(§14.1.1)——每个默认值都有一个"为什么不是别的"的理由。
② 两态枚举优于 Option(§14.1.2)——Dur::Default/Configured 配合 Time::Empty 表达了 Option 不能表达的用户意图。
③ 早失败胜过晚失败(§14.4.2)——显式配了 timeout 却缺 timer、buffer 下限设得过小,都在配置 / 建连时立即 panic,而不是拖到读请求头时才爆。
④ 类型系统决定性能路径(§14.5.2)——"smallvec_inline! 的 100 槽栈数组 vs 运行时构造的 smallvec!(超过 100 才上堆)"是 Rust 编译期常量 vs 运行时大小的自然分叉。
⑤ assert 拦截逻辑错误(§14.6.1)——max < 8192 是 bug 不是业务失败、panic 合理。
⑥ trait 方法传递实现者知识(§14.7.3)——is_write_vectored() 让 TLS 层告诉 hyper "别给我 vectored"。
⑦ 实验性 + 文档警示(§14.8.6 pipeline_flush)——暴露但标注、让用户做有知情的选择。
⑧ 原样转发上游 config(§14.13)——httparse::ParserConfig 直接塞进 Builder、对外只转发同名开关、不重新包装。
这八条原则跨越"默认策略、类型设计、错误处理、性能优化、向下兼容、实验管理、接口设计"七个维度——每一条单独看都平淡、合起来就是 hyper 作为 Rust HTTP 生态基石令人信任的根源。
下一章我们开始讲 HTTP/2——从 h2 crate 和 HPACK 开始,理解二进制 frame 和流控窗口如何改变整个协议模型。