Hyper 与 Tower:工业级 HTTP 栈

第16章 多流调度与流控:backpressure 在 HTTP/2 的落地

作者 杨艺韬 · 10,413 字

第16章 多流调度与流控:backpressure 在 HTTP/2 的落地

16.1 HTTP/2 的复杂度都在这一章

上一章讲了 HTTP/2 的帧格式和 HPACK。如果我们停在那里——你会以为 HTTP/2 就是”HTTP/1 + 帧化 + 压缩”。这是严重的低估。

HTTP/2 真正的复杂度——以及上线一个 HTTP/2 服务最容易踩坑的地方——在多路复用和流控。它们是 HTTP/2 能在一条 TCP 连接上安全地承载 1000 个并发请求的物理基础,也是”你的服务在某个奇怪的时刻突然 0 吞吐量”的一般来源。

这一章我们读 hyper 的 proto/h2/server.rs 以及它在 http2::Builder 上暴露的一组看起来类似但作用完全不同的参数:

// hyper/src/proto/h2/server.rs:32-41
const DEFAULT_CONN_WINDOW: u32 = 1024 * 1024;                      // 1mb
const DEFAULT_STREAM_WINDOW: u32 = 1024 * 1024;                    // 1mb
const DEFAULT_MAX_FRAME_SIZE: u32 = 1024 * 16;                     // 16kb
const DEFAULT_MAX_SEND_BUF_SIZE: usize = 1024 * 400;               // 400kb
const DEFAULT_SETTINGS_MAX_HEADER_LIST_SIZE: u32 = 1024 * 16;      // 16kb
const DEFAULT_MAX_LOCAL_ERROR_RESET_STREAMS: usize = 1024;

六个数字。每一个背后都是一个具体的协议机制 + 一次曾经上过头条的安全事故 + 一个你部署时必须做的判断。

16.2 多路复用:一条 TCP,多个 stream

HTTP/2 的一条 TCP 连接能承载多个 stream——每个 stream 是一个独立的请求-响应。stream 之间的 frame 在线路上可以任意交错:

时刻  T1    T2    T3    T4    T5    T6
流量  H/s1  D/s1  D/s3  H/s5  D/s1  D/s5
            ↑     ↑     ↑
            在发 s1 的 body 中间,插入了 s3 的 body 和 s5 的 header

对方根据 Stream ID 字段把每个 frame 路由到对应的请求 context。

16.2.1 stream 的生命周期

每个 stream 经历若干状态(RFC 9113 §5.1):

idle → open → half-closed (local / remote) → closed
  • idle:还没分配。
  • open:双方都在交换数据。
  • half-closed (remote):对端已发完(END_STREAM flag),本端还没发完。
  • half-closed (local):本端已发完,对端还在发。
  • closed:双方都发完。

注意:stream 可以从 open 直接 → closed(通过 RST_STREAM 取消),这是 CVE-2023-44487 的切入点(下文)。

16.2.2 stream ID 规则

  • 客户端发起 的 stream:奇数ID(1, 3, 5, …)
  • 服务端发起 的 stream:偶数ID(2, 4, 6, …,主要用于已弃用的 Server Push)

所以实际的 HTTP/2 server 基本只看到奇数 stream。ID 是严格递增的——连接上永远不会有小于已见最大 ID 的新 stream。老的 stream ID 不会被复用(31 bit 够用 ~21 亿个 stream)。

16.2.2.5 Stream ID 严格递增的协议价值

§16.2.2 提到 stream ID 是严格递增的——客户端奇数 / 服务端偶数、且 “永远不会有小于已见最大 ID 的新 stream”。这个看似简单的规则承载了三项协议保证

① 避免 ID 冲突导致的 state 混乱——如果允许 ID 复用、一个已关的 stream ID 突然又来 HEADERS、server 不知道是新 stream 还是 “某个 buffered frame 迟到了”。严格递增让看到小于当前最大 ID 的 HEADERS 一定是协议错误——直接拒绝、简化状态机。

② 让 GOAWAY 能精确表达 “我还会处理哪些 stream”——GOAWAY 帧的 payload 是 “last stream ID I’ll process”(最后会处理的 stream ID)。因为 ID 递增、这个单一数字就能表达 “比这个大的 stream 我都不接了”——一次性 cut-off。

③ 支持 priority 和 scheduling 的稳定排序——虽然 HTTP/2 的 priority 机制(RFC 7540)因为复杂被后来的 RFC 9218 替换、但 ID 作为 “哪个 stream 先到” 的时序标记仍然有用——fair queuing 算法可以按 ID 做 tie-breaking。

31 bit ID 空间(21 亿)对大多数连接永远用不完——一个每秒开 100 个新 stream 的连接要 6 个月才用完。这是协议给的安全余量——真的用完了、唯一合规动作是关连接、重建一条新连接从 ID=1 重新开始

h2 源码里有一个 next_stream_id: StreamId 字段、每 HEADERS 后 +2(保持奇偶性)、达到 31 bit 上限时发 GOAWAY 关连接。这个 overflow handling 一次都没在生产里被触发过(至少没有公开记录)——但代码存在、保证了即使极限场景也有确定行为

16.2.3 max_concurrent_streams:允许多少同时在跑

hyper 默认:

max_concurrent_streams: Some(200),

最多 200 个 stream 同时打开。超过这个数,对端发的新 HEADERS frame 会被 hyper 拒绝(RST_STREAM with REFUSED_STREAM)。

200 是一个中等激进的默认值。HTTP/2 spec 建议至少 100SETTINGS_MAX_CONCURRENT_STREAMS)。Nginx 默认 128、Envoy 默认 1024、Chrome 允许对方开到 1000。

为什么不设更高?每个 in-flight stream 消耗内存——至少一个 header map(几 KB)+ 一个 flow-control window 状态(几十字节)+ user service 的 future(视业务而定)。200 个 stream 大约 1-2 MB 内存。如果你每秒来几千个连接,全都开 1024 并发 stream——内存占用会放大 5 倍。

实战建议:内部服务(可信客户端)设高(1000+),暴露到公网(可能被滥用)设低(100-500)。

16.2.3.5 “Rapid Reset” 攻击暴露的 stream 生命周期陷阱

§16.2.1 提到 stream 可以从 open 直接 → closed(通过 RST_STREAM)。这个状态跃迁看似合法、却是 CVE-2023-44487 的关键——协议允许的合法状态跃迁被攻击者当作放大器

正常 stream 生命周期:

