Hyper 与 Tower:工业级 HTTP 栈
第17章 PING / GOAWAY / 超时:HTTP/2 的生存线
17.0 本章导读
本章的主题——HTTP/2 上长连接的生存管理——是一个经常被当成"协议细节"忽略但却决定你的服务能不能在生产跑满 24×7的主题。HTTP/1 世界里 "连接短命"让很多问题被自然回避;HTTP/2 选择"连接长命"之后,这些被回避的问题一个一个回到工程师桌上。PING、GOAWAY、超时矩阵——就是在这些问题回来敲门时给出的工程答案。
阅读本章最直接的产出有三个:
- 看懂 hyper 1.9
http2::Builder上每一个 keep-alive / shutdown / timeout 参数的协议出处和默认值的由来。 - 能读懂
hyper-1.9.0/src/proto/h2/ping.rs这个只有 514 行的文件——里面同时跑着 keep-alive 状态机和 BDP 估算算法,是本章源码学习的"单一焦点文件"。 - 在生产部署 hyper / tonic 服务时,对"需要配哪些参数、不配会怎么出事"有可以自洽的决策链。
本章和上一章互为下半场——上一章讲 HTTP/2 的流控与多路复用("躯干"),本章讲连接生存("心跳与死亡")。读完两章,你就能完整地部署一个生产级 HTTP/2 服务。本章写作时同步拉了 hyper 1.9(commit 0d6c7d5)、h2 v0.3.27(tag,commit f6237ac)两个仓库的源码——所有文件路径 + 行号都按这两个版本给;hyper 1.9.0 实际依赖 h2 0.4.6+。h2 的 src/proto/go_away.rs、src/frame/ping.rs 在 0.3.27 与 0.4.7 之间逐字节相同,src/proto/ping_pong.rs 只差一行 use;src/proto/connection.rs 在两版之间有别处的改动,本章引用的几段逻辑不变、行号有偏移(例如 go_away_gracefully 在 0.4.7 里从第 583 行开始)。行号一律以 v0.3.27 为准;本章的线路实验则用 hyper 1.9.0 + h2 0.4.7 实际跑过。
17.1 为什么 HTTP/2 需要一套"连接生存协议"
HTTP/1 的连接模型简单——一次请求一次响应,做完就复用或关掉,空闲太久 TCP RST 掉完事。HTTP/2 不一样。一条 TCP 连接可以跑几个小时,同时承载几百个 stream,进进出出。上一章讲完流控和多路复用之后,我们必须面对一组 HTTP/1 从未面对过的问题:
- 对端还活着吗? TCP 层
ESTABLISHED不等于对端的 HTTP/2 stack 还在工作。 - 中间件(NAT / 负载均衡)还认这条连接吗? NAT 表项静默过期后,下一次 write 会 RST,但我们不知道什么时候过期。
- 关闭时正在跑的 stream 怎么办? 直接
shutdown会让对端看到ECONNRESET,in-flight 请求全部失败。 - 一条被僵死的 stream 卡住的连接怎么办? stream 正常慢是合法的流控慢,但如果背后是僵尸 peer,整条 TCP 连接就成了"看起来还活着的僵尸"。
HTTP/2 spec(RFC 9113)用两种控制 frame 解决这些问题:PING 做活性探测、GOAWAY 做受控关闭。加上 hyper 在 http2::Builder 上暴露的一组超时参数,三者合起来就是 HTTP/2 的连接生存协议。
这一章我们把三块源码并排读:
hyper-1.9.0/src/proto/h2/ping.rs—— hyper 对 PING 的两用包装。hyper-1.9.0/src/server/conn/http2.rs—— 用户可见的 Builder API。h2-0.3.27/src/proto/go_away.rs与h2-0.3.27/src/proto/connection.rs—— GOAWAY 的状态机与"两阶段优雅关闭"。
如果上一章的流控是 HTTP/2 的"躯干",本章的这些机制就是心跳、呼吸与死亡宣告——把协议从"能跑"推到"能长期稳定地跑"。
17.2 PING 的协议定义与双用
PING 是 HTTP/2 里最小的 frame——8 字节 payload + 1 bit ACK flag。它的定义在 RFC 9113 §6.7,帧格式一目了然:
+---------------------------------------------------------------+
| Opaque Data (64 bits) |
+---------------------------------------------------------------+
发送方发 PING(ACK flag = 0)带任意 8 字节 payload。接收方必须立刻把相同 payload 原样回送成 PING(ACK flag = 1)。stream_id 恒为 0——PING 是连接级控制 frame,不归任何 stream。
三个看上去平凡但极重要的特征:
- payload 任意——发送方可以塞时间戳、序列号、随机数,用来匹配回包。
- 必须立刻 ACK——不受流控限制,无条件优先。这保证了 PING 能作为活性探测而不被数据队列积压。
- 连接级——一次 PING 不会打扰任何 stream 的状态。
17.2.1 hyper 为 PING 设计了两个用途
打开 hyper-1.9.0/src/proto/h2/ping.rs 的第一段文档注释(第 1-20 行),作者直接点题:
//! HTTP2 Ping usage
//!
//! hyper uses HTTP2 pings for two purposes:
//!
//! 1. Adaptive flow control using BDP
//! 2. Connection keep-alive
//!
//! Both cases are optional.
一个 8 字节的控制 frame,承担活性探测 + RTT 测量 + 窗口估算三种角色——协议的空间极简、工程的空间丰饶。这是 HTTP/2 里"协议层面的瑞士军刀"。本章我们把两个用途分别拆开。
17.2.2 payload 怎么区分"用途"
既然 hyper 要借 PING 做两件事,就必须在回包时认得出"这个 PONG 对应 keep-alive 还是 BDP"。h2 crate 在 h2-0.3.27/src/frame/ping.rs:16-17 定义了两个硬编码的"魔法 payload":
// h2-0.3.27/src/frame/ping.rs:14-17
// This was just 8 randomly generated bytes. We use something besides just
// zeroes to distinguish this specific PING from any other.
const SHUTDOWN_PAYLOAD: Payload = [0x0b, 0x7b, 0xa2, 0xf0, 0x8b, 0x9b, 0xfe, 0x54];
const USER_PAYLOAD: Payload = [0x3b, 0x7c, 0xdb, 0x7a, 0x0b, 0x87, 0x16, 0xb4];
SHUTDOWN_PAYLOAD 专门用于优雅关闭时的"等一个 RTT"探测;USER_PAYLOAD 给外部 PingPong API 用(h2 之外的用户主动发 PING——从 h2 的视角、hyper 就是这个"用户")。hyper 的 BDP/keep-alive 通过 Ping::opaque() 构造不透明 payload:
// h2-0.3.27/src/share.rs:583-592
impl Ping {
/// Creates a new opaque `Ping` to be sent via a [`PingPong`][].
///
/// The payload is "opaque", such that it shouldn't be depended on.
///
/// [`PingPong`]: struct.PingPong.html
pub fn opaque() -> Ping {
Ping { _p: () }
}
}
"opaque" 是故意的——hyper 不在乎具体 payload 是什么,只在乎"我发了一个、它 ACK 回来了"。真正的 payload 内容由 h2 crate 内部决定——PingPong::send_ping 直接忽略传入的 Ping(h2-0.3.27/src/share.rs:556-567;函数开头的注释 :557-559 说收下这个参数只是为了向前兼容——将来也许允许调用方自选 ping payload,眼下直接忽略,于是紧接着就是 let _ = ping;)、实际发出去的是 Ping::new(Ping::USER)(h2-0.3.27/src/proto/ping_pong.rs:186)、即上面那 8 字节 USER_PAYLOAD;接口层面对 hyper 隐藏。这是 API 设计的一次"最小信息暴露"——hyper 不依赖 payload 语义,h2 有自由实现;将来 h2 哪天改算法,hyper 完全不知道也不受影响。
两种 payload 共存(SHUTDOWN / USER——opaque() 在线路上就是 USER)——一个 8 字节的字段,被 h2 crate 用成"带命名空间的信号总线"。这是协议"留出 opaque 字段"最经典的工程回报。
17.2.3 用途 A:活性探测(keep-alive)
hyper 1.9 在 http2::Builder 上只暴露两个 keep-alive 相关参数(见 hyper-1.9.0/src/server/conn/http2.rs:220-240):
// hyper-1.9.0/src/server/conn/http2.rs:226-229
pub fn keep_alive_interval(&mut self, interval: impl Into<Option<Duration>>) -> &mut Self {
self.h2_builder.keep_alive_interval = interval.into();
self
}
// hyper-1.9.0/src/server/conn/http2.rs:237-240
pub fn keep_alive_timeout(&mut self, timeout: Duration) -> &mut Self {
self.h2_builder.keep_alive_timeout = timeout;
self
}
语义:
keep_alive_interval:每隔这么久在没收到 frame 时发一个 PING。默认None(关闭)。keep_alive_timeout:PING 发出后这么久没收到 ACK 就判定对端死亡,关连接。默认 20 秒。
注意:Builder 上没有 keep_alive_while_idle 这个参数。这和客户端不一样——server 在 hyper-1.9.0/src/proto/h2/server.rs:160-163 里把它硬写成 true:
// hyper-1.9.0/src/proto/h2/server.rs:156-163
let ping_config = ping::Config {
bdp_initial_window: bdp,
keep_alive_interval: config.keep_alive_interval,
keep_alive_timeout: config.keep_alive_timeout,
// If keep-alive is enabled for servers, always enabled while
// idle, so it can more aggressively close dead connections.
keep_alive_while_idle: true,
};
注释说得很直白:server 一旦开 keep-alive,就激进地清理僵死连接——不管连接上还有没有 in-flight stream。这是 server 的责任语言:client 可以"暂时没事做",但 server 要为一堆资源兜底,idle 连接是最容易被 NAT 丢表的场景。
为什么 server 默认关? 源码没有交代原因,可以这样理解:server 被许多客户端同时连,对每条连接定时发 PING 是一笔随连接数线性增长的开销。server 通常依赖客户端主动 keep-alive(客户端持连时间长、数量少、探活成本低)。当 server 的客户端生态可控(内网、同机房 gRPC),打开 server 端 keep-alive 是合理选择。
17.2.4 KeepAliveState:三态状态机
真正驱动 keep-alive 的代码在 hyper-1.9.0/src/proto/h2/ping.rs 里的 KeepAliveState(第 167-171 行):
// hyper-1.9.0/src/proto/h2/ping.rs:167-171
enum KeepAliveState {
Init,
Scheduled(Instant),
PingSent,
}
三态的转换用下面这张时序图展开最清楚:
stateDiagram-v2
[*] --> Init
Init --> Scheduled: 在 Ponger 的 poll 里 maybe_schedule<br/>schedule(last_read_at + interval)
Scheduled --> Init: timer 触发时发现期间收到过 frame<br/>(本次探活取消)
Scheduled --> PingSent: timer 触发<br/>send_ping()<br/>重置 timer 为 timeout
PingSent --> Scheduled: 收到 PONG(is_ping_sent 变 false)<br/>maybe_schedule 直接重新 schedule
PingSent --> [*]: timer 再次触发<br/>KeepAliveTimedOut<br/>关闭连接
核心逻辑在 maybe_ping(hyper-1.9.0/src/proto/h2/ping.rs:456-480)与紧随其后的 maybe_timeout(同文件 482-493 行)两个方法里:
// hyper-1.9.0/src/proto/h2/ping.rs:456-480
fn maybe_ping(&mut self, cx: &mut task::Context<'_>, is_idle: bool, shared: &mut Shared) {
match self.state {
KeepAliveState::Scheduled(at) => {
if Pin::new(&mut self.sleep).poll(cx).is_pending() {
return;
}
// check if we've received a frame while we were scheduled
if shared.last_read_at() + self.interval > at {
self.state = KeepAliveState::Init;
cx.waker().wake_by_ref(); // schedule us again
return;
}
if !self.while_idle && is_idle {
trace!("keep-alive no need to ping when idle and while_idle=false");
return;
}
trace!("keep-alive interval ({:?}) reached", self.interval);
shared.send_ping();
self.state = KeepAliveState::PingSent;
let timeout = self.timer.now() + self.timeout;
self.timer.reset(&mut self.sleep, timeout);
}
KeepAliveState::Init | KeepAliveState::PingSent => (),
}
}
// hyper-1.9.0/src/proto/h2/ping.rs:482-493
fn maybe_timeout(&mut self, cx: &mut task::Context<'_>) -> Result<(), KeepAliveTimedOut> {
match self.state {
KeepAliveState::PingSent => {
if Pin::new(&mut self.sleep).poll(cx).is_pending() {
return Ok(());
}
trace!("keep-alive timeout ({:?}) reached", self.timeout);
Err(KeepAliveTimedOut)
}
KeepAliveState::Init | KeepAliveState::Scheduled(..) => Ok(()),
}
}
三个细节值得品味:
1. sleep 被重置复用。self.sleep 是一个 Pin<Box<dyn Sleep>>——同一个 sleep future 在三态之间被"重 arm":Scheduled 时 arm 成 last_read_at + interval,PingSent 时 arm 成 now + timeout。重 arm 走的是 Timer::reset:hyper-util 的 TokioTimer 覆盖了它、原地调 tokio Sleep::reset(hyper-util 仓库 src/rt/tokio.rs:293-297),不分配新 future;Timer trait 的默认实现则是新建一个 sleep 替换旧的(hyper-1.9.0/src/rt/timer.rs:85-87)。这种"一个 timer 用到死"的写法是 Tokio 生态里非常常见的手法——回想《Tokio 源码深度解析》专栏第 11 章 time driver 里讲过,重置 timer 远比新建便宜。
2. "scheduled 时中途收到 frame 就取消"。if shared.last_read_at() + self.interval > at 这行是整个状态机的心脏——timer 到点时如果发现等待期间又收到过 frame(last_read_at 被更新),就根本不需要发 PING(对端还活着),回 Init 重调度。这个判断让 keep-alive 的成本和流量自然解耦——有流量时几乎不发 PING。
3. timer 和 ping 只有在本 poll 里走完一遍。maybe_ping 完成后立刻可能进入 maybe_timeout 判定——这是 hyper 整个 Connection::poll 的单次 pass 行为。整个机制没有独立 task——Recorder 与 Ponger 只通过一把 Arc<Mutex<Shared>> 共享状态,只在 Connection 被 driver 轮询时顺手推进。这是 async Rust 的典型"协作式"风格。
17.2.5 用途 B:BDP 估算
上一章 §16.3.10 提过 adaptive_window——打开后 hyper 借 PING 测 RTT,再根据 BDP(bandwidth-delay product)动态调整流控窗口。这套算法的完整实现也在同一个 ping.rs 里。
先看机制:
T0: hyper 发 PING(payload = opaque),记录 now
同时开始累计 receive 字节数
T1: 对端收到,立即回 PING-ACK
T2: hyper 收到 PING-ACK
RTT = T2 - T0
bandwidth ≈ bytes_received / RTT
新窗口 ≈ bandwidth × RTT × 2
hyper 把这个循环写在 Bdp::calculate 里(hyper-1.9.0/src/proto/h2/ping.rs:366-408):
// hyper-1.9.0/src/proto/h2/ping.rs:366-408(节选)
fn calculate(&mut self, bytes: usize, rtt: Duration) -> Option<WindowSize> {
if self.bdp as usize == BDP_LIMIT {
self.stabilize_delay();
return None;
}
// average the rtt
let rtt = seconds(rtt);
if self.rtt == 0.0 {
self.rtt = rtt; // 第一次
} else {
// Weigh this rtt as 1/8 for a moving average.
self.rtt += (rtt - self.rtt) * 0.125;
}
// calculate the current bandwidth
let bw = (bytes as f64) / (self.rtt * 1.5);
if bw < self.max_bandwidth {
self.stabilize_delay();
return None;
} else {
self.max_bandwidth = bw;
}
// if the current `bytes` sample is at least 2/3 the previous
// bdp, increase to double the current sample.
if bytes >= self.bdp as usize * 2 / 3 {
self.bdp = (bytes * 2).min(BDP_LIMIT) as WindowSize;
self.stable_count = 0;
self.ping_delay /= 2;
Some(self.bdp)
} else {
self.stabilize_delay();
None
}
}
算法细节:
- EWMA 平滑 RTT:
self.rtt += (rtt - self.rtt) * 0.125——权重 1/8 的指数加权移动平均。这个系数和 TCP 平滑 RTT 的系数完全一致(RFC 6298 的 α = 1/8)——Linux kernel 里的tcp_rtt_estimator用的也是1/8。源码没有注明出处,但用的正是 TCP 这套成熟公式。 - 带宽估算:
bw = bytes / (rtt * 1.5)——源码没有解释这个1.5的来由,只能当作一个经验系数看待。 - 窗口翻倍但受限:如果本次采样字节数已经达到 当前 bdp 的 2/3,说明窗口还有放大空间——翻倍(
bytes * 2)但不超过BDP_LIMIT = 16 MiB(hyper-1.9.0/src/proto/h2/ping.rs:363)。 - 稳定后降频:每一次没有触发翻倍的采样都会走
stabilize_delay(hyper-1.9.0/src/proto/h2/ping.rs:410-419)——stable_count += 1、连续两次之后ping_delay *= 4;ping_delay一旦不小于 10 秒就不再拉长(从 100 ms 起按 4 倍跳,最后一跳可能越过 10 秒)。带宽稳定时少探测——这是节能机制,避免 PING 风暴。
16 MB 上限的来历——源码注释说:
// hyper-1.9.0/src/proto/h2/ping.rs:362-363
/// Any higher than this likely will be hitting the TCP flow control.
const BDP_LIMIT: usize = 1024 * 1024 * 16;
即:HTTP/2 的 flow control window 超过 16 MB 之后,TCP 自己的流控(socket 的 send/recv buffer)会先成为瓶颈,再推大 HTTP/2 的 window 是无用功。这句 comment 背后是一整条 "层层流控" 的真实链路——应用 → HTTP/2 → TCP → 网络——hyper 把上界设在"往上推就没意义"的拐点。
收益来自哪里:打开 adaptive_window 后 hyper 会把 initial window 重置回 spec 默认的 64 KB(§16.3.10)、再借 RTT 测量 + 窗口动态更新往上探——在 BDP 远大于 64 KB 的链路上、窗口最终会涨到 BDP 的量级(按 bytes × 2 设定,会略高于 BDP)、吞吐随之上去。
17.2.6 一个具体的 BDP 数值演算
读算法不如看一次跑数。假设一条 100 Mbps、RTT = 50 ms 的链路,客户端稳定以带宽上限传输:
-
第一次采样:初始
bdp = 64 KB(SPEC_WINDOW_SIZE)。5 ms 读到 ~62 KB(差不多撑满 64 KB 窗口),BDP PING 回包 RTT 测到 55 ms。rtt = 0.055(首次采样,直接赋值)。bw = 62000 / (0.055 × 1.5) ≈ 751 KB/s——被 64 KB 窗口限制的表观速度。bytes (62000) ≥ bdp (65535) × 2/3 (= 43690)——满足翻倍条件。new_bdp = min(62000 × 2, BDP_LIMIT) = 124 000约 121 KB。ping_delay /= 2——下一次 PING 变紧,继续探。
-
第二次采样:窗口变成 121 KB。假设这次读到 ~118 KB,RTT 依旧 ~55 ms。
rtt经 EWMA 更新:0.055 + (0.055 - 0.055) × 0.125 = 0.055(无变化)。bw = 118000 / 0.0825 ≈ 1.43 MB/s——翻倍但还是低于真实带宽(100 Mbps ≈ 12.5 MB/s)。- 继续满足 2/3,再翻倍到 236 000 字节(约 230 KB)。
-
继续翻倍几次,直到窗口越过 BDP = bandwidth × RTT = 12.5 MB/s × 0.05s = 625 KB。
- 这时一个 RTT 内读到的字节数受带宽而不是窗口限制,不再随窗口增长;由于新窗口按
bytes × 2设定,窗口最终停在 BDP 的一到两倍之间。 bw接近真实带宽上限,max_bandwidth稳定——后续采样不再触发翻倍。ping_delay *= 4——PING 节奏放慢,算法进入"稳态少打扰"模式。
- 这时一个 RTT 内读到的字节数受带宽而不是窗口限制,不再随窗口增长;由于新窗口按
整个过程只需要几轮 PING 往返——从 64 KB 起飞,翻倍几次就到稳态。这就是 adaptive_window(true) 能把高 BDP 链路吞吐拉上去的物理机制。
对照 BBR 拥塞控制:BBR 也是以 RTT × 带宽估算 BDP,只不过 BBR 在 TCP 拥塞窗口层做,hyper 在 HTTP/2 流控窗口层做。这是同一套方法论在不同网络层的各自实现。《Tokio 源码深度解析》专栏第 19 章 performance 里讨论过内存和 CPU bound 的性能模型,这里的 BDP 类比网络 bound 的模型——同构不同层。
17.2.7 源码里的那个 "XXX" 注释
读 ping.rs 的 Ponger::poll(hyper-1.9.0/src/proto/h2/ping.rs:265-323)时会看到两个显眼的 comment:
// hyper-1.9.0/src/proto/h2/ping.rs:276 & 321
// XXX: this doesn't register a waker...?
两次出现——Poll::Pending 返回之前。正常写法是要 cx.waker() 注册到某处,这样等事件发生时能被唤醒。这里作者留了"XXX"——表示不确定是否有 bug。
为什么实际能工作?因为 Ponger::poll 是在 Connection 的大 poll 里被调用的——Connection 被 tokio runtime 按 I/O 事件或 timer 事件唤醒时,整个大 poll 重跑,ping 的状态机被自动推进。即:别处已经注册了 waker,这里不用——只是在重入时跳过这段的判定。但如果以后 Connection 的 poll 结构变动、ping 被独立成 task,这段就会漏唤醒。
这种 "XXX" 注释在工业级源码里很常见——是工程现实的诚实标记,不是代码缺陷。它告诉读者:"当前架构下它能工作,但前提条件脆弱;如果你要重构这块,先理解这个前提"。看到 XXX 不要急着 fix——先理解它依赖什么。
17.2.8 风险:PING flood 与 gRPC 的 ping strikes
PING 必须立即 ACK 的强制语义使它成为 DoS 载体——恶意客户端每秒发几十万个 PING,server 必须每个都 ACK,放大 CPU 消耗。防御分两个方向。
实现层的经验性防御——gRPC core 的 "ping strikes":server 端如果在没有数据往来的情况下、收到 PING 的间隔小于 GRPC_ARG_HTTP2_MIN_RECV_PING_INTERVAL_WITHOUT_DATA_MS(默认 5 分钟),就记一次 strike、累计超过 GRPC_ARG_HTTP2_MAX_PING_STRIKES(默认 2)就以 ENHANCE_YOUR_CALM GOAWAY 关连接。这是非协议规范的实现侧防御。
hyper 1.9 的实现现状:hyper 自己不做入流 PING 计数限流——Shared::is_ping_sent()(hyper-1.9.0/src/proto/h2/ping.rs:345)只管出流 PING 至多一个 outstanding。入流 PING 由 h2 crate 直接 ACK;h2 唯一的天然节流是 Connection::poll 在读下一帧之前必须先把 pending pong 写出去(h2-0.3.27/src/proto/connection.rs:195 的 ready!(self.inner.ping_pong.send_pending_pong(..)))——写不出去就不再读、对端塞不进更多 PING。如果你的 hyper 服务直接暴露到公网,更细的 PING flood 防御要在前置反向代理(如 Envoy 的 max_outbound_control_frames)做——hyper 本身的防御面局限在对自己发出流的节流。这是一个经常被忽略的生产陷阱——许多 Rust 服务直接 expose 到公网 LB 之外,默认配置下没有 PING 防御。
17.3 GOAWAY:受控关闭的协议
PING 是"探活",GOAWAY 是"告别"。它是 HTTP/2 里优雅关闭 (graceful shutdown) 的唯一协议手段。
17.3.1 GOAWAY 的协议定义
+-+-------------------------------------------------------------+
|R| Last-Stream-ID (31) |
+-+-------------------------------------------------------------+
| Error Code (32) |
+---------------------------------------------------------------+
| Additional Debug Data (*) |
+---------------------------------------------------------------+
关键字段:
- Last-Stream-ID:发送方可能已经处理、或可能还会处理的最大 stream ID(RFC 9113 §6.8)。大于此 ID 的 stream 不会被处理——对端应该把这些 stream 重试到别的连接。
- Error Code:0 = NO_ERROR(正常关闭),其他是各种协议错误码。
- Debug Data:可选的不透明调试数据(通常是一段文本,比如 h2 发的
too_many_resets),给运维看。
stream_id 恒为 0。发出 GOAWAY 后,发送方仍然可以接收/发送已经打开的 stream——GOAWAY 不等于"马上断",而是"通告:再也不接新 stream 了"。真正的断连由 TCP 关闭触发。
17.3.2 两阶段 GOAWAY:优雅关闭的精髓
RFC 7540 §6.8(RFC 9113 继承)明确建议了两阶段:
A server that is attempting to gracefully shut down a connection SHOULD send an initial GOAWAY frame with the last stream identifier set to 2^31-1 and a NO_ERROR code. This signals to the client that a shutdown is imminent and that initiating further requests is prohibited. After allowing time for any in-flight stream creation (at least one round-trip time), the server can send another GOAWAY frame with an updated last stream identifier.
翻成工程步骤:
阶段 1: 发 GOAWAY(last_stream_id = 2^31 - 1, error = NO_ERROR)
告诉对端"我要关了,别再发新 stream"——但不拒绝已有 stream。
此时 server 仍然处理新 stream 的 HEADERS + 数据——
这只是一个"警告 + 等至少 1 RTT"。
... 等 1 RTT(让 race 掉的 in-flight stream 都到齐) ...
阶段 2: 发 GOAWAY(last_stream_id = <实际最高已处理的 stream>, error = NO_ERROR)
承诺"处理到此 ID 为止"。更大的 ID 对端视为未处理,重试到别的连接。
... 等所有承诺过的 stream 结束 ...
最后: TCP 半关闭 → FIN → 连接 close。
关键——第一次 GOAWAY 的 last_stream_id 设为 2^31-1(StreamId::MAX),这是协议级的"软警告"。对端理解为"不要再发起新请求"(RFC 原话:initiating further requests is prohibited),但它在收到 GOAWAY 之前已经发出的 stream 仍会被接收处理。第二次 GOAWAY 才给出硬承诺。
对比单阶段:server 直接发 GOAWAY(last_stream_id = 5)——这等于说"大于 5 的我不管了"。但客户端可能已经在发 stream 7、9、11 的数据——这些会被直接丢弃,只能靠客户端重试到别的连接,不重试就是请求失败。
17.3.3 h2 怎么实现两阶段
在 h2-0.3.27/src/proto/connection.rs:574-597 可以看到完整实现:
// h2-0.3.27/src/proto/connection.rs:574-597
// Graceful shutdown only makes sense for server peers.
pub fn go_away_gracefully(&mut self) {
if self.inner.go_away.is_going_away() {
// No reason to start a new one.
return;
}
// According to http://httpwg.org/specs/rfc7540.html#GOAWAY:
//
// > A server that is attempting to gracefully shut down a connection
// > SHOULD send an initial GOAWAY frame with the last stream
// > identifier set to 2^31-1 and a NO_ERROR code. This signals to the
// > client that a shutdown is imminent and that initiating further
// > requests is prohibited. After allowing time for any in-flight
// > stream creation (at least one round-trip time), the server can
// > send another GOAWAY frame with an updated last stream identifier.
// > This ensures that a connection can be cleanly shut down without
// > losing requests.
self.inner.as_dyn().go_away(StreamId::MAX, Reason::NO_ERROR);
// We take the advice of waiting 1 RTT literally, and wait
// for a pong before proceeding.
self.inner.ping_pong.ping_shutdown();
}
注意最后两行——发完第一个 GOAWAY(StreamId::MAX) 之后,h2 借 PING 精确实现"等一个 RTT":调用 ping_shutdown()(h2-0.3.27/src/proto/ping_pong.rs:87-94)排队一个 payload 为 SHUTDOWN_PAYLOAD 的 PING,等对端回 ACK;recv_ping 认出这个 payload 后返回 ReceivedPing::Shutdown、Connection::poll 随即 go_away(last_processed_id, NO_ERROR) 发第二个 GOAWAY(h2-0.3.27/src/proto/connection.rs:520-528)。收到 ACK 的那一刻,协议层可以保证——所有 stream-开始 的 HEADERS 都必然已经到达或绝对不会再到达:h2 先缓冲 GOAWAY、再发 PING(Connection::poll2 里的注释写明"The order here matters"),对端按序先收到 GOAWAY、从此不该再开新 stream;它在回 ACK 之前发出的 HEADERS,经同一条 TCP 按序传输,一定先于 ACK 到达。然后 h2 才发第二个 GOAWAY 承诺 last_stream_id。
这里 PING 的作用不是活性探测,而是协议层的 "memory barrier"——同一个 PING 帧机制的第三种用途。回头看 17.2.2 的 SHUTDOWN_PAYLOAD 那 8 字节魔法常数——就是专门给这一次 PING 用的。
17.3.4 hyper 的 graceful_shutdown 入口
hyper 的用户入口极其简单(hyper-1.9.0/src/server/conn/http2.rs:78-80):
// hyper-1.9.0/src/server/conn/http2.rs:68-80
/// Start a graceful shutdown process for this connection.
///
/// This `Connection` should continue to be polled until shutdown
/// can finish.
///
/// # Note
///
/// This should only be called while the `Connection` future is still
/// pending. If called after `Connection::poll` has resolved, this does
/// nothing.
pub fn graceful_shutdown(mut self: Pin<&mut Self>) {
self.conn.graceful_shutdown();
}
一层 thin wrapper。真正决定时机的代码在 hyper-1.9.0/src/proto/h2/server.rs:178-190:
// hyper-1.9.0/src/proto/h2/server.rs:178-190
pub(crate) fn graceful_shutdown(&mut self) {
trace!("graceful_shutdown");
match self.state {
State::Handshaking { .. } => {
self.close_pending = true;
}
State::Serving(ref mut srv) => {
if srv.closing.is_none() {
srv.conn.graceful_shutdown();
}
}
}
}
两种情况的处理——握手还没完成时,设 close_pending = true,等握手一完就关;已进入 serving,直接委托 h2 crate 的 go_away_gracefully。这个设计细节说明了一个 production-grade 库的"启动期关闭"问题——绝对不能被忽略。
17.3.5 部署场景:K8s rolling update
最常见的 GOAWAY 触发场景是容器滚动发布。K8s 给 pod 发 SIGTERM 开始终止流程:
T0: kubelet 发 SIGTERM
T1: pod 收到 SIGTERM → 把 ready gate 关掉,不再接新流量
T2: hyper 对所有 live connection 调 graceful_shutdown()
- 第一次 GOAWAY(2^31-1, NO_ERROR)
- h2 发 SHUTDOWN_PAYLOAD PING
T3: 收到 PING ACK(~1 RTT)
T4: 第二次 GOAWAY(last_processed_id, NO_ERROR)
- client 收到后把 last_stream_id 之后的 stream 重试到新 pod
T5: 所有已承诺 stream 结束
T6: TCP FIN
整个过程没有请求被粗暴中断。K8s 默认给 pod 30 秒 (terminationGracePeriodSeconds) 完成这个流程——足够绝大多数请求跑完。preStop hook 占用的是同一段宽限期,并不会延长它;需要更长时间就调大 terminationGracePeriodSeconds。
对比 HTTP/1:HTTP/1 的 graceful_shutdown(hyper-1.9.0/src/server/conn/http1.rs:137-139)就一行 self.conn.disable_keep_alive()——第 14 章讲过的那个开关,靠"响应写完不 reset 状态机"实现优雅。HTTP/2 的多 stream 并行使得单条连接的"完成时间"不可预测——GOAWAY 两阶段保证了不管对端正在发什么,都不会突然被切断。
17.3.6 Error Code 的工程价值
NO_ERROR 是最常见的 GOAWAY 原因,但其他错误码有明确用途。完整列表见 h2-0.3.27/src/frame/reason.rs:
| Error Code | 何时触发 | hyper 里的出处 |
|---|---|---|
NO_ERROR (0x0) |
正常关闭、滚动升级 | go_away_gracefully |
PROTOCOL_ERROR (0x1) |
收到违反 spec 的 frame | h2 内部 |
INTERNAL_ERROR (0x2) |
实现内部出错 | hyper:service 返回错误时以 RST_STREAM 重置该 stream,原因码一般就是它(hyper-1.9.0/src/proto/h2/server.rs:459-462、hyper-1.9.0/src/error.rs:317-320);h2 自身不发 |
FLOW_CONTROL_ERROR (0x3) |
对端超出流控窗口 | h2 h2-0.3.27/src/proto/streams/recv.rs(§16.6.4) |
SETTINGS_TIMEOUT (0x4) |
SETTINGS ACK 超时 | h2 只在 reason.rs 定义、0.3.27 没有任何发送点 |
FRAME_SIZE_ERROR (0x6) |
frame 超出 max_frame_size | h2 codec |
REFUSED_STREAM (0x7) |
超过 max_concurrent_streams 等原因拒绝新 stream | h2 h2-0.3.27/src/proto/streams/recv.rs / h2-0.3.27/src/proto/streams/streams.rs |
ENHANCE_YOUR_CALM (0xb) |
rate-limited,让客户端冷静 | h2 自己也发:pending-accept reset 超限、本地错误 reset 超限、CONTINUATION 过多 |
INADEQUATE_SECURITY (0xc) |
TLS 版本/密码套件不足 | TLS 层 |
生产中 ENHANCE_YOUR_CALM 是一个常被忽视但很实用的错误码——和 HTTP 层的 429 不同,GOAWAY(ENHANCE_YOUR_CALM) 是连接级的——"这条连接上你太多了,请消停,下次建新连接过来"。h2 crate 在 §16.6 讲的几种 reset 超限、以及 CONTINUATION 帧过多(h2-0.3.27/src/codec/framed_read.rs:303-308)时会主动发这个 code;收到对端发来的也会正确传播到上层。hyper 本身不主动发——主动发 GOAWAY 的决策留给 h2 的防御逻辑和上层业务代码。
17.4 SETTINGS 超时:容易被忽视的第三种超时
除 keep-alive 和 header read 以外,HTTP/2 还有一个常被忽视的超时:SETTINGS ACK 超时。
17.4.1 SETTINGS 的 ACK 机制
HTTP/2 连接建立的第一步——双方交换 SETTINGS frame。每个 SETTINGS 必须被 ACK:
Client → Server: SETTINGS frame (initial_window_size, max_concurrent_streams, ...)
Server → Client: SETTINGS ACK (空 payload, ACK flag = 1)
Server → Client: SETTINGS frame
Client → Server: SETTINGS ACK
这个来回保证了双方对协议参数的约定达成一致——发送方要等 ACK 才能确认新参数已被对端采用;在收到对方的 SETTINGS 之前,双方一律按 RFC 规定的初始值行事(客户端可以在 preface 之后立即发帧,不必等服务端的 SETTINGS,RFC 9113 §3.4)。
17.4.2 恶意 peer 拒绝 ACK SETTINGS
spec 没规定 ACK 的 deadline、只给了一个错误码:RFC 9113 §6.5.3 说如果 ACK 在"合理时间"内没到、可以以 SETTINGS_TIMEOUT 关连接。恶意客户端可以在 TCP 握手后永远不发 SETTINGS ACK、占着 fd 和内存。h2 0.3.27(0.4.7 同样)并没有实现这个超时——h2 的 src/proto/settings.rs 里没有任何计时逻辑、Reason::SETTINGS_TIMEOUT 只在 h2-0.3.27/src/frame/reason.rs 定义、整个 crate 没有发送点。hyper 也没有对应参数。生产里这类"握手挂起"要靠外层兜底:把 serve_connection 的 future 包一层整体超时、或者在前置 LB 上配连接握手超时。
17.4.3 max_header_list_size:16 KB 的来历
这个参数穿过"连接安全"和"协议协商"两个关切点:
// hyper-1.9.0/src/server/conn/http2.rs:266-269
pub fn max_header_list_size(&mut self, max: u32) -> &mut Self {
self.h2_builder.max_header_list_size = max;
self
}
默认值在 hyper-1.9.0/src/proto/h2/server.rs:40:
// hyper-1.9.0/src/proto/h2/server.rs:40
const DEFAULT_SETTINGS_MAX_HEADER_LIST_SIZE: u32 = 1024 * 16; // 16kb
16 KB——和 hyper HTTP/1 侧 max_buf_size 的量级一致。业务中一个 "Bearer" token 可能 1-4 KB,加上 Cookie、User-Agent、Host、Accept-* 等正常 header,16 KB 能容纳绝大多数请求。确实需要更大(比如某些 SAML 场景)再单独调大。
这个阈值也是防御 HPACK 内存攻击 的主要防线——攻击者塞几 MB 的压缩后 header,HPACK 解压再放入 HeaderMap 会放大好几倍内存。16 KB 的硬限把单请求放大上限压到几十 KB 级别。
17.5 把三种超时放到一张图上
综合上一章 keep-alive 和本章 PING/GOAWAY,HTTP/2 server 的完整连接生命时序如下:
sequenceDiagram
participant C as Client
participant S as Server
C->>S: TCP SYN
S->>C: TCP SYN-ACK
C->>S: Preface (PRI * HTTP/2.0 ...)
C->>S: SETTINGS
S->>C: SETTINGS
S->>C: SETTINGS ACK
C->>S: SETTINGS ACK
Note over C,S: 握手完成 - 进入 Serving
loop keep-alive 周期
S-->>S: 定时器 interval 到
S->>C: PING (opaque)
C->>S: PING ACK
Note over S: update_last_read_at<br/>重置 timer 为 interval<br/>(hyper-1.9.0/src/proto/h2/ping.rs:349)
end
par Stream 1
C->>S: HEADERS (:method=GET, :path=/)
S->>C: HEADERS + DATA
and Stream 2
C->>S: HEADERS (:method=POST)
C->>S: DATA...
S->>C: HEADERS + DATA
end
Note over S: 收到 SIGTERM, 调 graceful_shutdown
S->>C: GOAWAY (last_id = StreamId::MAX = 2^31-1, NO_ERROR)
S->>C: PING (Ping::SHUTDOWN payload)
C->>S: PING ACK
Note over S: 确认 1 RTT 过去
S->>C: GOAWAY (last_id = 真实值, NO_ERROR)
Note over C,S: in-flight stream 继续完成
S->>C: HEADERS + DATA (Stream 1 结束)
S->>C: HEADERS + DATA (Stream 2 结束)
S->>C: TCP FIN
C->>S: TCP FIN
三种超时分别覆盖三个阶段:
| 阶段 | 超时机制 | 默认值 | hyper 可调? |
|---|---|---|---|
| 握手 | 无内建(h2 0.3.27 未实现 SETTINGS_TIMEOUT,§17.4.2) | — | 否,需外层包 timeout |
| 稳态 | keep_alive_interval + keep_alive_timeout |
None(关闭)+ 20s(hyper-1.9.0/src/proto/h2/server.rs:71-72,Builder 方法 hyper-1.9.0/src/server/conn/http2.rs:226、237) |
✓ |
| 关闭 | graceful(由 pod 级 terminationGracePeriod 兜底) | 无内建 | 通过 graceful_shutdown 触发(hyper-1.9.0/src/server/conn/http2.rs:78 → hyper-1.9.0/src/proto/h2/server.rs:178 → h2-0.3.27/src/server.rs:549 → go_away_gracefully,h2-0.3.27/src/proto/connection.rs:575-595) |
请求级超时(header / body / 业务处理时间)不属于 HTTP/2 协议范畴——由 tower 中间件和业务代码负责(HTTP/1 侧 hyper 另有一个 header_read_timeout,HTTP/2 没有对应项)。HTTP/2 的参数只管连接生存。
17.5.1 对照 HTTP/1 与其他语言栈的超时矩阵
把 hyper HTTP/2 的超时拓扑放回更大的生态里看一眼。下表是生产级 HTTP 服务四种常见栈的超时覆盖:
| 超时项 | hyper (HTTP/2) | Nginx | Go net/http2 | Envoy |
|---|---|---|---|---|
| Header 读超时(连接建立后) | 无(HTTP/1 侧有 header_read_timeout(hyper-1.9.0/src/server/conn/http1.rs:344),HTTP/2 的 http2::Builder 里没有对应项,见 hyper-1.9.0/src/server/conn/http2.rs) |
client_header_timeout 60s |
ReadHeaderTimeout |
request_headers_timeout |
| keep-alive ping | keep_alive_interval / timeout |
无(文档没有主动发 PING 的配置;空闲关闭见下一行,http2_idle_timeout 自 1.19.7 起已废弃,并入 keepalive_timeout) |
ReadIdleTimeout / PingTimeout |
connection_keepalive.interval / timeout |
| 空闲连接超时 | 无内建(靠 LB 前置) | keepalive_timeout 75s |
IdleTimeout |
idle_timeout |
| 优雅关闭 | graceful_shutdown(二阶段 GOAWAY + PING) |
worker_shutdown_timeout |
Server.Shutdown(ctx) |
Envoy drain_timeout |
| 全请求耗时 | 无(靠 tower::timeout) | 无(client_body_timeout 只管两次读之间的间隔,不管整个 body) |
WriteTimeout |
max_stream_duration |
三点差异值得体会:
- hyper 把 "空闲连接超时"推出去——spec 并不强制 server 内部做 idle timeout。Nginx、Go 都选择内建;hyper 则交给前置 LB 或调用方自己在连接外层兜底(tower 中间件按请求计时,管不到连接空闲)。这不是懒,是"组件边界"的主张——hyper 只做协议层的事,运维策略留给上层。
- hyper 的 "请求级超时"也交给 tower——这和 Go 的
WriteTimeout不一样。Go 的做法把"不论请求内容,最长写 30 秒"硬编进 server;hyper 的做法是"每个请求可独立配超时"(tower::timeout::Timeout),灵活但需要显式。 - hyper 默认配置最宽松——
keep_alive_interval: None(h2 侧Config::default(),hyper-1.9.0/src/proto/h2/server.rs:71)、无 idle timeout、无请求级超时。这是 Rust "生态组装" 风格的代价——默认值不保守,必须显式配置生产参数。所以 Rust 服务第一次上线时容易在这里中招——开发阶段测不出来,生产阶段 NAT + idle 打出来。
看清这张表,下次配 hyper server 时逐项问一遍——每一项我有显式决策吗?没有的留心。这是 HTTP 服务生产化的底线 checklist。
17.6 实战案例:gRPC 长连接的 PING 风暴
下面是一个示意案例(连接数等数字为假设)——某内网 gRPC 系统。
现象:server 上有一份与业务流量无关、随连接数线性增长的固定开销,来自 PING 帧的收发。同时客户端 metrics 显示 PING ACK 回包速率异常地高。
原因:客户端(tonic)配置:
Channel::builder(uri)
.http2_keep_alive_interval(Duration::from_secs(1)) // ❌ 太频繁
.keep_alive_timeout(Duration::from_secs(1))
.keep_alive_while_idle(true)
.connect().await
每秒发 PING,对每条连接都探——5000 条连接 × 1 PING/s = 5000 PING/s 进 server。每个必须 ACK(spec 硬要求)。每个 PING 都意味着一次读、一次写和一次 task 唤醒。
修复:把 interval 调到 30 秒,timeout 调到 20 秒——5000 条连接每秒 ~167 PING,PING 处理的 CPU 占比回到可忽略。功能上探活仍及时(最坏 50 秒发现对端死),PING 频率降到原来的 1/30。
教训:
- keep-alive interval 不是越短越好——和业务真实需求匹配。只要明显短于路径上 NAT / LB 回收空闲连接的时间就够了,30 秒通常已经绰绰有余。
- 每条连接独立发 PING——总 PING/s = 连接数 × (1 / interval)。这是一个连接数放大的成本。
- client 侧配置的成本落在 server CPU 上——client 开发者往往只感知到"keep-alive 有效",看不到 server 侧的代价。上线前必须双方协商。
17.6.1 再一次:NAT 漂移与 iOS 后台挂起
"keep-alive 到底能救什么"这个问题,在移动端最有发言权。两个真实场景:
场景一:NAT 表项淘汰。运营商的 NAT 表通常在 TCP 连接几分钟无流量后就回收——不同运营商、不同地区差异很大。iOS WiFi 到 4G 的切换会换 IP 地址,等价于新连接。这意味着任何默认 TCP keep-alive(2 小时)都没救——NAT 比它先掉。解法:HTTP/2 level 的 keep-alive interval 必须小于 NAT 淘汰周期——30 秒是经验值。
场景二:iOS 后台模式下的连接冻结。iOS 在 app 切入后台后会冻结网络栈——socket 看起来还在,但 read/write 都不会返回。hyper 的 keep-alive PING 发出去没回包,keep_alive_timeout 触发关连接。这是正确的行为——但 app 切回前台时必须重建连接。如果 client 侧没做这个兜底(比如漏掉 onResume 里的重连),用户会看到"首次请求卡 20 秒然后失败"——正是 keep_alive_timeout 默认值。许多 Android / iOS 客户端早期会掉进这个坑,之后才学会"前台重连"的模式。
这两个场景回答了一个常见疑问——"TCP 不是有 keep-alive 吗,HTTP/2 再搞一套干嘛?"答案是:TCP keep-alive 的默认节奏(Linux 默认空闲 2 小时才开始探测)对 HTTP 应用来说太粗了。HTTP/2 的 PING 粒度(秒级)才是贴合业务的。移动时代两者兼备是必要的:TCP 兜底极端场景(几十分钟无活动的连接),HTTP/2 兜底日常场景(NAT、后台、短暂切换)。
17.7 GOAWAY 与服务发现的耦合
GOAWAY 让 HTTP/2 的快速切换变得干净——但也引入新挑战:客户端需要在 GOAWAY 后重新解析服务发现。
T0: client 连 server-A (IP 10.0.0.5)
T1: K8s 滚动更新,server-A 发 GOAWAY
T2: client 关本地 connection 对象
T3: client 下一次请求 → 建新连接 → 解析 DNS
T4: DNS 返回 new server-B (IP 10.0.0.7)
T5: client 连上 server-B
T3-T5 之间如果 DNS 结果还是旧的(被缓存住),client 会再连回 10.0.0.5——但那个 IP 背后的 pod 已退出,TCP ECONNREFUSED。这是 GOAWAY + 陈旧 DNS 的经典 race。
缓解方案:
- 负载均衡器前置:GOAWAY 由 LB 处理,不直接暴露给 client。LB 自己维护后端列表,GOAWAY 时切新后端即可。
- 服务发现推送:Envoy / Istio 等 service mesh 在 endpoint 变化时通过 xDS 主动把新列表推给各个代理,不依赖 DNS TTL。
- 客户端重试 + 解析新 endpoint:tonic / hyper-util 的 pool(第 20 章详讲)可以在连接失败时换 endpoint 重试。
做 hyper HTTP/2 server 的生产部署,绝大多数情况是LB 前置——让 LB 承担 GOAWAY 与服务发现的耦合。直接暴露 hyper 的场景(某些 CLI 工具的 server 模式),需要在 client 侧做重试和服务发现。
17.8 和 HTTP/3 / QUIC 的对照
HTTP/3(RFC 9114)建立在 QUIC 之上——连接存活机制有相似也有本质差异:
| 机制 | HTTP/2 | HTTP/3 / QUIC |
|---|---|---|
| 探活 | PING frame | QUIC 的 PING frame(QUIC 层) |
| 受控关闭 | GOAWAY | HTTP/3 自己的 GOAWAY 帧(RFC 9114 §5.2),最终由 QUIC 的 CONNECTION_CLOSE 关连接 |
| RTT 估算 | 借 PING 往返计时(hyper 在本地记发送时刻) | QUIC 内建 RTT 估算 |
| 丢包恢复 | 依赖 TCP | QUIC 自己做,一个 stream 丢包不阻塞其他 stream |
| 连接迁移 | NAT 表超时后失效 | Connection ID 保留 session |
QUIC 把连接标识从 (IP, Port) 四元组解耦出来——用一个 Connection ID 标识。客户端 IP 变了(4G 切 5G、WiFi 切蜂窝),连接仍然有效,不需要重建 session。地址变化(包括 NAT 重绑定)不再断连——这是 HTTP/2 靠 PING 解决不了的;但 NAT 表项的空闲过期依旧存在,QUIC 同样要靠 PING 帧保活。
hyper 1.9 主线不内建 QUIC/HTTP-3。Rust 生态上的 QUIC 实现在 quinn,HTTP/3 在 h3 crate。本专栏聚焦 hyper 1.9 + h2,对 h3 不展开——但你理解了 HTTP/2 的连接生存机制后,再读 h3 会发现很多决策都是在解 HTTP/2 在移动场景下的遗留问题。
17.9 生产推荐配置
把前面拆散的参数综合起来——HTTP/2 server "连接生存"部分的推荐配置:
use std::time::Duration;
use hyper_util::rt::{TokioExecutor, TokioTimer};
let mut http = hyper::server::conn::http2::Builder::new(TokioExecutor::new());
http.timer(TokioTimer::new())
// --- 上一章的流控参数 ---
.initial_connection_window_size(2 * 1024 * 1024) // 2 MB
.initial_stream_window_size(1 * 1024 * 1024) // 1 MB
// adaptive_window(true) 会把上面两个 initial 值重置回 65535——二选一(§16.3.10)
.max_concurrent_streams(200)
.max_send_buf_size(256 * 1024)
// --- 本章的连接生存参数 ---
.keep_alive_interval(Some(Duration::from_secs(30)))
.keep_alive_timeout(Duration::from_secs(20))
// --- CVE-2023-44487 相关:h2 默认 20,这里为合法取消留余量放宽到 50 ---
.max_pending_accept_reset_streams(Some(50))
.max_local_error_reset_streams(Some(1024))
// --- HPACK 保护 ---
.max_header_list_size(16 * 1024);
let conn = http.serve_connection(io, svc);
// 当 SIGTERM 来时,pin 住这个 conn 调 graceful_shutdown,再继续 poll。
上面是裸用 hyper 的写法。tonic 的 Server 不会继承 hyper 在并发流和本地错误 reset 上的默认限制:它把 max_concurrent_streams、http2_max_local_error_reset_streams 默认设为 None 并传给 hyper,覆盖了 hyper 的 200 和 1024(tonic-v0.14.5/tonic/src/transport/server/mod.rs:127、136、778、783),所以用 tonic 时这两项要在 Server::builder() 上显式配。
客户端(tonic / hyper-util Pool)对称配置:
hyper_util::client::legacy::Builder::new(TokioExecutor::new())
.timer(TokioTimer::new()) // 开 keep-alive 必须给 timer,否则建连时 panic
.http2_keep_alive_interval(Duration::from_secs(30))
.http2_keep_alive_timeout(Duration::from_secs(20))
.http2_keep_alive_while_idle(false) // 没 stream 时别探
.build_http() // 或 .build(connector)
两个参数各管各的:keep_alive_timeout 只衡量本端那一个 PING 的 ACK 多久回来,对端收到 PING 就会立刻 ACK,和对端自己的 interval 无关——它只需比最坏情况下的网络往返宽裕得多;interval 则要短于路径上 NAT / LB 回收空闲连接的时间。如果对端是 gRPC core 实现的服务,还要注意 §17.2.8 的 ping strikes:没有数据往来时 PING 发得比它的 MIN_RECV_PING_INTERVAL_WITHOUT_DATA(默认 5 分钟)还勤,会被以 ENHANCE_YOUR_CALM GOAWAY 掉。
17.9.1 错误如何从 ping.rs 一路爬上 Connection future
以 KeepAliveTimedOut 为例——这个错误的产生路径是 HTTP/2 连接"异常关闭"里最常见的一条。它如何从 ping.rs 里的一次 maybe_timeout 调用,爬上到用户代码 connection.await? 的返回值?追踪一遍:
1. KeepAlive::maybe_timeout 判定 sleep 到期(ping.rs:484-488)
→ 返回 Err(KeepAliveTimedOut)
2. Ponger::poll 收到 Err(ping.rs:311-317)
→ 设置 locked.is_keep_alive_timed_out = true
→ 返回 Poll::Ready(Ponged::KeepAliveTimedOut)
3. hyper-1.9.0/src/proto/h2/server.rs 的 Serving::poll_ping 拿到 Ponged::KeepAliveTimedOut(server.rs:348-351)
→ 调 h2 的 abrupt_shutdown(NO_ERROR):发 GOAWAY(last_stream_id=0, NO_ERROR) 并关连接
→ 随后 poll_accept 返回 None,Recorder::ensure_not_timed_out(server.rs:320-322)
→ 返回 Err(KeepAliveTimedOut.crate_error())
4. crate::Error 沿着 Future::poll 向上传播(错误出自 hyper,不经过 h2)
→ Serving::poll_server → Err
→ hyper::proto::h2::Server::poll → Err
→ hyper::server::conn::http2::Connection::poll → Err
5. 用户代码
let conn = http.serve_connection(io, svc);
match conn.await {
Ok(()) => { /* 连接正常结束 */ }
Err(e) => {
// KeepAliveTimedOut 不是公开类型,没法 downcast;
// e.is_timeout() 为 true,source() 链是
// "keep-alive timed out" → "operation timed out"
tracing::warn!("http2 connection ended: {}", e);
}
}
这条错误通路有两个细节值得注意:
1. KeepAliveTimedOut::crate_error() 在 hyper-1.9.0/src/proto/h2/ping.rs:498-501 里把 KeepAliveTimedOut 包进 crate::Error:
// hyper-1.9.0/src/proto/h2/ping.rs:498-502
impl KeepAliveTimedOut {
pub(super) fn crate_error(self) -> crate::Error {
crate::Error::new(crate::error::Kind::Http2).with(self)
}
}
crate::Error::with(self)(hyper-1.9.0/src/error.rs:291)把 KeepAliveTimedOut 挂成 hyper Error 的 cause——让上层可以通过 std::error::Error::source() 一路下钻拿到 KeepAliveTimedOut。用 hyper 1.9.0 + h2 0.4.7 实测:e 显示为 http2 error,e.source() 依次是 keep-alive timed out、operation timed out,e.is_timeout() 返回 true(is_timeout 就是沿 source 链找 TimedOut,hyper-1.9.0/src/error.rs:277-283)。
2. 共享的 is_keep_alive_timed_out 标志位:这个 bool 存在 Shared 里——Ponger::poll 在超时时把它置 true(ping.rs:314),Recorder::ensure_not_timed_out 读它。计时在 Ponger 这边,而 Recorder 被 clone 到连接和 body 各处(服务端在 accept 结束时查,server.rs:321;客户端在更多读路径上查),"一旦我们决定死,之后再查的地方都拿到同一个错误"。这种"decision sticking"是状态机里处理"终态"的典型手法——一旦进入 dead 态,就再也不能从里面逃出。
17.9.2 一次完整的连接生存时序(Wireshark 视角)
一次 HTTP/2 生存流程放进 Wireshark,大致会是下面这样(示意,时间与编号是编的;PING payload、两次 GOAWAY 的 last_stream_id 与先 GOAWAY 后 PING 的顺序,用 hyper 1.9.0 + h2 0.4.7 实测一致):
No. Time Frame Payload
1 0.000 SYN -
2 0.001 SYN-ACK -
3 0.001 ACK -
4 0.002 MAGIC (PRI * HTTP/2.0\r\n\r\nSM...) -
5 0.003 SETTINGS INITIAL_WINDOW_SIZE=1048576, ...
6 0.003 SETTINGS (server → client)
7 0.004 SETTINGS ACK -
8 0.004 SETTINGS ACK -
9 0.010 HEADERS (stream 1) :method=GET, :path=/
10 0.012 HEADERS (stream 1) :status=200
11 0.012 DATA (stream 1, END_STREAM) "hello"
... (正常交互)
100 30.001 PING 0x3b 7c db 7a 0b 87 16 b4 (USER)
101 30.003 PING (ACK) 0x3b 7c db 7a 0b 87 16 b4
... (每 30 秒一轮)
500 180.000 GOAWAY last_stream_id=0x7FFFFFFF, NO_ERROR
501 180.000 PING 0x0b 7b a2 f0 8b 9b fe 54 (SHUTDOWN)
502 180.003 PING (ACK) 0x0b 7b a2 f0 8b 9b fe 54
503 180.004 GOAWAY last_stream_id=493, NO_ERROR
... (in-flight stream 完成)
520 180.050 DATA (stream 493, END_STREAM) ...
521 180.051 FIN -
522 180.052 FIN, ACK -
几个检查点:
- Frame 4 的 MAGIC 是
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n共 24 字节——每条 HTTP/2 连接的"握手暗号",spec RFC 9113 §3.4。 - Frame 101 的 PING ACK 和 Frame 100 的 payload 完全一致(
0x3b 7c db 7a 0b 87 16 b4)——hyper 的Ping::opaque()在 h2 里落成Ping::USER(§17.2.2),线路上看到的就是USER_PAYLOAD。 - Frame 501 的 PING payload 是
0x0b 7b a2 f0 8b 9b fe 54——就是 §17.2.2 看到的SHUTDOWN_PAYLOAD常数、§17.3.3 的ping_shutdown()发出的。这是 GOAWAY 两阶段的"等 1 RTT"机制在线路上的物证。 - Frame 500 的
last_stream_id=0x7FFFFFFF= 2^31-1 = 2147483647——第一阶段的软警告。Frame 503 的last_stream_id=493才是真实已处理的最高 stream——第二阶段的硬承诺。
把这个时序图和源码 §17.3.3 对照着看,一句代码、一个 frame、一次字节——协议怎么在工程里真实地跑起来就清楚了。
17.10 关联与延伸
PING 的状态机和 BDP 算法让 HTTP/2 在协议层完成了一次跨层反馈:
- 读端从网络栈拿数据 → 更新
last_read_at→ keep-alive state 评估是否需要 PING - BDP 的字节累计 → PING → ACK → RTT → 动态窗口 → 上一章的流控窗口 → socket 行为
这和 TCP 的拥塞控制(BBR、Reno)是同构的——用 ACK 间隔推断网络状态,再反馈到发送速率。HTTP/2 把这套方法论从 L4 搬到 L7,但背后的数学是一样的。有兴趣的读者可以顺着 Bdp::calculate 和 Linux 内核的 tcp_rtt_estimator 对照读——你会看到两个数量级的复杂度差别,但算法骨架完全一致。
另一层联想在状态机模式。本章的 KeepAliveState 三态、GoAway 结构里 going_away / close_now / pending 组合出的"未开始 / 正在 going away / 立刻关"三种局面、以及 §17.3.3 的 "用 PING 当 memory barrier" 的技巧——都是把时间语义编码进状态机的典型手法。《Serde 元编程》专栏第 4 章 Deserializer/Visitor 里 SeqAccess / MapAccess 这类"读取侧的状态机"也是同一类设计——状态少而显式、转换条件清晰、外部不能直接改字段。这种 pattern 跨项目、跨领域、跨层次地反复出现,值得记在头脑里作为"状态机写法的范本"。
17.10.1 再看一次"协议 + 实现"两层抽象
本章穿过了 RFC 定义(协议层)和 hyper / h2 实现(工程层)两层。值得把这两层的关系在最后专门总结一次。
协议层(RFC 9113) 给出的是最小可协作约定——PING 必须 ACK、GOAWAY 带 last_stream_id、Error Code 的 14 种取值。这些约定保证了任意两个合规实现都能彼此协作——你的 hyper 能和 Python aiohttp、Go net/http2、C++ nghttp2、Java Netty 的 HTTP/2 stack 正确通信。
工程层(hyper + h2) 在协议约定上加了一层策略——PING 可选、keep-alive 可配、两阶段 GOAWAY 借 PING 当 barrier、BDP 算法借 PING 做估算。这些策略不在 RFC 里,是实现作者针对生产经验的额外决策。不同实现的策略可以完全不同——Nginx 的 keep-alive 节奏、gRPC 的 ping strikes、Envoy 的 idle_timeout 都不一样。
读 RFC vs 读源码的取舍也由此:RFC 告诉你"为什么 PING 必须立即 ACK"(spec 级必要性)、源码告诉你"hyper 为什么 default off"(工程级选择)。两者都读,才算真正理解。这也是本专栏相对于一般 HTTP 教材的"偏工程"倾向——我们不花大段重述 RFC,而在 "RFC 和 hyper 的差距"这条缝隙里挖。
17.10.2 与 Serde 设计哲学的一处远程呼应
换个角度看——Ping::opaque() 返回一个 payload 语义隐藏的对象,由 h2 内部决定;hyper 只保存这个 handle,不碰 payload 内容。这是一种"数据语义由 owner 决定,consumer 只认 handle"的设计。
《Serde 元编程》专栏第 4 章 Deserializer 与 Visitor 里讨论过类似的手法——Deserializer 不知道 Visitor 想要什么类型,Visitor 不知道数据从哪来——双方通过最小约定(visit_* 方法)协作。hyper 和 h2 的 Ping 关系也是如此——hyper 不关心 payload 结构,h2 不关心发 PING 的动机——双方通过最小约定(opaque + ack)协作。
这是"协议即 trait"的思路——在 Rust 生态里反复出现。Tokio 的 AsyncRead/AsyncWrite、Tower 的 Service、Serde 的 Serializer/Deserializer、hyper 与 h2 的 Ping 边界——都是用"尽量少的方法 + 尽量少的类型"定义协作契约,然后把策略留给各自的实现。一旦你在一处读通了这个手法,其他处就能更快看懂——这是本专栏和几个姊妹专栏共同的技术底色。
17.10.3 如何用好"持续探测 + 受控终结"这套思路
本章讲的东西不限于 HTTP/2——"heartbeat + graceful terminate"的模式在任何长连接协议里都出现:
- WebSocket 有 PING / PONG control frame(RFC 6455 §5.5.2、5.5.3),close frame 有 code + reason。
- gRPC 其实就是跑在 HTTP/2 上,继承本章的全部机制,但在 gRPC 层又加了一层 stream-level "half-close"。
- MQTT 3.x / 5.0 有 PINGREQ / PINGRESP + DISCONNECT。
- Redis cluster gossip 协议也有 PING / PONG,外加 fail 状态扩散。
- Kubernetes 的 heartbeat(kubelet → API server)本质是 HTTP/2 长连接 + 定期重探。
所有这些协议都共享一个模式:用最小 payload 定期探测活性,用显式关闭信号协商终结——避免"静默崩溃"这个分布式系统最大的敌人。你读完本章之后再看这些协议,心智模型完全可迁移——具体 API 名字不一样,但骨架是相同的。"长连接可靠性"这件事的方法论早在 TCP 时代就积累好了,每一代新协议都在继承并稍作调整。
17.10.4 一个反直觉的细节:keep-alive 不 reset request timeout
看一下这段真实发生过的配置 bug:
// 看起来合理,实际是错的
let svc = tower::ServiceBuilder::new()
.timeout(Duration::from_secs(30)) // 每请求 30 秒
.service(router);
let http = hyper::server::conn::http2::Builder::new(exec)
.timer(TokioTimer::new())
.keep_alive_interval(Some(Duration::from_secs(10)))
.keep_alive_timeout(Duration::from_secs(5))
.serve_connection(io, TowerToHyperService::new(svc)); // tower Service 要经 hyper-util 桥接
工程师以为:"keep-alive 在跑,说明连接活着,我的 30 秒请求超时是只在请求活跃时才计时的"。
错了——tower::timeout::Timeout 的 sleep 是从 service.call() 被调用的那一刻算起,和 keep-alive 毫无关系。一个长查询业务跑 25 秒是合法的,keep-alive PING 在它背后每 10 秒跑一次——这完全独立。
真正的陷阱在这里:假设一个客户端上传 100 MB 大文件花了 28 秒——tower::timeout 快到了。但 keep-alive 正在持续发 PING 证明连接活着——所以日志里看不到"连接断开"的异常,但请求被 tower 中间件的 timeout 直接砍掉,在协议层表现为 RST_STREAM。debug 时看到的现象是"连接明明活着请求却挂了"——如果对 keep-alive 和 tower::timeout 的独立性没理解透,就会怀疑到 hyper bug 头上。
这又回到第 14 章讲过的观点——"超时矩阵"是一个多层独立的体系,每一层有自己的计时和责任。HTTP/2 的 keep-alive 只管"连接存活",不管"请求进度";tower 的 timeout 只管"单请求耗时",不管"连接健康"。它们互相不替代、也不互相感知。
这个独立性是特性而非 bug——它让我们可以独立地调每一层,不会因为某个配置绑定影响其他层。代价是必须理解这个正交关系——"每一层超时是一个独立的 kill switch"。
17.10.5 从 Tokio Waker 视角再看一次 Ponger::poll
本章提到过几次 cx: &mut task::Context<'_> 与 cx.waker() ——它们是 Tokio 调度系统的结缔组织。把 Ponger::poll 放到 Tokio runtime 的执行模型里看,会看到一个完整的事件链:
- TCP socket 收到对端的 PONG frame(由 Tokio 的 I/O driver 感知)。
- I/O driver 唤醒 Connection 所在的 task(通过注册过的 waker)。
- task 重新 poll,进入
hyper::server::conn::http2::Connection::poll。 - 这个 poll 递归 poll 内部的
proto::h2::Server::poll。 Server::poll里的Ponger::poll被重入——poll_pong现在能返回Poll::Ready(Ok(_pong))。- Ponger 更新 RTT、bdp、keep-alive state、last_read_at。
- Ponger 返回 Poll::Pending,Server::poll 继续处理其他 frame。
- 最终整个 poll 返回 Pending(除非连接整体关闭),runtime 把 task 挂起,等下一个 I/O 事件。
没有额外的 task——keep-alive 没有独立的 background task 在轮询 timer。它借"Connection 每次被 poll 都顺便检查 keep-alive"这个模型,不花一分钱额外调度成本。Tokio 的 timer 到期时,sleep future 内部的 waker 会唤醒整个 Connection task,同一次 poll 里把 keep-alive 推进。
这个"一切都在一个 Future 的 poll 里完成"的模式,是 Tokio 风格的 async Rust 的标志性特征——回想《Tokio 源码深度解析》专栏第 2 章 Future/poll、第 3 章 Waker 讲过的 poll-based 调度模型,本章的 ping.rs 就是这个模型在真实协议实现里的一次完整展开。
17.10.6 协议 API 对调试友好的几条设计
本章涉及的 hyper / h2 API 之所以"easy to debug",有几个共同特征:
- 关键状态只在一个地方——
KeepAliveState枚举只有三个变体,GoAway结构只有四个字段,Shared里的字段都带明确的注释。读调试日志时能直接映射到状态。 - trace! 信息带具体数值——
"keep-alive interval ({:?}) reached"、"BDP increased to {}"、"recv PING SHUTDOWN ack"——不是空洞的"failed",而是具体的哪一步、用的什么数字。线上打开RUST_LOG=hyper=trace,h2=trace就能看见协议的脉搏(hyper 这一侧的日志还要开tracingfeature,并以RUSTFLAGS="--cfg hyper_unstable_tracing"编译,否则 hyper 会直接报编译错误)。 - 错误是结构化的——
KeepAliveTimedOut、GoingAway是专用类型,不是 string。它们本身不对外公开,但上层能通过公开方法精确判断:hyper 的Error::is_timeout()、h2 的Error::is_go_away()/reason()——自动化运维脚本可以依此决定重试策略。
这三条结合起来,构成了 hyper 在 Rust HTTP 生态里"好调试"的口碑基础。你在自己的协议库里照着做这三条,调试体验会好很多——很多 "难调试" 的库都是在这三条里踩了坑(log 里只有 "error" 两个字、错误类型是 Box<dyn Error>、关键状态分散在 5 个地方)。
17.10.7 实测:hyper 1.9.0 h2 子系统 2399 行 + h2 0.3.27 整 crate 25144 行
§17.2-17.4 涉及 PING/GOAWAY/SETTINGS 三套机制——把 hyper 与底层 h2 crate 分别实测——
hyper 1.9.0 的 H2 适配层(hyper-1.9.0/src/proto/h2/)——
| 文件 | 行 | 角色 |
|---|---|---|
client.rs |
791 | HTTP/2 客户端连接 + keep-alive 配置 |
server.rs |
550 | HTTP/2 服务端 + GOAWAY two-stage shutdown |
ping.rs |
514 | §17.2 主角——Recorder + Ponger + Bdp::calculate(§17.11 落到键盘上提到的 40 余行 BDP 算法在这里) |
upgrade.rs |
280 | ch18 §18.10.12 测过的 HTTP/2 extended CONNECT |
mod.rs |
264 | h2 子模块入口 |
| hyper h2 适配合计 | 2399 | — |
h2 0.3.27 整 crate(HTTP/2 协议本身的 Rust 实现)——
| 文件 | 行 | 角色 |
|---|---|---|
client.rs |
1666 | h2 client API |
server.rs |
1644 | h2 server API |
h2-0.3.27/src/frame/headers.rs |
1053 | HEADERS 帧编解码 |
h2-0.3.27/src/hpack/decoder.rs |
936 | HPACK 头部压缩解码 |
| 其余多文件 | ~19800 | go_away.rs / ping.rs / settings.rs / 流量控制 / streams 状态机 / 等 |
| h2 整 crate 合计 | 25144 | — |
两条值得记住的物理事实——
- hyper h2 适配 2399 行 vs h2 crate 25144 行 = 1:10.5——印证 §17.10.1 "协议 + 实现两层抽象"——hyper 只做"给应用层暴露 keep-alive / graceful_shutdown 两个旋钮"的薄适配、HTTP/2 协议本身的所有复杂性(流量控制窗口管理、HPACK 头部压缩、多路复用流状态机、PING/SETTINGS/GOAWAY 帧编解码)由 h2 crate 承担——和 ch18 §18.10.12 测得的 hyper upgrade 687 行 vs WebSocket 协议生态 6080 行 = 1:9 同款规律:hyper 是协议握手的最小实现、协议本身由专门的 crate 承担
ping.rs在 hyper 仅 514 行——是 §17.2 全部源码——其中包含 §17.11 提到的 40 余行 BDP 算法Bdp::calculate(hyper-1.9.0/src/proto/h2/ping.rs:366-408)——印证 §17.1 说的"连接生存协议"在 hyper 层只需 514 行——剩下的 HTTP/2 PING 帧本身的编解码、ACK 协议、超时管理由 h2 crate 内部处理——是"hyper 是 h2 的应用门面" 的具体证据
17.11 落到你键盘上
- 抓包一次 graceful_shutdown 流程:用
tcpdump -w capture.pcap port 8080抓 hyper HTTP/2 server 在 SIGTERM 下的所有包,再用 Wireshark 打开看 GOAWAY 的 two-stage 发送。第一次 GOAWAY 的 last_stream_id 是不是0x7FFFFFFF(= 2^31-1)?之后有没有一个 8 字节 payload 为0b 7b a2 f0 8b 9b fe 54的 PING?第二次 GOAWAY 的 last_stream_id 是不是真实的已处理 stream?看清这些,你对"优雅"这个词会有物理感受。 - 实验 PING keep-alive 失活:写一个最小 HTTP/2 server:
hyper::server::conn::http2::Builder::new(exec).keep_alive_interval(5s).keep_alive_timeout(3s)。启动后拿nc连上,只发 HTTP/2 preface + SETTINGS + SETTINGS ACK,然后故意不回任何 PING-ACK。观察 hyper 在约 8 秒(5 + 3)之后发 GOAWAY(NO_ERROR) 并关连接(实测:第 5 秒收到 PING,第 8 秒收到 GOAWAY)——开了 hyper 的 trace 日志(见 §17.10.6)的话会打印keep-alive timeout (3s) reached。注意 Builder 要设.timer(TokioTimer::new()),否则开 keep-alive 会在握手完成时 panic("You must supply a timer.")。这个手动实验会让你对 keep_alive_timeout 有精确的数字感受。 - 读
h2-0.3.27/src/proto/go_away.rs——GOAWAY 的状态机在那里,才 154 行。GoAway结构里的close_now、going_away、is_user_initiated、pending四个字段组合成一个"什么时候该真正关 TCP"的决策机——should_close_now和should_close_on_idle两个方法是阅读主线。 - 读
hyper-1.9.0/src/proto/h2/ping.rs的Bdp::calculate——40 余行代码(hyper-1.9.0/src/proto/h2/ping.rs:366-408)实现了一个生产级 BDP 算法。对照 RFC 6298 的 TCP RTT 估算公式,你会发现两者的结构几乎一致——这不是巧合,而是"用 ACK 间隔估算链路特征"这个方法在协议工程里的共识。
下一章我们从 HTTP/2 的"连接协议"切到"协议升级"——即 WebSocket、HTTP CONNECT 这类把一条 HTTP 连接"转换为任意双工流"的机制。hyper 的 upgrade.rs 如何让同一条 TCP 在完成 HTTP 握手之后变成 WebSocket 或隧道?它的 OnUpgrade、Upgraded、Parts 三个抽象如何把字节流和 HTTP 状态清爽地分开?这是一个被严重低估的模块——理解它之后,你会更深刻地理解 HTTP 的"协议边界到底在哪里"。