Appearance
第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/src/proto/h2/ping.rs这个只有 500 行的文件——里面同时跑着 keep-alive 状态机和 BDP 估算算法,是本章源码学习的"单一焦点文件"。 - 在生产部署 hyper / tonic 服务时,对"需要配哪些参数、不配会怎么出事"有可以自洽的决策链。
本章和上一章互为下半场——上一章讲 HTTP/2 的流控与多路复用("躯干"),本章讲连接生存("心跳与死亡")。读完两章,你就能完整地部署一个生产级 HTTP/2 服务。本章写作时同步拉了 hyper 1.9(commit 0d6c7d5)、h2(commit dbc204e)两个仓库的源码——所有文件路径 + 行号都按这两个版本给。
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/src/proto/h2/ping.rs—— hyper 对 PING 的两用包装。hyper/src/server/conn/http2.rs—— 用户可见的 Builder API。h2/src/proto/go_away.rs与h2/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/src/proto/h2/ping.rs 的第一段文档注释(第 1-20 行),作者直接点题:
rust
//! 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/src/frame/ping.rs:16-17 定义了两个硬编码的"魔法 payload":
rust
// h2/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 用(用户主动发 PING)。hyper 的 BDP/keep-alive 则通过 Ping::opaque() 构造不透明 payload:
rust
// h2/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.
pub fn opaque() -> Ping {
Ping { _p: () }
}
}"opaque" 是故意的——hyper 不在乎具体 payload 是什么,只在乎"我发了一个、它 ACK 回来了"。真正的 payload 内容由 h2 crate 内部选一个值——可能是全零、可能是一个递增计数器,接口层面对 hyper 隐藏。这是 API 设计的一次"最小信息暴露"——hyper 不依赖 payload 语义,h2 有自由实现;将来 h2 哪天改算法,hyper 完全不知道也不受影响。
三种 payload 共存(SHUTDOWN / USER / opaque)——一个 8 字节的字段,被 h2 crate 用成"带命名空间的信号总线"。这是协议"留出 opaque 字段"最经典的工程回报。
17.2.3 用途 A:活性探测(keep-alive)
hyper 1.9 在 http2::Builder 上只暴露两个 keep-alive 相关参数(见 hyper/src/server/conn/http2.rs:220-240):
rust
// hyper/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/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/src/proto/h2/server.rs:160-163 里把它硬写成 true:
rust
// hyper/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 被许多客户端同时连——大规模并发下每 10 秒对每条连接发 PING,CPU + 带宽都不划算。server 通常依赖客户端主动 keep-alive(客户端持连时间长、数量少、探活成本低)。当 server 的客户端生态可控(内网、同机房 gRPC),打开 server 端 keep-alive 是合理选择。
17.2.4 KeepAliveState:三态状态机
真正驱动 keep-alive 的代码在 hyper/src/proto/h2/ping.rs 里的 KeepAliveState(第 167-171 行):
rust
// hyper/src/proto/h2/ping.rs:167-171
enum KeepAliveState {
Init,
Scheduled(Instant),
PingSent,
}三态的转换用下面这张时序图展开最清楚:
核心逻辑在 maybe_ping 与 maybe_timeout 两个方法里(ping.rs:456-493):
rust
// hyper/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 => (),
}
}rust
// hyper/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 成 interval,PingSent 时 arm 成 timeout。这避免了每次都分配新 future,成本下降到接近零。这种"一个 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 行为。整个机制无锁、无 channel、无独立 task——只在 Connection 被 driver 轮询时顺手推进。这是 async Rust 的典型"协作式"风格。
17.2.5 用途 B:BDP 估算
上一章 §16.3.5 提过 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 × 2hyper 把这个循环写在 Bdp::calculate 里(ping.rs:366-408):
rust
// hyper/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 Reno 的 RTT 估算系数完全一致(RFC 6298)——Linux kernel 里的tcp_rtt_estimator用的也是1/8。hyper 没有重新发明,直接搬了 TCP 的成熟公式。 - 带宽估算:
bw = bytes / (rtt * 1.5)——除以1.5是因为"我们实际测到的 RTT 覆盖了 1.5 个方向"(发送方向 + 回包部分时间 + PING/PONG 的 RTT 本身),这是启发式修正。 - 窗口翻倍但受限:如果本次采样字节数已经达到 当前 bdp 的 2/3,说明窗口还有放大空间——翻倍(
bytes * 2)但不超过BDP_LIMIT = 16 MiB(ping.rs:363)。 - 稳定后降频:如果带宽没超过历史 max,或连续两次稳定,把
ping_delay *= 4——带宽稳定时少探测。这是节能机制,避免 PING 风暴。
16 MB 上限的来历——源码注释说:
rust
// hyper/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 在 100Mbps / 50ms RTT 链路上 throughput 从 ~15 MB/s 升到 ~45 MB/s(因为 spec 默认 64KB 窗口远小于 BDP)。这一章讲到的 RTT 测量 + 窗口动态更新,是收益的来源。
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 KB。
继续翻倍几次,直到窗口达到 BDP = bandwidth × RTT = 12.5 MB/s × 0.05s = 625 KB。
- 这时单次采样已经覆盖完 RTT 内的所有 in-flight 数据,窗口成为 TCP send/recv 的"影子"。
bw接近真实带宽上限,max_bandwidth稳定——后续采样不再触发翻倍。ping_delay *= 4——PING 节奏放慢,算法进入"稳态少打扰"模式。
整个过程通常在几秒内完成——从 64 KB 起飞,经 5-7 次翻倍到稳态。这就是 adaptive_window(true) 能把高 BDP 链路的 throughput 从 ~15 MB/s 升到 ~45 MB/s 的物理机制。
对照 BBR 拥塞控制:BBR 也是以 RTT × 带宽估算 BDP,只不过 BBR 在 TCP 拥塞窗口层做,hyper 在 HTTP/2 流控窗口层做。这是同一套方法论在不同网络层的各自实现——两者协同工作的链路上,你会看到 TCP BBR cwnd 和 HTTP/2 flow window 互相追着对方成长,最后都收敛到 BDP。卷四《Tokio 源码深度解析》第 19 章 performance 里讨论过内存和 CPU bound 的性能模型,这里的 BDP 类比网络 bound 的模型——同构不同层。
17.2.7 源码里的那个 "XXX" 注释
读 ping.rs 的 Ponger::poll(ping.rs:265-323)时会看到两个显眼的 comment:
rust
// hyper/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 与 Chrome 的 PING_STRIKES
PING 必须立即 ACK 的强制语义使它成为 DoS 载体——恶意客户端每秒发几十万个 PING,server 必须每个都 ACK,放大 CPU 消耗。防御分两个方向。
协议层的经验性防御——Chrome 在 2019 年引入的 PING_STRIKES:如果一条连接上连续两次的入 PING 间隔 < 最大并发 stream 数量 × 2 秒,判定为"抽搐式 PING",累加 strike 计数,超过某阈值就 GOAWAY 关连接。这是非协议规范的客户端侧防御,原因是 server 发错误 PING 节奏也会伤客户端。
hyper 1.9 的实现现状:hyper 自己不做入流 PING 限流——Shared::is_ping_sent() 只管出流 PING 至多一个 outstanding。入流 PING 由 h2 crate 直接 ACK,没有计数防御。如果你的 hyper 服务直接暴露到公网,PING flood 的防御必须在前置反向代理(Nginx 的 http2_max_ping_strikes、Envoy 的 max_inbound_priority_frames_per_second)做——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:发送方承诺已经处理或将会处理到这个 ID 的 stream。大于此 ID 的 stream 不会被处理——对端应该把这些 stream 重试到别的连接。
- Error Code:0 = NO_ERROR(正常关闭),其他是各种协议错误码。
- Debug Data:可选的 UTF-8 文本,给运维看。
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),这是协议级的"软警告"。对端理解为"你暂时还可以发,但准备好切换",从而有充裕时间在自己这侧退出连接。第二次 GOAWAY 才给出硬承诺。
对比单阶段:server 直接发 GOAWAY(last_stream_id = 5)——这等于说"大于 5 的我不管了"。但客户端可能已经在发 stream 7、9、11 的数据——这些会被直接丢弃,用户感知到大面积失败。
17.3.3 h2 怎么实现两阶段
在 h2/src/proto/connection.rs:599-623 可以看到完整实现:
rust
// h2/src/proto/connection.rs:599-623
// 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() 发出一个 payload 为 SHUTDOWN_PAYLOAD 的 PING,等对端回 ACK。收到 ACK 的那一刻,协议层可以保证——所有 stream-开始 的 HEADERS 都必然已经到达或绝对不会再到达(因为 PING 按帧顺序,PING 之后的新 stream 都是在本端发 GOAWAY 之后才被对端启动的)。然后 h2 才发第二个 GOAWAY 承诺 last_stream_id。
这里 PING 的作用不是活性探测,而是协议层的 "memory barrier"——同一个 PING 帧机制第三种用途。回头看 17.2.2 的 SHUTDOWN_PAYLOAD 那 8 字节魔法常数——就是专门给这一次 PING 用的。
17.3.4 hyper 的 graceful_shutdown 入口
hyper 的用户入口极其简单(hyper/src/server/conn/http2.rs:78-80):
rust
// hyper/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.
pub fn graceful_shutdown(mut self: Pin<&mut Self>) {
self.conn.graceful_shutdown();
}一层 thin wrapper。真正决定时机的代码在 hyper/src/proto/h2/server.rs:178-190:
rust
// hyper/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 可以进一步延长。
对比 HTTP/1:HTTP/1 的 graceful_shutdown 第 14 章讲过,就一行 disable_keep_alive(),靠"响应写完不 reset 状态机"实现优雅。HTTP/2 的多 stream 并行使得单条连接的"完成时间"不可预测——GOAWAY 两阶段保证了不管对端正在发什么,都不会突然被切断。
17.3.6 Error Code 的工程价值
NO_ERROR 是最常见的 GOAWAY 原因,但其他错误码有明确用途。完整列表见 h2/src/frame/reason.rs:
| Error Code | 何时触发 | hyper 里的出处 |
|---|---|---|
NO_ERROR (0x0) | 正常关闭、滚动升级 | go_away_gracefully |
PROTOCOL_ERROR (0x1) | 收到违反 spec 的 frame | h2 内部 |
INTERNAL_ERROR (0x2) | server 自己的 bug / panic | h2 内部 |
FLOW_CONTROL_ERROR (0x3) | 对端超出流控窗口 | streams::state |
SETTINGS_TIMEOUT (0x4) | SETTINGS ACK 超时 | h2 settings.rs |
FRAME_SIZE_ERROR (0x6) | frame 超出 max_frame_size | h2 codec |
REFUSED_STREAM (0x7) | server 拒绝再接 stream(过载) | hyper 的 reset counter |
ENHANCE_YOUR_CALM (0xb) | rate-limited,让客户端冷静 | 少数反向代理主动发 |
INADEQUATE_SECURITY (0xc) | TLS 版本/密码套件不足 | TLS 层 |
生产中 ENHANCE_YOUR_CALM 是一个常被忽视但很实用的错误码——和 HTTP 层的 429 不同,GOAWAY(ENHANCE_YOUR_CALM) 是连接级的——"这条连接上你太多了,请消停,下次建新连接过来"。Envoy、HAProxy、Nginx 在重负载保护时会主动发这个 code;h2 crate 在上游发给它时会正确传播到上层。hyper 本身不主动发——主动发 GOAWAY 的决策留给上层业务代码。
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这个来回保证了双方对协议参数的约定达成一致——客户端发送前必须知道服务端的 max_frame_size,不能赌。
17.4.2 恶意 peer 拒绝 ACK SETTINGS
spec 没规定 ACK 的 deadline。恶意客户端可以在 TCP 握手后永远不发 SETTINGS ACK——server 按协议不能在 ACK 之前关连接,被占用 fd 和内存。h2 crate 在 h2/src/proto/settings.rs 里内建了 SETTINGS ACK 超时,发出后若干秒没收到 ACK 就主动 GOAWAY 关。hyper 没有直接暴露这个参数——它在 h2 内部固定。如果你需要调(比如极慢的客户端),直接用 h2 crate 搭连接更灵活。
17.4.3 max_header_list_size:16 KB 的来历
这个参数穿过"连接安全"和"协议协商"两个关切点:
rust
// hyper/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/src/proto/h2/server.rs:41:
rust
// hyper/src/proto/h2/server.rs:41
const DEFAULT_SETTINGS_MAX_HEADER_LIST_SIZE: u32 = 1024 * 16; // 16kb16 KB——这个数字和 Nginx、Envoy、nghttp2 一致。业务中一个 "Bearer" token 可能 1-4 KB,加上 Cookie、User-Agent、Host、Accept-* 等正常 header,16 KB 已经能容纳 99.9% 的请求。剩下 0.1% 如果确实需要(比如某些 SAML 场景)再单独调大。
这个阈值也是防御 HPACK 内存攻击 的主要防线——攻击者塞几 MB 的压缩后 header,HPACK 解压再放入 HeaderMap 会放大好几倍内存。16 KB 的硬限把单请求放大上限压到几十 KB 级别。
17.5 把三种超时放到一张图上
综合上一章 keep-alive 和本章 PING/GOAWAY,HTTP/2 server 的完整连接生命时序如下:
三种超时分别覆盖三个阶段:
| 阶段 | 超时机制 | 默认值 | hyper 可调? |
|---|---|---|---|
| 握手 | SETTINGS ACK | h2 内部 | 否 |
| 稳态 | keep_alive_interval + timeout | 关闭 + 20s | ✓ |
| 关闭 | graceful(由 pod 级 terminationGracePeriod 兜底) | 无内建 | 通过 graceful_shutdown 触发 |
请求级超时(header / body / 业务处理时间)不属于 HTTP/2 协议范畴——由 tower 中间件和业务代码负责,和 HTTP/1 完全一致。HTTP/2 的参数只管连接生存。
17.5.1 对照 HTTP/1 与其他语言栈的超时矩阵
把 hyper HTTP/2 的超时拓扑放回更大的生态里看一眼。下表是生产级 HTTP 服务四种常见栈的超时覆盖:
| 超时项 | hyper (HTTP/2) | nghttp2 / Nginx | Go net/http2 | Envoy |
|---|---|---|---|---|
| Header 读超时(连接建立后) | h2 内部 settings_timeout | client_header_timeout 60s | ReadHeaderTimeout | request_headers_timeout |
| keep-alive ping | keep_alive_interval / timeout | http2_idle_timeout | ReadIdleTimeout / PingTimeout | connection_keepalive.interval / timeout |
| 空闲连接超时 | 无内建(靠 LB 前置) | keepalive_timeout 65s | IdleTimeout | idle_timeout |
| 优雅关闭 | graceful_shutdown(二阶段 GOAWAY + PING) | worker_shutdown_timeout | Server.Shutdown(ctx) | Envoy drain_timeout |
| 全请求耗时 | 无(靠 tower::timeout) | client_body_timeout | 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、无 idle timeout、无请求级超时。这是 Rust "生态组装" 风格的代价——默认值不保守,必须显式配置生产参数。这也是为什么很多 Rust 服务第一次上线就中招的原因——开发阶段测不出来,生产阶段 NAT + idle 打出来。
看清这张表,下次配 hyper server 时逐项问一遍——每一项我有显式决策吗?没有的留心。这是 HTTP 服务生产化的底线 checklist。
17.6 实战案例:gRPC 长连接的 PING 风暴
一次真实复盘——某内网 gRPC 系统。
现象:server CPU 中 3% 固定用在 h2 ping frame 处理,峰值时升到 12%。同时客户端 metrics 显示每秒约 50000 条 PING ACK 回包。
原因:客户端(tonic)配置:
rust
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 硬要求)。server CPU 基本在做 pong。
修复:把 interval 调到 30 秒,timeout 调到 20 秒——5000 条连接每秒 ~167 PING,server CPU 降回 0.5%。功能上探活仍及时(最坏 50 秒发现对端死),成本小了 30 倍。
教训:
- keep-alive interval 不是越短越好——和业务真实需求匹配。NAT 30 分钟丢表,30 秒 interval 已经远超需要。
- 每条连接独立发 PING——总 PING/s = 连接数 × (1 / interval)。这是一个连接数放大的成本。
- client 侧配置的成本落在 server CPU 上——client 开发者往往只感知到"keep-alive 有效",看不到 server 侧的代价。上线前必须双方协商。
17.6.1 再一次:NAT 漂移与 iOS 后台挂起
"keep-alive 到底能救什么"这个问题,在移动端最有发言权。两个真实场景:
场景一:NAT 30 分钟表项淘汰。运营商的 NAT 表通常保留 TCP 连接 2-10 分钟无流量后就回收——不同运营商不一样。中国移动 3 分钟、电信 5 分钟、联通视地区差异 2-10 分钟。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 的时间粒度(分钟-小时级)对 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-BT3-T5 之间如果 DNS 结果还是旧的(被缓存住),client 会再连回 10.0.0.5——但那个 IP 背后的 pod 已退出,TCP ECONNREFUSED。这是 GOAWAY + 陈旧 DNS 的经典 race。
缓解方案:
- 负载均衡器前置:GOAWAY 由 LB 处理,不直接暴露给 client。LB 自己维护后端列表,GOAWAY 时切新后端即可。
- 服务发现推送:Envoy / Istio 等 service mesh 在 GOAWAY 发生时主动推送新 endpoint 到所有客户端,不依赖 DNS TTL。
- 客户端重试 + 解析新 endpoint:tonic / hyper-util 的 pool(第 20 章详讲)可以在连接失败时换 endpoint 重试。
做 hyper HTTP/2 server 的生产部署,99% 的情况是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 | CONNECTION_CLOSE frame |
| RTT 估算 | 借用 PING payload 时间戳 | QUIC 内建 RTT 估算 |
| 丢包恢复 | 依赖 TCP | QUIC 每 stream 独立 |
| 连接迁移 | NAT 表超时后失效 | Connection ID 保留 session |
QUIC 把连接标识从 (IP, Port) 四元组解耦出来——用一个 Connection ID 标识。客户端 IP 变了(4G 切 5G、WiFi 切蜂窝),连接仍然有效,不需要重建 session。这彻底解决了移动设备的 NAT 漂移问题,而这正是 HTTP/2 在移动网络下 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 "连接生存"部分的推荐配置:
rust
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) // 打开 BDP
.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 防御 ---
.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。客户端(tonic / hyper-util Pool)对称配置:
rust
hyper_util::client::legacy::Builder::new(TokioExecutor::new())
.http2_keep_alive_interval(Duration::from_secs(30))
.http2_keep_alive_timeout(Duration::from_secs(20))
.http2_keep_alive_while_idle(false) // 没 stream 时别探
.build()一条铁律:client 的 timeout 必须比对端的 interval 短——否则对端还没开始发下一次 PING,本端就判死了。以 (client.interval=30s, client.timeout=20s, server.interval=30s, server.timeout=20s) 对称配置最稳。
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/src/proto/h2/server.rs 的 Serving::poll 被 Ponger::poll 唤醒
→ 调 Recorder::ensure_not_timed_out
→ 返回 Err(KeepAliveTimedOut.crate_error())
4. crate::Error 沿着 Future::poll 向上传播
→ h2::server::Connection::poll → 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) => {
// e.downcast_ref::<KeepAliveTimedOut>() 在这里可以拿到原因
tracing::warn!("http2 connection ended: {}", e);
}
}这条错误通路有两个细节值得注意:
1. KeepAliveTimedOut::crate_error() 在 ping.rs:499-501 里把 KeepAliveTimedOut 包进 crate::Error:
rust
// hyper/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) 用的是第 1 章的 source chaining 机制——让上层可以通过 std::error::Error::source() 一路下钻拿到 KeepAliveTimedOut。用户代码里 e.source() 链条看起来是 Http2 → KeepAliveTimedOut → TimedOut,每一层都有明确语义。
2. 共享的 is_keep_alive_timed_out 标志位:这个 bool 存在 Shared 里——Recorder::ensure_not_timed_out 和 Ponger::poll 都会查。这是因为 ping 的计时部分在 Ponger,可写这个 bool;而 HTTP/2 数据路径的读回包部分会通过 Recorder 查,"一旦我们决定死,所有下一次读都得立刻返回错误"。这种"decision sticking"是状态机里处理"终态"的典型手法——一旦进入 dead 态,就再也不能从里面逃出。
17.9.2 一次完整的连接生存时序(Wireshark 视角)
抓一次真实的 HTTP/2 生存流程,放 Wireshark 里看会长这样(简化版):
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 0x00 00 00 00 00 00 00 01 (opaque)
101 30.003 PING (ACK) 0x00 00 00 00 00 00 00 01
... (每 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 完全一致(
0x00 00 00 00 00 00 00 01)——Ping::opaque()生成的 payload 在 h2 crate 里可能是计数器形态,这里看起来是 1。 - Frame 501 的 PING payload 是
0x0b 7b a2 f0 8b 9b fe 54——就是 §17.2.2 看到的SHUTDOWN_PAYLOAD常数。这是 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 的 Init/Going Away/Close 三态、以及 §17.3.3 的 "用 PING 当 memory barrier" 的技巧——都是把时间语义编码进状态机的典型手法。卷四《Tokio 源码深度解析》第 11 章 time driver 里的 TimerState、以及《Serde 元编程》第 4 章 Deserializer/Visitor 里的 Access 状态机都是同一类设计——小而显式的 enum、转换条件清晰、外部不能直接改字段。这种 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 节奏、Chrome 的 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 名字不一样,but 骨架是相同的。"长连接可靠性"这件事的方法论已经在上世纪 70 年代的 TCP RFC 里就积累好了,每一代新协议都在继承并稍作调整。
17.10.4 一个反直觉的细节:keep-alive 不 reset request timeout
看一下这段真实发生过的配置 bug:
rust
// 看起来合理,实际是错的
let svc = tower::ServiceBuilder::new()
.timeout(Duration::from_secs(30)) // 每请求 30 秒
.service(router);
let http = hyper::server::conn::http2::Builder::new(exec)
.keep_alive_interval(Some(Duration::from_secs(10)))
.keep_alive_timeout(Duration::from_secs(5))
.serve_connection(io, svc);工程师以为:"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 的标志性特征——回想卷四第 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就能看见协议的脉搏。 - 错误是结构化的——
KeepAliveTimedOut、GoingAway是专用类型,不是 string。e.downcast_ref::<KeepAliveTimedOut>()可以在上层精确判断类型——自动化运维脚本可以依此决定重试策略。
这三条结合起来,构成了 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 适配层(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 落到键盘上提到的 32 行 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 |
frame/headers.rs | 1053 | HEADERS 帧编解码 |
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 提到的 32 行 BDP 算法Bdp::calculate——印证 §17.0 标题 "连接生存协议" 在 hyper 层只需 514 行——剩下的 HTTP/2 PING 帧本身的编解码、ACK 协议、超时管理由 h2 crate 内部处理——是"hyper 是 h2 的应用门面" 的具体证据
串联 ch09 §9.9.3 实测的 http crate 14395 + ch18 §18.10.12 hyper upgrade 687 + 本节 hyper h2 适配 2399 + h2 0.3.27 整 crate 25144 = 42625 行——是 Rust HTTP/2 协议栈的 "HTTP 数据结构 + 协议升级 + 连接生存 + h2 协议引擎" 总工程量;其中 h2 crate 一个就占 59%——印证 HTTP/2 是协议自身复杂度最高的 Rust 网络协议(HTTP/1 在 hyper 内部、不需要独立 crate)。
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)之后关连接——日志里会打印keep-alive timeout (3s) reached。这个手动实验会让你对 keep_alive_timeout 有精确的数字感受。 - 读
h2/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/src/proto/h2/ping.rs的Bdp::calculate——32 行代码实现了一个生产级 BDP 算法。对照 RFC 6298 的 TCP RTT 估算公式,你会发现两者的结构几乎一致——这不是巧合,而是"用 ACK 间隔估算链路特征"这个方法在协议工程里的共识。
下一章我们从 HTTP/2 的"连接协议"切到"协议升级"——即 WebSocket、HTTP CONNECT 这类把一条 HTTP 连接"转换为任意双工流"的机制。hyper 的 upgrade.rs 如何让同一条 TCP 在完成 HTTP 握手之后变成 WebSocket 或隧道?它的 OnUpgrade、Upgraded、Parts 三个抽象如何把字节流和 HTTP 状态清爽地分开?这是一个被严重低估的模块——理解它之后,你会更深刻地理解 HTTP 的"协议边界到底在哪里"。