idle → open → half-closed → closed
  [建]   [传]    [收尾]       [结束]

每阶段都有 对等的通信成本——server 做的事情对应 client 做的事情。

Rapid Reset 攻击生命周期:

idle → open → closed     (RST_STREAM 直接关)
  [建]   [传]    [毁灭]

client 端的工作:发 HEADERS(几十字节)+ 发 RST_STREAM(9 字节)。

server 端的工作:解析 HEADERS、分配 header map、创建 stream state、调用 user service.call()(可能启动一个 Tokio task)、收到 RST_STREAM、cancel 那个 task、清理 state。

不对称性在哪里?——service.call() 内部可能已经:

  • 做了 auth check(查数据库)
  • 开始 body processing
  • 创建了 Tokio task
  • 申请了 memory

即使马上 cancel、这些副作用已经发生、不能 “撤销”。cancel 本身还要 走 Drop 链、释放 Future 内部所有资源。server CPU 的总消耗 >> client 的发送成本——这就是放大攻击的核心。

h2 crate 的修复 是在接收 HEADERS 后、调用 service.call 之前做延迟判断——如果某条 stream 的 HEADERS 到达时、前面连续 N 条 stream 在 service.call 之前就被 RST、就拒绝这条 HEADERS 的 service.call。这就把”service 未 accept 就被 reset”的 stream 数量封顶。

max_pending_accept_reset_streams 参数就是这个封顶值——默认 None 是为了向后兼容、生产推荐 Some(50)。这个数字的选择是:正常情况下客户端取消请求每秒不会超过几十次——50 次就已经远离正常使用区间、判为攻击合理。

16.2.4 FlowControl 结构的两个字段:window_size vs available

打开 h2-0.3.27/src/proto/streams/flow_control.rs:29-49FlowControl 核心数据结构只有两个字段——但它们的语义差异是整个 HTTP/2 流控的关键:

#[derive(Copy, Clone, Debug)]
pub struct FlowControl {
    /// Window the peer knows about.
    window_size: Window,
    /// Window that we know about.
    available: Window,
}

window_size——对端认为的窗口。对端收到 SETTINGS_INITIAL_WINDOW_SIZE 和后续的 WINDOW_UPDATE 都会更新这个值、代表”对端以为我还能接多少”。

available——我本地认为的窗口。我消费数据后、自己先把 available 增加、然后再决定什么时候发 WINDOW_UPDATE 告诉对端。

两者可能暂时不一致——available > window_size 意味着”我本地有还没同步给对端的窗口增量”、这就是 “unclaimed capacity” 的语义。

为什么要两个字段而不是一个?——因为发 WINDOW_UPDATE 也是有成本的(一个 4-byte 帧的网络开销)。如果每次消费 1 个字节就发一个 WINDOW_UPDATE(1)——大量小帧占满带宽。h2 的做法是:

  1. 消费时只改 available
  2. available - window_size 达到某个阈值、再发 WINDOW_UPDATE 同步

这就是 unclaimed_capacity 方法的来源——它检查 available - window_size 是否已经超过 window_size 的 1/2(两个常量 UNCLAIMED_NUMERATOR=1, UNCLAIMED_DENOMINATOR=2),超过才返回 Some(unclaimed) 让上层发 UPDATE 帧。

阈值用 1/2 而不是 1/4 或 3/4——因为:

  • 太小(如 1/8):频繁发 UPDATE、浪费带宽
  • 太大(如 3/4):对端等得太久、实际吞吐下降

1/2 是 Google / Cloudflare 多年生产经验积累的黄金比例——既不抖动、又能让对端有足够”缓冲”。这个常量写死在 h2 源码里、没有暴露给用户——因为它的调整不会有太大收益、不如节省 API 表面。

16.2.5 Window(i32) 允许负数的意义

Windowi32 而不是 u32——看注释(line 191-197):

/// The current capacity of a flow-controlled Window.
///
/// This number can go negative when either side has used a certain amount
/// of capacity when the other side advertises a reduction in size.

窗口可以是负数。什么场景?举个 h2 源码里 line 35-41 给的具体例子:

对端发了一个请求、用掉了 window 里的 32 KB。这时候我们发 SETTINGS_INITIAL_WINDOW_SIZE(16 KB)——把初始窗口从默认 64 KB 改成 16 KB。对端得调整自己的 window 认知:

default (64kb) - used (32kb) - settings_diff (64kb - 16kb) = -16kb

窗口变成 -16KB。这不是 bug、是 HTTP/2 spec 允许的合法状态(RFC 9113 §6.9.2)。只是意味着”对端在新的窗口策略下超发了 16KB”——不能再发、直到收到 WINDOW_UPDATE 把窗口拉回正数。

i32 而不是 u32必须的——u32 的减法下溢会 panic 或 wrap-around、都是错的。i32 让下溢变成合法的负数、代码逻辑自然处理。

这就是 HTTP/2 协议”可以动态调整窗口”带来的复杂性——SETTINGS 帧修改 INITIAL_WINDOW_SIZE影响所有已开 stream 的窗口、历史已发送量和新窗口之间的差就是负数来源。h2 把这个负数空间用 i32 安全覆盖——用类型防御协议边界

16.3 两级流控:Connection 级 + Stream 级

HTTP/2 的流控是最容易让人头大的部分。它有两层

  • Connection-level window整条连接的总流控窗口。
  • Stream-level window:每个 stream 独立的窗口。

发送数据时必须同时满足两个窗口都 > 0。任何一个为 0,数据就发不出去。

16.3.0.5 proto/h2/server.rs 里的 6 个 const 为什么要集中定义

第 16.1 节开头列出的 6 个常量集中在 hyper/src/proto/h2/server.rs:32-41

const DEFAULT_CONN_WINDOW: u32 = 1024 * 1024;           // 1mb
const DEFAULT_STREAM_WINDOW: u32 = 1024 * 1024;         // 1mb
const DEFAULT_MAX_FRAME_SIZE: u32 = 1024 * 16;          // 16kb
const DEFAULT_MAX_SEND_BUF_SIZE: usize = 1024 * 400;    // 400kb
const DEFAULT_SETTINGS_MAX_HEADER_LIST_SIZE: u32 = 1024 * 16; // 16kb
const DEFAULT_MAX_LOCAL_ERROR_RESET_STREAMS: usize = 1024;

注意几个细节

① 所有常量都用 1024 * N 表达——而不是 0x1000001048576可读性优先——maintainers 一眼看出”这是 1 MB”、不用心算 0x100000 = 2^20。代码审查时减少 “这个数对不对” 的认知负担。

② 用 u32 而不是 usize——因为它们要直接送进 h2 的 SETTINGS 帧、spec 规定这些值是 32-bit 无符号整数。usize 会在 32-bit 平台 / 64-bit 平台有不同宽度、塞进 frame 时可能静默溢出。协议字段类型就是协议字段类型、不借用 Rust 的 “自然整数” 习惯。

MAX_SEND_BUF_SIZEusize 而不是 u32——因为它不是协议字段、是 hyper 内部 buffer 的容量——以 usize 表达和 Vec/BytesMut 对齐。协议边界用 u32、内部边界用 usize——这是类型选择上的边界意识。

④ 集中定义而不是散在代码各处——让调参变成修改一个数字就完的事。如果每个默认值散在 Builder::new 的某一行、find-and-replace 就得找整个文件。集中定义 = 可维护性。

⑤ 名字带 DEFAULT_ 前缀——和 Builder::setter 接受的”用户自定义值”形成明显区分。后续代码里看到 DEFAULT_CONN_WINDOW 就知道这是 fallback、看到 self.initial_connection_window_size 就知道是用户 override。

16.3.1 为什么要两级

假设只有 Connection 级:

  • 某个 stream 发大量数据把 connection window 耗尽。
  • 其他 stream 即使 idle 也无法发数据。
  • 这和 HTTP/1 没有区别——一个慢 stream 阻塞整条连接

假设只有 Stream 级:

  • 每个 stream 独立窗口——没问题。
  • 但是 内存攻击:攻击者开 1000 个 stream,每个窗口 64KB,总共 64MB 内存——server 没法一起承担。

两级流控给细粒度控制:connection 级限制总容量,stream 级保证公平性。

16.3.1.5 FlowControl::inc_window 的 overflow 双重检查

inc_window 方法的实现展示了 h2 对数值安全的严谨(line 113-133):

pub fn inc_window(&mut self, sz: WindowSize) -> Result<(), Reason> {
    let (val, overflow) = self.window_size.0.overflowing_add(sz as i32);

    if overflow {
        return Err(Reason::FLOW_CONTROL_ERROR);
    }

    if val > MAX_WINDOW_SIZE as i32 {
        return Err(Reason::FLOW_CONTROL_ERROR);
    }

    // ...
    self.window_size = Window(val);
    Ok(())
}

两层检查

overflowing_add 检查 i32 溢出——szu32i32、理论上 sz 可能超过 i32::MAX(尽管 WINDOW_UPDATE 的 spec 限制是 31-bit 正数)。overflowing_add 返回 (result, did_overflow) 元组、安全检查溢出。

val > MAX_WINDOW_SIZE 检查协议上限——即使 i32 没溢出、HTTP/2 spec 规定窗口不能超过 2^31 - 1MAX_WINDOW_SIZE)。如果对端发 WINDOW_UPDATE 让我们超过这个数、我们 必须拒绝并关连接(以 FLOW_CONTROL_ERROR 发 GOAWAY)。

两个检查对应两种不同的错误源

  • 溢出:我们自己的整数溢出 bug 或恶意对端发超大值
  • 超限:协议规定的硬上限被突破

两者都返回同一个 Reason::FLOW_CONTROL_ERROR——对下游处理流程来说一视同仁(都触发连接关闭)、但日志层面可以区分(tracing::trace! 打印不同 message)。

这种 “多层防御 + 同一错误码” 的模式是 Rust 网络库的经典——攻击者不该能利用不同错误码的区别(比如用溢出 vs 超限探测实现细节)、所以对外统一;但内部日志要能区分、便于运维定位。

16.3.2 初始窗口

HTTP/2 spec 默认每个 stream 的 initial window 是 65535 字节(2^16 - 1,历史原因)。hyper 的默认覆盖:

initial_conn_window_size: 1024 * 1024,        // 1 MB
initial_stream_window_size: 1024 * 1024,      // 1 MB

hyper 默认 1 MB——spec 默认的 16 倍。为什么?

spec 注释给了答案:

// Our defaults are chosen for the "majority" case, which usually are not
// resource constrained, and so the spec default of 64kb can be too limiting
// for performance.

64KB 窗口在现代网络里太小了。考虑 BDP(bandwidth-delay product):

  • 100Mbps 带宽 + 50ms 往返延迟 → BDP = 100e6 / 8 × 0.05 = 625 KB
  • 10Gbps 带宽 + 1ms 往返延迟(数据中心内部)→ BDP = 10e9 / 8 × 0.001 = 1.25 MB

窗口小于 BDP 意味着 TCP pipe 填不满。64KB 窗口在 100Mbps/50ms 链路上只能跑 ~10Mbps(一直 stop-and-wait),95% 带宽浪费。

1 MB 是个”大多数场景能填满 pipe、内存不过分”的折中。每连接 2 MB(connection 窗口 + 一个 stream 的窗口),200 并发 stream 总 402 MB。你有 32 GB 内存的 server 可以放心用。

16.3.2.5 send_dataassert! 断言:由 caller 保证窗口有容量

FlowControl::send_data 的关键断言(line 168-187):

/// Decrements the window reflecting data has actually been sent. The caller
/// must ensure that the window has capacity.
pub fn send_data(&mut self, sz: WindowSize) -> Result<(), Reason> {
    if sz > 0 {
        // Ensure that the argument is correct
        assert!(self.window_size.0 >= sz as i32);

        // Update values
        self.window_size.decrease_by(sz)?;
        self.available.decrease_by(sz)?;
    }
    Ok(())
}

assert!(self.window_size.0 >= sz as i32)——断言窗口必须有足够容量。注释明确说 “The caller must ensure that the window has capacity”。

为什么用 assert 而不是返回 Err?——因为发送方在调用 send_data 之前就应该通过 poll_capacity 等到窗口有 sz 字节才发——调 send_data 时超容量是内部 bug、不是外部意外。bug 用 assert panic 是 Rust 的惯例(§14.6.1 讲过同样思想)。

这个 assert 还有一个实用价值——快速暴露 prioritize 模块的 bug。h2 的 prioritize 逻辑(决定哪个 stream 发多少数据)相当复杂、偶尔会有”本来要发 100 字节、但算错了窗口剩余只有 50 字节”这种 off-by-one。assert 让这种 bug 在第一次触发时立刻 panic、而不是 silent 数据不一致、后续慢慢偏离状态。

if sz > 0 前置判断——sz == 0 的情况直接跳过(零字节发送对窗口无影响)。这在 HTTP/2 里有意义——END_STREAM flag 可以带在一个空 DATA frame 上DATA, len=0, END_STREAM=1)、代表”没数据但 stream 结束”。这种帧不消耗窗口、也不该触发断言。

16.3.2.6 为什么 h2 的 flow control 要分 claim_capacityassign_capacity

API 里有一对看起来对称的方法(line 78-84):

pub fn claim_capacity(&mut self, capacity: WindowSize) -> Result<(), Reason> {
    self.available.decrease_by(capacity)
}

pub fn assign_capacity(&mut self, capacity: WindowSize) -> Result<(), Reason> {
    self.available.increase_by(capacity)
}

两个都操作 available 字段——一个减一个加。为什么取这两个名字?

  • claim_capacity:应用层想发送数据时调用——“我要占 N 字节的配额”。
  • assign_capacity:有新数据被 peer 确认消费(或初始化时)——“给我分配 N 字节”。

动词区分”消费者 vs 生产者”的视角——claim 是 taking(消费)、assign 是 giving(分配)。这种明确的动词对让代码读起来语义自然——不需要看实现就能从方法名推断作用。

这和 §14.1.2 讲的 Dur 三态一样——是 Rust API 设计的”语义显式化”原则。命名不是 decoration、是编译器看不见但人看得见的类型信息

16.3.3 WINDOW_UPDATE

读取方通过WINDOW_UPDATE告诉发送方”我又处理掉了 N 字节,你可以多发 N 字节”。

WINDOW_UPDATE frame payload: increment (31 bit)
stream_id == 0  →  更新 connection window
stream_id >  0  →  更新该 stream 的 window

hyper 通过 h2 crate 自动管理 WINDOW_UPDATE——当 body 被用户 consumer 消费(调用 body.poll_frame 后数据被真正取走),h2 会根据”消费了多少”发送对应的 WINDOW_UPDATE。

这一层自动化很关键——你读 body 慢,h2 就晚发 WINDOW_UPDATE,对端就被 back-pressure 到发得慢。流控信号从业务层一路回到对端 TCP 发送缓冲——这就是 HTTP/2 “真正端到端背压” 的机制。

16.3.4 实测:流控信号的传导

想象一个场景:

  • Client 上传一个 100 MB 的 body。
  • Server 处理每个 chunk 需要 10ms(例如写磁盘)。

没有流控:client 按网络带宽全速发 → 100MB 全到 server 的 socket buffer → server 进程内存爆。

有流控

  1. Server 初始窗口 1 MB。
  2. Client 发 1 MB 后流控打到 0,不再发。
  3. Server 业务代码消费掉 100 KB(body.poll_frame → data 被复制出来)。
  4. h2 发 WINDOW_UPDATE(100_000)
  5. Client 收到,再发 100 KB。
  6. 循环。

Client 的发送速度被 server 的消费速度反向拉住——永远不会超速。在 tokio 层面,这个约束通过 h2::SendStream::poll_capacity 传递到 user 层——这就是上一章末尾讨论过的 PipeToSendStream 循环。

16.3.4.5 dec_send_window vs dec_recv_window:为什么 recv 还要改 available

h2 有两个看似对称的 decrement 方法(line 139-166):

pub fn dec_send_window(&mut self, sz: WindowSize) -> Result<(), Reason> {
    self.window_size.decrease_by(sz)?;
    Ok(())
}

pub fn dec_recv_window(&mut self, sz: WindowSize) -> Result<(), Reason> {
    self.window_size.decrease_by(sz)?;
    self.available.decrease_by(sz)?;  // ← 多了这一行
    Ok(())
}

两者都在 “接收到 SETTINGS 帧修改 INITIAL_WINDOW_SIZE 后” 调用。但 dec_recv_window 多改一个 available——为什么不对称?

原因在于谁拥有信息

send 方向dec_send_window本地发送方调用——对端通过 SETTINGS 告诉我们”我接收的窗口变小了”。我们要调整 window_size(对端认为的窗口)来反映这个变化。available(我们认为的发送配额)不需要改——因为 available 是 send_data 扣减的、和 receive_side SETTINGS 无关。

recv 方向dec_recv_window本地接收方调用——我们把 ACK 发给对端、告诉对端”我接收的窗口变小了”。同时我们自己的 available(代表 “对端还能塞给我多少字节”)也要相应减少——因为 receive 侧的窗口已经不是之前那么大。

两行 vs 一行的差异体现了 “接收侧 flow control 是双向可见的”——既要让对端知道、也要本地立刻收紧。send 侧只需要单向同步——只让本地状态反映对端的认知变化。

这种非对称性在 HTTP/2 spec 里是隐含的、h2 源码把它落成两个方法名不同的函数——用名字提醒读者 “别把两者当成对称的”、避免未来维护者犯对称迁移错。

16.3.5 adaptive_window:动态调整

adaptive_window: bool,  // 默认 false

设为 true 时,hyper 根据 RTT 和消费速度自动调整窗口大小。机制:

  1. 用 PING 测 RTT。
  2. 用 “BDP 估算” 算出合理的窗口大小:RTT × 带宽 的 2-4 倍。
  3. 通过 WINDOW_UPDATE 动态调整实际窗口。

这个启发式来自 BBR congestion control 的思路——让 flow-control window 跟上实际带宽。开启之后对吞吐通常有 20-50% 提升。

hyper 默认关闭——因为需要更多内存、在某些场景会”窗口放大攻击”(malicious client 故意延迟发 ACK 让你持续放大窗口)。生产代码如果内部服务可信、追求极致吞吐,建议开

16.3.6 PartialOrd<usize> 的手写实现

Window(i32) 的比较(line 249-257)不是 derive 而是手写

impl PartialOrd<usize> for Window {
    fn partial_cmp(&self, other: &usize) -> Option<::std::cmp::Ordering> {
        if self.0 < 0 {
            Some(::std::cmp::Ordering::Less)
        } else {
            (self.0 as usize).partial_cmp(other)
        }
    }
}

为什么 Window 要对 usize 实现 PartialOrd?——因为 h2 内部大量做 “窗口是否 >= 需要发送的字节数” 的判断、且字节数类型是 usize

if window >= bytes_to_send {
    // 可以发
}

如果 Window 只实现 PartialOrd<Window>、上面比较就要 Window(bytes_to_send as i32) 显式包装、语法噪音大、还有溢出风险。直接实现 PartialOrd<Window> for usize(反过来实现一次 PartialOrd<usize> for Window)、比较语法就变回自然的 window >= bytes_to_send

负数处理——if self.0 < 0 时直接返回 Less——因为负数 Window 不能容纳任何正数字节、negative_window >= positive_bytes 永远 false。这个显式检查避免了 i32 -> usize 转换时的 cast 歧义(-16 as usize 在 Rust 里是巨大的正数、会让比较走错分支)。

这种 “基础类型实现 PartialOrd 跨类型” 的手写模式在 Rust 网络代码里很常见——bytes::BytesMut&[u8] 实现 PartialEq、chrono::DateTimeSystemTime——让不同来源的值能直接对比、节省转换样板代码。

16.4 max_frame_size vs max_send_buf_size

两个看起来类似的参数——容易混。

16.4.1 max_frame_size(16KB 默认)

这是 HTTP/2 协议层的 SETTINGS 参数——告诉对端 “我接收的单个 frame payload 最大多少”。hyper 默认 16KB(spec 允许的最小值),h2 spec 允许最大 16 MB。

16KB 是一个好的默认:

  • cache 友好:一个 frame 刚好放 L2 cache 的一小部分。
  • 响应迅速:对端发大数据时会被切成多 frame——我们可以在 frame 之间插入其他 stream 的 frame,实现交错。

设大(比如 1MB)的副作用:一个大 stream 的 DATA frame 独占线路几 ms,其他 stream 的 frame 要等——变相 head-of-line blocking。实操几乎没人调大这个。

16.4.2 max_send_buf_size(400KB 默认)

这是 hyper 内部的实现参数——“发送队列缓存最多多少字节”。发送方从 user body 拉 frame 装到这个 buffer,按流控节奏冲出去。

为什么 hyper 要一个发送缓冲?因为 user body 的 poll_frame 不能跟着网络节奏跑——业务代码可能一次吐 1MB、下次 1 秒才吐 1KB。hyper 需要平滑这个节奏,发送缓冲吸收突刺。

400 KB 是默认。设大→ 吞吐更稳、内存占用升。设小→ 内存省、突发 throughput 容易被截断。

16.4.3 unclaimed_capacity 的阈值聚合:避免微小 WINDOW_UPDATE 雪崩

§16.2.4 提到 unclaimed_capacity 用 1/2 阈值聚合 WINDOW_UPDATE 的发送时机。看完整实现(line 93-108):

pub fn unclaimed_capacity(&self) -> Option<WindowSize> {
    let available = self.available;

    if self.window_size >= available {
        return None;
    }

    let unclaimed = available.0 - self.window_size.0;
    let threshold = self.window_size.0 / UNCLAIMED_DENOMINATOR * UNCLAIMED_NUMERATOR;

    if unclaimed < threshold {
        None
    } else {
        Some(unclaimed as WindowSize)
    }
}

三步判断

if self.window_size >= available { return None }——如果对端认为的窗口 >= 本地可用、不存在 “对端不知道的增量”、不需要发 UPDATE。

unclaimed = available - window_size——计算待同步的增量。

if unclaimed < threshold { None }——如果增量还没超过阈值(窗口的 1/2)、不发 UPDATE、等下次

**为什么这样聚合?**算一个极端例子:

如果没有阈值、用户每 read 1 个字节都发一次 WINDOW_UPDATE——读 1 MB 数据会产生 1 百万个 WINDOW_UPDATE frame。每个 frame 9-byte header + 4-byte payload = 13 字节、总共 13 MB 的 UPDATE 流量——比原始数据还多

有了 1/2 阈值:一个 1 MB 窗口被消费掉 500 KB 才发一个 UPDATE。1 MB 数据只会产生 ~2 个 UPDATE frame、总共几十字节——overhead 接近 0

这个阈值不是 HTTP/2 spec 要求的——spec 没规定聚合策略、只规定不能乱发超过窗口限制的 UPDATE。h2 的 1/2 是实现层面的最佳实践——和 TCP 的 delayed ACK、文件系统的 write coalescing 是同一种”用延迟换吞吐”的优化。

16.5 max_concurrent_streams × stream_window 的内存数学

把前面的参数组合起来,单连接的内存上界大约:

per-connection-memory 
  = max_concurrent_streams × stream_window + conn_window + per-stream overhead
  = 200 × 1MB + 1MB + 200 × ~5KB
  ≈ 202 MB  (!!)

一个 HTTP/2 连接最坏情况 200 MB。假设你有 100 个并发连接——20 GB。

当然这是最坏情况——所有 200 个 stream 都 full buffer。实际运行时 stream 数和 body 大小都是动态的,典型占用是理论上界的 5-10%。但理论上界是你部署规划时必须考虑的数字

实操调参的几条经验:

  • 流量小(<1000 QPS):默认(1 MB × 200)就用,不担心。
  • 公网入口(可能被攻击):max_concurrent_streams(50) + stream_window(64 KB) —— 把攻击面缩小到一次 3MB / 连接。
  • 内网高吞吐adaptive_window(true) + 大 window(4-16 MB)—— 让 BDP 被填满。

16.5.1 sanity_unclaimed_ratio 单元测试作为文档

flow_control.rs 开头有一个看起来多余的测试(line 21-27):

#[test]
#[allow(clippy::assertions_on_constants)]
fn sanity_unclaimed_ratio() {
    assert!(UNCLAIMED_NUMERATOR < UNCLAIMED_DENOMINATOR);
    assert!(UNCLAIMED_NUMERATOR >= 0);
    assert!(UNCLAIMED_DENOMINATOR > 0);
}

这个测试在 “对两个 const 做 assert”——编译器完全可以做这件事(static_assert!)。为什么写成 runtime test?

原因是自文档化——未来有人想调整这两个常量(比如改成 3/4 或 1/3)、CI 会立刻跑这个测试、如果意外把 UNCLAIMED_NUMERATOR 设大于 UNCLAIMED_DENOMINATOR(会导致逻辑崩溃),测试会 fail、提醒开发者 “这两个常量有约束关系”。

#[allow(clippy::assertions_on_constants)] 一行很有意思——clippy 默认会提示 “assert 常量没意义”、开发者显式 allow 告诉 clippy “我知道、这是有意的”。这种 “显式允许 lint 规则为了特定意图” 是 Rust 社区的礼貌——不 silent 地 #![allow(...)] 全局禁用、只在需要的地方局部允许

这种 “防御性单元测试” 成本极低(跑 3 个 assert 几纳秒)、但把常量之间的不变式钉在 CI 上——比注释”请保持 NUMERATOR < DENOMINATOR”好得多。注释会被忽略、测试会被跑。

16.6 max_pending_accept_reset_streams:CVE-2023-44487 防御

最刺激的一个参数。

16.6.1 HTTP/2 Rapid Reset 攻击

2023 年 10 月披露的 CVE——一种利用 HTTP/2 多路复用 + RST_STREAM 组合的 DoS 攻击。机制:

  1. 客户端发 HEADERS / stream_id=1
  2. 客户端立刻发 RST_STREAM / stream_id=1——取消请求。
  3. 重复上面两步——每对 frame 组合大约 20-30 字节。
  4. server 端收到 HEADERS 后需要:
    • 解析 HPACK 分配 header map。
    • 触发 service.call(req) —— 可能已经调 call 了 —— 分配了各种资源。
    • 然后收到 RST_STREAM —— 取消 service future、回收资源。
  5. 攻击者的发送 cost 小(几十 bytes/op),但 server 的处理 cost 大(分配 + service future + 取消)。

此前很多 HTTP/2 实现(包括 Nginx、Envoy、Go/Apache)不对这个组合做限流——攻击者可以在一条 TCP 连接上每秒发 数十万次 HEADERS+RST_STREAM 循环,把 server CPU 打到 100%。

CVE-2023-44487 攻击被 Google、Amazon、Cloudflare 同时观察到——峰值 3.98 亿 requests/second,比他们之前的 DDoS 记录大 7 倍。

16.6.2 hyper 的防御

hyper 在 CVE 披露后(2023 年 10 月 10 日)当天发布 0.14.x 补丁(后向 1.0 同步),加入两个参数:

max_pending_accept_reset_streams: Option<usize>,
max_local_error_reset_streams: Option<usize>,  // 默认 1024
  • max_pending_accept_reset_streams:客户端可以 RST 而我们还没开始 accept 的 stream 最大数量。超过 hyper 发 GOAWAY 关连接。
  • max_local_error_reset_streams我们因为某种协议错误 reset 的 stream 数量(如收到非法 header)。攻击者可以故意发非法 header 让我们 reset,这个限流防止 “我们自己被放大”。

默认 max_local_error_reset_streams = 1024 —— 1024 次本地 reset 后关连接。合理——正常客户端很少触发非法 header;1024 次已是极大嫌疑

max_pending_accept_reset_streams 默认 None —— 因为”pending accept” 的判断依赖于 h2 crate 内部行为,hyper 把选择权留给用户。生产建议设成 Some(50) 或类似——“允许 50 个正常取消,超过就判为攻击”。

16.6.3 更深一层:协议层的固有问题

CVE-2023-44487 是 HTTP/2 协议设计的一个系统性问题——请求取消不需要发送方付出显著代价。这是 HTTP/1 不会有的问题(HTTP/1 取消=关连接,显著代价)。

后续 RFC 9113 更新和 HTTP/3(QUIC)引入了 “Stream Reset Ratio” 限流建议——每 stream 开启 / 取消比例超过一定值就触发 rate limiting。hyper 实现了 pending accept 和 local error 两个 counter,是这类防御的实现。

这是一个经典的 “协议、库、运维” 三层共同承担 DoS 的案例——协议修复要时间,库能发防御补丁,运维要主动配置防御参数。如果你的服务暴露在公网,必须 调这两个参数。

16.6.4 max_local_error_reset_streamsReason::FLOW_CONTROL_ERROR 的绑定

max_local_error_reset_streams 防御的一个具体场景——对端发违反流控规则的数据、触发 h2 发 RST_STREAM:

Client → HEADERS (stream 1)
Client → DATA (stream 1, 500KB) ← 违反 window_size = 1 KB
Server (h2) → RST_STREAM (stream 1, FLOW_CONTROL_ERROR)

每次这种违规、h2 都要:

  1. 检测违规(FlowControl::send_dataclaim_capacity 返回 Err(FLOW_CONTROL_ERROR)
  2. 在内部记录 “这个 stream 要 reset”
  3. 下次 poll 时把 RST_STREAM 发出去
  4. 清理 stream 状态(从 store 里移除)

攻击者可以故意连续发违规 DATA、让 server 不停走这四步——如果没限流、server CPU 被消耗在处理 RST_STREAM 上、无法处理正常请求。

max_local_error_reset_streams = 1024 让 h2 计数——本连接内已经因为本地错误 reset 了多少 stream。超过后 h2 不再 reset、而是直接发 GOAWAY 关连接——“你这客户端有问题、不陪你玩了”。

这和 §16.6.2 讲的 max_pending_accept_reset_streams两种互补防御

  • max_pending_accept_reset_streams——防客户端主动 RST 还没 accept 的 stream(HEADERS + RST_STREAM 组合)
  • max_local_error_reset_streams——防服务端被迫 RST 因协议错误(违规 DATA / 非法 header 等)

两者都是对 “无限 reset 放大攻击” 的配额防御——一次 CVE 事件教会 hyper 在两个维度同时加防。

16.7 对照:HTTP/1 的 pipeline vs HTTP/2 的 multiplex

第 1 章里提过 HTTP/1 的 pipelining 实际上弃用了。这里对照一下为什么 HTTP/2 的多路复用”更好”。

HTTP/1 Pipelining

Client:  [Req A] [Req B] [Req C]   →
Server:  [Resp A] [Resp B] [Resp C]  ←

响应必须按请求顺序返回——这就是 HOL (head-of-line) blocking。如果 Resp A 慢,Resp B/C 都得等——哪怕 Resp B 已经在 server 内存里写好了。

HTTP/2 Multiplex

Client:  [HEADERS A] [HEADERS B] [HEADERS C]  →
Server:  [HEADERS B, DATA B=0-100B] [DATA A=0-500B] [HEADERS C, DATA C=0-50B] [DATA B=100-200B] ...  ←

响应可以乱序、分片、交错——Resp B 先到没关系,Resp A 同时慢慢发,所有 stream 独立。这才真正解决了 HOL。

但 HTTP/2 有自己的 HOL——TCP 级别。HTTP/2 所有 stream 跑在一条 TCP 连接上——如果 TCP 丢包,整条连接停等重传——所有 stream 都被影响。这是 HTTP/3/QUIC 解决的问题(每个 stream 独立 TCP-like 传输)。

按时序看:

  • HTTP/1.0:每请求一连接。
  • HTTP/1.1:keep-alive,但仍然一条 tcp 一次一请求。
  • HTTP/2:多路复用,消除应用层 HOL,保留 TCP HOL。
  • HTTP/3 (QUIC):每个 stream 独立 TCP-like 传输,消除 TCP HOL。

HTTP/2 是一个 “足够好、部署成熟” 的平衡点——过去十年的主流。HTTP/3 正在逐步铺开,但 hyper 目前不内建(有 h3 crate 但生态还在成长,本书不展开)。

16.7.5 每个 stream 的 FlowControl 独立实例

h2 的 Stream 结构体里每个 stream 有两个独立 FlowControl 实例

pub(super) struct Stream {
    // ... 其他字段 ...
    pub send_flow: FlowControl,  // 本端发送侧窗口
    pub recv_flow: FlowControl,  // 本端接收侧窗口
}

加上 Connection 结构里的连接级 FlowControl、一条 HTTP/2 连接同时存在:

  • 1 个 conn_send_flow(发送侧的连接总窗口)
  • 1 个 conn_recv_flow(接收侧的连接总窗口)
  • N 个 stream_send_flow(每个 stream 的发送侧窗口)
  • N 个 stream_recv_flow(每个 stream 的接收侧窗口)

总共 2 + 2N 个 FlowControl 实例、每个 ~24 字节(两个 i32 + 一些元数据)——200 个 stream 大约 9.6 KB。

为什么拆得这么细?——因为 HTTP/2 的流控是双层双向的:

  • 连接级 × send 方向——我能向对端发多少
  • 连接级 × recv 方向——对端能向我发多少
  • stream 级 × send 方向——我能在这条 stream 上发多少
  • stream 级 × recv 方向——对端能在这条 stream 上向我发多少

每个维度都有独立的 SETTINGS、独立的 WINDOW_UPDATE、独立的消耗轨迹——必须独立追踪。合并任意两个都会导致语义错误。

这种 “多维度状态分别承载” 在 h2 里是贯彻到底的——第一眼看到感觉代码重、但每个维度都在做自己的计数、不会相互污染。Rust 的类型系统让这种”4 个 FlowControl 实例各司其职”的设计在编译期就不会搞混——你不会不小心把 stream_send 的 available 加到 conn_recv 上。

16.8 与 Tokio Semaphore 的对照

回想第 4 章 Tower 的 ConcurrencyLimitSemaphore 做流控。HTTP/2 的 window 和 Semaphore 是同一个抽象的两种实现:

特征SemaphoreHTTP/2 Window
单位许可(discrete)字节(continuous)
获取acquire(1)poll_capacity(n)
释放permit.drop()send_data 后自动释放
范围进程内跨网络
分级Connection + Stream

都是 “counting + backpressure” 的 pattern——只是实现尺度不同。Semaphore 在进程内、Window 在跨网络。

读过卷四《Tokio 源码深度解析》第 12 章的 Semaphore 源码 后再读 h2 的 flow control——你会感到两者的状态机骨架一模一样

  • 当前可用容量(remaining)
  • 等待队列(waiters)
  • 唤醒信号(waker)

把流控这件事抽象到”counting primitive”层面之后,网络的 flow control 和进程内的并发控制可以用同一套心智模型理解。这是 async runtime 的一个巨大价值——让不同物理层次的资源管理共享统一抽象

16.8.1 本章与其他章节的呼应

与第 12 章(Dispatcher)的呼应——HTTP/1 Dispatcher 的 keep_alive 三态(Busy/Idle/Disabled)和 HTTP/2 的 stream state(idle/open/half-closed/closed)都是 请求生命周期状态机、只是粒度不同:HTTP/1 在连接级、HTTP/2 在 stream 级。状态机思想一脉相承、具体机制随协议变。

与第 15 章(h2 HPACK)的呼应——本章讲的 max_header_list_size 和第 15 章 HPACK 编解码的 dynamic table 紧密相关——limit 头列表大小是HPACK table 爆炸的防御(CVE 历史上还有 HPACK Bomb 攻击)。两章的防御机制是协议不同层面的同一种抵抗

与 vllm 第 8 章的呼应——HTTP/2 的 WINDOW_UPDATE 聚合(1/2 阈值)和 vllm 的 CUDA Graph capture sizes 选择([1,2,4,8,16,24,32,48,64,...])都是 “粗粒度阶梯 + 平均 50% 浪费换掉大量小操作” 的工程权衡——一个在网络帧、一个在 GPU 启动——问题空间不同、优化直觉完全一样

与 Vite 第 15 章 SSR 的呼应——vite 的 concurrentModuleNodePromises Map 去重和 hyper 的 WINDOW_UPDATE 聚合都是 “合并瞬时大量相似操作” 的模式——前者是并发 fetch、后者是 UPDATE 帧——都通过 “短暂等待 + 批量发出” 换来系统整体效率。

16.9 生产推荐配置

基于上面的讨论,给一个生产级 hyper HTTP/2 server 的推荐配置:

use std::time::Duration;
use hyper_util::rt::{TokioExecutor, TokioTimer};

let http = hyper::server::conn::http2::Builder::new(TokioExecutor::new());
http.timer(TokioTimer::new())
    
    // 流控 - 内网服务可调大到 4 MB
    .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)
    
    // CVE-2023-44487 防御
    .max_pending_accept_reset_streams(Some(50))
    .max_local_error_reset_streams(Some(1024))
    
    // 保活(第 17 章详讲)
    .keep_alive_interval(Some(Duration::from_secs(10)))
    .keep_alive_timeout(Duration::from_secs(20))
    
    // 限制 header
    .max_header_list_size(16 * 1024)                   // 16 KB
    
    // 发送缓冲
    .max_send_buf_size(256 * 1024)                     // 256 KB
    
    .serve_connection(io, svc)
    .await

公网暴露场景加:

.max_concurrent_streams(50)                // 降低并发 stream
.initial_stream_window_size(64 * 1024)     // 降低内存攻击面

每一条都有具体理由——每一条修改都回答”防御什么”或”优化什么”。

16.9.5 FlowControl 的三条不变式

读完 h2 flow_control.rs 的全部 269 行、可以总结出 FlowControl 维护的三条关键不变式:

① 双字段偏差有上界——available - window_size 最多等于”窗口大小”(full window 都被消费但还没发 UPDATE)、不会更多。这是因为 available 只有在 assign_capacity 时才增加、而 assign_capacity 本身受协议限制(对端不会发超过 MAX_WINDOW_SIZE 的 UPDATE)。

window_size 可负、available 可负——都是 i32。负数代表 “我/对端已经超发,必须补发 UPDATE 才能恢复正常发送” 的暂态。但两个字段加减运算都走 checked_ 路径——溢出直接返回 FLOW_CONTROL_ERROR、关连接。

③ WINDOW_UPDATE 的生成是幂等的——多次调用 unclaimed_capacity 返回同一个值、调用 assign_capacity 后才会变。发 UPDATE 的决策(由上层做)也是由这个 pure 查询驱动——这让整个 flow control 可以在单元测试里被穷尽验证。

这三条不变式在 h2 的任何状态机迁移后都必须保持——recv_data、send_data、inc_window、dec_xxx_window 这些变更方法的每一个都要让这三条成立。对每个方法、可以逐个分析它是否破坏这些不变式——这就是 Rust 库代码可推理性的来源。

不变式显式化的另一个好处是模糊测试(fuzzing)友好——写一个 fuzzer、随机调 FlowControl 的所有方法、每次调用后检查三条不变式是否成立——就能穷尽测试状态空间。h2 的 fuzz 目录里就有这种结构。不变式是代码的 contract、是 fuzz 的 oracle——两者相辅相成。

16.10 落到你键盘上

  • h2/src/proto/streams/flow_control.rs——真正的 flow control 算法在那里。它是 HTTP/2 flow control spec 的直接 Rust 实现,带 counter、waker、限制检测,不到 400 行。
  • wrk2 + HTTP/2 对你的服务做压力测试——观察不同 initial_window_size 下 throughput 的差别。你会看到 64 KB vs 1 MB 在高带宽链路上差 10 倍。
  • 模拟 rapid reset 攻击(在受控环境里)——用 Go/Rust 写一个 client,不停发 HEADERS + RST_STREAM。不配 max_pending_accept_reset_streams 的 server 很快 CPU 100%;配了的 server 会在 50 次后关连接。直观感受防御效果。

下一章讲 HTTP/2 的”连接生存机制”——PING、GOAWAY 和超时。这三样一起构成 HTTP/2 在长连接下的健康度管理。

16.11 本章要点的九条工程原则

① 双字段解耦对端认知与本地认知(§16.2.4)——window_size vs available 让 WINDOW_UPDATE 可以延迟聚合。

② 整数类型承载协议合法负数空间(§16.2.5)——i32 Window 覆盖 SETTINGS 引发的暂态超发。

③ overflowing_add + 双重上限(§16.3.1.5)——一层拦住整数溢出 bug、一层拦住协议超限。

④ assert 断言 caller 合同(§16.3.2.5)——内部 bug 用 panic 暴露、合同违反在最早点爆出。

⑤ 语义明确的动词命名(§16.3.2.6)——claim vs assign 表达消费者 vs 生产者视角。

⑥ 非对称 decrement 方法(§16.3.4.5)——recv 方向多改 available、名字提醒 “别假对称”。

PartialOrd<usize> 跨类型比较(§16.3.6)——避免 cast 噪音、同时防御负数语义。

⑧ 1/2 阈值聚合 UPDATE(§16.4.3)——运营经验量化成协议优化常量。

⑨ 单测作为常量不变式文档(§16.5.1)——防御性测试比注释更可靠。

这九条原则把一个 269 行的小模块放到工业级位置——每一条都不是可选。把 FlowControl 和自己写过的”简单计数器”对比、你会发现”真正可用的计数器”有多深。

读本章还能体会一个跨越本系列的哲学——协议细节的正确实现比协议本身难十倍。HTTP/2 spec 定义了”窗口流控”的抽象语义、几百字就讲完;把它转成可靠、高效、抗恶意的工业代码、却需要 269 行精心推敲、加几百行协议事故补丁、外加一次 CVE 驱动的紧急发版。这就是工业库的价值——把协议的可操作距离从”能理解 spec”压缩到”能上生产”

16.12 本章阅读推荐

读完本章、建议按下面顺序深入:

  • h2/src/proto/streams/flow_control.rs(269 行)——本章的主源文件、读一遍完整的。
  • h2/src/proto/streams/prioritize.rs——在窗口允许发多少的基础上、谁先发的决策——承载了协议的 fairness 语义。
  • h2/src/proto/streams/counts.rs——stream 计数和 max_concurrent_streams 的实施点、以及 CVE 防御的实际落点。
  • h2/src/server.rs——把上面几个模块粘起来的服务端状态机。
  • hyper/src/proto/h2/server.rs——hyper 如何 wrap h2、提供 Builder API。

五个文件合计 ~3000 行——两天能读完。读完后你对”HTTP/2 作为多路复用协议到底怎么工作”会有比读 spec 更深入的理解——因为你看到了在每个协议条款下工程师为了正确实现做了什么

这是读工业库源码的独特价值——spec 告诉你”做什么”、实现告诉你”为了做这件事要处理多少 edge case”。前者是规范、后者是工程智慧——两者缺一不可。

16.13 从 FlowControl 到 Tower 中间件:一个类型级的统一

回到本章开头提到的 hyper Builder 的 6 个 const——它们都在静态定义一个系统的行为约束

整个 Rust HTTP 生态是怎么处理这类约束的:

  • hyper::server::conn::http2::Builder 的 6 个 const + ~20 个 setter——定义协议级行为
  • tower::ServiceBuilder::new().timeout(..).concurrency_limit(..).rate_limit(..) ——定义中间件级行为
  • http_body_util::Limited::layer(N) ——定义消息级行为

三层叠加、通过 Rust 的 trait 系统组合到一起、编译期就能确定整条请求路径的约束集。没有哪一层需要知道另一层的细节——hyper 只看到 Service<Request> → Response、tower 看到 Layer<Service> → Service、http-body 看到 Body

这种分层可组合的抽象是 Rust 生态的美学——每一层只暴露最少的接口、但组合起来能表达极其复杂的约束网络。和 C++ 的 Folly / Beast、Go 的 net/http 比较——Rust 生态的可组合性明显高出一个数量级。

Rust HTTP 服务的配置层看起来啰嗦、上手陡,这是清晰分层的必然——每一层解决不同维度的问题、每一层都有自己的最小 API。没有哪一层需要理解另一层的实现,组合起来就能表达复杂的约束网络。下一章继续这条主线,讲 PING / GOAWAY / 超时管理如何在同一套类型系统里协作。