Hyper 与 Tower:工业级 HTTP 栈

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

作者 杨艺韬 · 12,733 字 · 发布于 · 更新于

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

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

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

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

// hyper-1.9.0/src/proto/h2/server.rs:36-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;

六个数字。每一个背后都是一个具体的协议机制 + 一个你部署时必须做的判断——其中至少一个(MAX_LOCAL_ERROR_RESET_STREAMS)直接来自一次安全事件的后续加固。

本章引用的 h2 源码行号都按专栏首页声明的 h2 0.3.27;hyper 1.9.0 实际依赖的 0.4.x 里 src/proto/streams/flow_control.rs 与 0.3.27 逐字节相同,recv.rs、send.rs、prioritize.rs、streams.rs 等文件的行号则略有出入。文中的实测都用 hyper 1.9.0 + h2 0.4.7 + tokio 1.46.1。

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.3 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 9113 废弃、由 RFC 9218 的新方案取代、但 ID 作为 "哪个 stream 先到" 的时序标记仍然有用——fair queuing 算法可以按 ID 做 tie-breaking。

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

h2 源码里 Send 结构有一个 next_stream_id: Result<StreamId, StreamIdOverflow> 字段(h2-0.3.27/src/proto/streams/send.rs:20)、本端每开一个 stream 就用 StreamId::next_id() +2(保持奇偶性,h2-0.3.27/src/frame/stream_id.rs:69-72);一旦溢出、字段变成 Err(StreamIdOverflow)、之后再想开新 stream 会在 ensure_next_stream_id 处得到 UserError::OverflowedStreamId(h2-0.3.27/src/proto/streams/send.rs:557-560)——不是发 GOAWAY、而是把"再也开不了新 stream"的事实交给上层去关连接。极限场景极少触发、但代码存在、保证了即使极限场景也有确定行为。

16.2.4 max_concurrent_streams:允许多少同时在跑

hyper 默认:

max_concurrent_streams: Some(200),

最多 200 个 stream 同时打开。超过这个数,对端发的新 HEADERS frame 会被 h2 拒绝(RST_STREAM with REFUSED_STREAM,h2-0.3.27/src/proto/streams/recv.rs:144-147、921)。

200 是一个中等激进的默认值。HTTP/2 spec 建议不小于 100(RFC 9113 §6.5.2 对 SETTINGS_MAX_CONCURRENT_STREAMS 的建议)。Nginx 的 http2_max_concurrent_streams 默认 128。

为什么不设更高?每个 in-flight stream 消耗内存——至少一个 header map + 两个 flow-control window 状态 + user service 的 future(视业务而定)。并发 stream 上限乘以连接数、就是这部分内存的上界——把它设成 1024 意味着单连接这部分开销的上界也放大到默认值的五倍。

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

16.2.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(13 字节:9 字节帧头 + 4 字节错误码)。

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 的防御落在 Recv::recv_reset(h2-0.3.27/src/proto/streams/recv.rs:783-808):收到 RST_STREAM 时、如果这条 stream 还在 accept 队列里没被应用取走(stream.is_pending_accept)、它就不再计入 "concurrent streams"、但仍然占着内存——所以 h2 给这类 "pending-accept 且已被对端 reset" 的 stream 单独计数(Counts::num_remote_reset_streams);计数达到上限时、直接返回 Error::library_go_away_data(Reason::ENHANCE_YOUR_CALM, "too_many_resets")——整条连接 GOAWAY。注释里引用的是 hyper issue #2877——这个限制在 h2 0.3.17(2023-04-13、CVE-2023-26964)就已经加入、比 CVE-2023-44487 的披露早半年。

max_pending_accept_reset_streams 参数就是这个封顶值——h2 自己的默认是 DEFAULT_REMOTE_RESET_STREAM_MAX = 20(h2-0.3.27/src/proto/mod.rs:34);hyper 侧的 Option<usize> 默认 None 意思是 "不覆盖 h2 的默认 20"、而不是不限。生产里如果确有大量合法取消、可以适当调大;反之没有必要为了"防御"再往小调。

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

打开 h2-0.3.27/src/proto/streams/flow_control.rs:29-49、FlowControl 核心数据结构只有两个字段——但它们的语义差异是整个 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——对端知道的窗口。以接收方向为例:它等于我们通告过的量(初始窗口加上已经发出的 WINDOW_UPDATE)减去对端已经发来的字节,代表"对端以为我还能接多少"。

available——我本地认为的窗口。应用取走数据后、h2 先把 available 增加、然后再决定什么时候发 WINDOW_UPDATE 告诉对端。(发送方向上 available 的含义不同:它是 prioritize 已经分给这条 stream、可以用来发数据的配额。)

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

为什么要两个字段而不是一个?——因为发 WINDOW_UPDATE 也是有成本的(每个 13 字节:9 字节帧头 + 4 字节增量)。如果每次消费 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 帧。注意分母是 window_size——对端眼里还剩下的窗口,不是初始窗口:剩余窗口越小,门槛越低;窗口被用光(window_size 为 0)时门槛就是 0,释放多少立刻通告多少(§16.3.8、§16.4.3 有实测)。

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

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

h2 源码注释(h2-0.3.27/src/proto/streams/flow_control.rs:6-17)说得很朴素:Many implementations do this by keeping a "ratio"——注释只说很多实现都靠一个比例来聚合,1/2 是 h2 选定的值("a 50% ratio is simply represented as 1/2")。这个常量写死在 h2 源码里、没有暴露给用户。

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

Window 是 i32 而不是 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 独立的窗口。

发送数据时两个窗口都得够用:一个 DATA 帧的长度不能超过任何一个窗口的剩余量(RFC 9113 §6.9.1)。任何一个为 0,数据就发不出去。

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

第 16.1 节开头列出的 6 个常量集中在 hyper-1.9.0/src/proto/h2/server.rs:36-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 表达——而不是 0x100000 或 1048576。可读性优先——maintainers 一眼看出"这是 1 MB"、不用心算 0x100000 = 2^20。代码审查时减少 "这个数对不对" 的认知负担。

② 用 u32 而不是 usize——因为它们对应的都是 HTTP/2 帧里的 32 位字段:stream 窗口、帧大小、header 列表上限是 SETTINGS 参数;连接窗口不在 SETTINGS 里(只能靠 WINDOW_UPDATE 调),但窗口本身同样是 31 位的量。usize 会在 32-bit 平台 / 64-bit 平台有不同宽度、塞进 frame 时可能静默溢出。协议字段类型就是协议字段类型、不借用 Rust 的 "自然整数" 习惯。

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

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

⑤ 名字带 DEFAULT_ 前缀——和 Builder::setter 接受的"用户自定义值"形成明显区分。后续代码里看到 DEFAULT_CONN_WINDOW 就知道这是 fallback、看到 config.initial_conn_window_size 就知道是最终生效的配置(可能是用户 override)。

16.3.2 为什么要两级

假设只有 Connection 级:

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

假设只有 Stream 级:

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

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

16.3.3 FlowControl::inc_window 的 overflow 双重检查

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

// h2-0.3.27/src/proto/streams/flow_control.rs:113
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 溢出——overflowing_add 返回 (result, did_overflow) 元组。从线路上来的 sz 不会超过 2^31 - 1:解析 WINDOW_UPDATE 时 h2 已经把最高的保留位清掉(h2-0.3.27/src/frame/window_update.rs:38),所以 sz as i32 不会变成负数。

② val > MAX_WINDOW_SIZE as i32 对照协议上限——HTTP/2 规定窗口不能超过 2^31 - 1(RFC 9113 §6.9.1)。可 h2 的 MAX_WINDOW_SIZE 就是 (1 << 31) - 1,源码注释写明 i32::MAX as u32(h2-0.3.27/src/proto/mod.rs:33);转回 i32 正好是 i32::MAX,一个 i32 不可能比它大——这个分支永远走不到。在这里"超过协议上限"和"i32 加法溢出"是同一件事,第①处已经拦下;第②处相当于把协议条款直接写成了代码。

超限时怎么收场,RFC 规定按作用域区分:stream 窗口超限发 RST_STREAM、连接窗口超限发 GOAWAY,错误码都是 FLOW_CONTROL_ERROR。inc_window 只负责返回 Reason::FLOW_CONTROL_ERROR,由调用方分流:连接级转成 GOAWAY(h2-0.3.27/src/proto/streams/streams.rs:638-639),stream 级由 Send::recv_stream_window_update 发 RST_STREAM(h2-0.3.27/src/proto/streams/send.rs:377-389)。函数里唯一的 tracing::trace! 在成功路径上、错误分支本身不打日志。错误码统一是 RFC 的要求,不是 h2 自己的取舍。

16.3.4 初始窗口

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

initial_conn_window_size: DEFAULT_CONN_WINDOW,      // 1024 * 1024 = 1 MB
initial_stream_window_size: DEFAULT_STREAM_WINDOW,  // 1024 * 1024 = 1 MB

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

源码注释(hyper-1.9.0/src/proto/h2/server.rs:30-32)给了答案:

// 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(65535 × 8 / 0.05 s ≈ 10.5 Mbps,一直 stop-and-wait),约 90% 带宽浪费。

1 MB 是个"大多数场景能填满 pipe、内存不过分"的折中。代价是内存上界:一条连接上"已经收下、应用还没取走"的 body 字节最多一个连接窗口(1 MB)——stream 窗口再多也不会叠加上去,§16.5 会把这笔账算清楚。

16.3.5 send_data 的 assert! 断言:由 caller 保证窗口有容量

FlowControl::send_data 的关键断言(line 168-187,// ... 处省略了 trace 日志):

// h2-0.3.27/src/proto/streams/flow_control.rs:168
/// 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 send size is zero it's meaningless to update flow control window
    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?——因为调用方是 h2 自己:prioritize 在 pop_frame 里先把这一帧的长度截到 max frame size 和 stream 容量以内、再检查对端知道的 stream 窗口,然后才调 send_data(h2-0.3.27/src/proto/streams/prioritize.rs:760-797)——调 send_data 时超容量是内部 bug、不是外部意外。bug 用 assert panic 是 Rust 的惯例(§14.6.1 讲过同样思想)。

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

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

16.3.6 为什么 h2 的 flow control 要分 claim_capacity 和 assign_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:从 available 里拿走 N 字节配额——比如 prioritize 把连接级配额分给某条 stream 时,先在连接的 flow 上 claim(h2-0.3.27/src/proto/streams/prioritize.rs:465);本地调小连接目标窗口时也走它(h2-0.3.27/src/proto/streams/recv.rs:475)。
  • assign_capacity:往 available 里放入 N 字节配额——接收方向是应用释放了已读数据(h2-0.3.27/src/proto/streams/recv.rs:373、403),发送方向是连接收到 WINDOW_UPDATE(h2-0.3.27/src/proto/streams/prioritize.rs:383)或 prioritize 把配额分给 stream(h2-0.3.27/src/proto/streams/stream.rs:281),初始化时也用它。

动词区分"拿走 vs 放入"的视角——claim 是 taking、assign 是 giving。这种明确的动词对让代码读起来语义自然——不需要看实现就能从方法名推断作用。

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

16.3.7 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 后数据被真正取走),hyper 立刻对这块数据调用 release_capacity(hyper-1.9.0/src/body/incoming.rs:245),h2 再按 unclaimed_capacity 的门槛决定何时发出 WINDOW_UPDATE。

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

16.3.8 实测:流控信号的传导

想象一个场景:

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

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

有流控:

  1. Server 初始窗口 1 MB。
  2. Client 发 1 MB 后流控打到 0,不再发。
  3. Server 业务代码取走一个 16 KB 的 DATA 帧(body.poll_frame 交出数据,hyper 随即 release_capacity)。
  4. 窗口已经用光、对端眼里剩 0,unclaimed_capacity 的门槛也是 0——h2 立刻发 WINDOW_UPDATE(16384)。
  5. Client 收到,再发 16 KB。
  6. 循环。

实测(hyper 1.9.0 默认配置作服务端、h2 0.4.7 客户端上传 3 MB、服务端每取一帧 sleep 10 ms):服务端发出的 stream 级 WINDOW_UPDATE 共 128 个,其中 127 个的增量正好是 16384——窗口耗尽之后,读端每取走一帧就通告一帧。

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

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

h2 有两个看似对称的 decrement 方法(line 139-166,省略 trace 日志):

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(())
}

两者都和 "INITIAL_WINDOW_SIZE 被调小" 有关、但触发时机不同——看各自的 doc comment:dec_send_window 在收到对端 SETTINGS(对端把它的 initial window 调小)之后调用;dec_recv_window 在收到对端对我们 SETTINGS 的 ACK(我们把自己的 initial window 调小、对端确认了)之后调用。dec_recv_window 还多改一个 available——为什么不对称?

原因在于谁拥有信息:

send 方向:dec_send_window 在 本地发送方调用——对端通过 SETTINGS 告诉我们"我接收的窗口变小了"。我们要调整 window_size(对端认为的窗口)来反映这个变化。available 不在这里一并扣——发送方向的 available 是从连接窗口分给这条 stream 的配额,由调用方 apply_remote_settings 单独处理:若 available 超出了新的 window_size,就用 claim_capacity 把多出的部分收回、再还给连接供别的 stream 使用(h2-0.3.27/src/proto/streams/send.rs:477-501、519)。

recv 方向:dec_recv_window 在 本地接收方调用——我们通过 SETTINGS 告诉对端"我接收的窗口变小了"、对端 ACK 之后这个新窗口正式生效。同时我们自己的 available(代表 "对端还能塞给我多少字节")也要相应减少——因为 receive 侧的窗口已经不是之前那么大。

两行 vs 一行的差异来自两个方向上 available 的含义不同:接收侧的 available 就是本地窗口本身,必须和 window_size 同步收紧;发送侧的 available 是连接配额的分账,收紧多少要看它和新窗口的差,所以留给调用方按 stream 逐个结算。

h2 把这种非对称性落成两个名字不同的函数,而不是一个带方向参数的函数——读代码的人一眼就知道两边不能照搬。

16.3.10 adaptive_window:动态调整

adaptive_window: bool,  // 默认 false

设为 true 时,hyper 根据 RTT 和收到的字节量自动调整窗口大小。机制写在 hyper-1.9.0/src/proto/h2/ping.rs 开头的模块注释和 Bdp::calculate(hyper-1.9.0/src/proto/h2/ping.rs:366-408)里:

  1. 收到 DATA 帧时、如果没有在途的 BDP ping、记下时间并发一个 PING;期间累计收到的字节数。
  2. PING ACK 回来时、把这次 RTT 以 1/8 权重并入滑动平均、算出带宽 bytes / (rtt × 1.5);带宽没有超过历史最大值就不动。
  3. 如果这次的 bytes 已经达到当前 BDP 估计的 2/3、就把 BDP 设成 bytes × 2(上限 BDP_LIMIT = 16 MB)、并把新值推给对端:连接窗口经 set_target_window_size 用 WINDOW_UPDATE 放大,stream 初始窗口经 set_initial_window_size 用 SETTINGS 调整(hyper-1.9.0/src/proto/h2/server.rs:345-346)。

注意 Builder::adaptive_window(true) 会把 initial window 重置回 spec 默认的 65535(hyper-1.9.0/src/server/conn/http2.rs:178-192;文档注释 :180-182 明说开启它会覆盖 initial_stream_window_size 与 initial_connection_window_size 设下的值,函数体随即把两者都写成 SPEC_WINDOW_SIZE)、让窗口从小往大探。反过来,在它之后再调 initial_stream_window_size / initial_connection_window_size,又会把 adaptive_window 关掉(同文件 :157-175)——两者谁后设谁生效。

hyper 默认关闭。生产代码如果内部服务可信、链路 BDP 差异大、可以开。

16.3.11 PartialOrd<usize> 的手写实现

Window 之间的比较是 derive 出来的(line 198 的 #[derive(..., PartialOrd)]),但它和 usize 的比较(line 249-257)是手写的:

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<usize> for Window、比较语法就变回自然的 window >= bytes_to_send(h2 只实现了这个方向,bytes_to_send <= window 这种写法编译不过)。

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

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

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 规定的初始值,也是允许的最小值),spec 允许最大 2^24 - 1(约 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 默认)

这不是协议字段、而是直接透传给 h2::server::Builder::max_send_buffer_size 的实现参数——"每个 stream 的发送缓冲最多多少字节"(h2-0.3.27/src/server.rs:959-971 的 doc:一旦某条 stream 缓冲到了上限、它的 flow control 就不再向上层 "poll" 更多 capacity、直到缓冲的字节被写到连接上)。发送方从 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.6 提到 unclaimed_capacity 用 1/2 阈值聚合 WINDOW_UPDATE 的发送时机。看完整实现(line 93-108):

// h2-0.3.27/src/proto/streams/flow_control.rs:93
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 }——如果增量还没达到阈值(对端眼里剩余窗口 window_size 的 1/2)、不发 UPDATE、等下次。

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

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

有了阈值:对端手里还有窗口时,已释放的增量要攒到剩余窗口的一半才通告。读端跟得上时,设窗口为 W、攒了 x 字节,条件是 x ≥ (W − x) / 2,即 x ≥ W / 3——大约每消费三分之一个窗口发一次。实测(hyper 默认 1 MB 窗口、服务端来一帧取一帧、客户端上传 4 MB):5 次运行各发出 10~11 个 stream 级 WINDOW_UPDATE,增量都在 36 万~57 万字节之间,每个 13 字节——overhead 接近 0。

窗口一旦被对端用光,门槛跟着降到 0,读端每释放一块就通告一块(§16.3.8 的实测里每帧一个 UPDATE)。这时 UPDATE 的频率由读端节奏决定,但每个 UPDATE 至少对应一整块被取走的数据,不会退化成"每读 1 字节发一次"。

这个阈值不是 HTTP/2 spec 要求的——spec 没规定聚合策略、只规定窗口不能被加到 2^31 - 1 以上。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 × (header map + stream state)
  ≈ 201 MB + 每 stream 的固定开销

这笔账把两级流控当成了相加,实际是层层封顶。每个 DATA 帧先扣连接窗口、再扣 stream 窗口(h2-0.3.27/src/proto/streams/recv.rs:604-616),而连接窗口要等应用把数据取走才会补回——hyper 在 poll_frame 交出数据时才调用 release_capacity(hyper-1.9.0/src/body/incoming.rs:245)。所以一条连接上"已经收下、应用还没取走"的 body 字节,最多就是一个连接窗口,与 stream 数无关;对端若无视连接窗口硬发,consume_connection_window 直接以 FLOW_CONTROL_ERROR 发 GOAWAY(h2-0.3.27/src/proto/streams/recv.rs:688-699)。

实测(hyper 1.9.0 服务端持有 body 但一个字节都不读,h2 0.4.7 客户端开 N 条 stream、每条想发 1 MB,2 秒后统计服务端从 TCP 读到的字节数):

连接窗口 N=1 N=10 N=40 N=150
1 MB(默认) ≈1.0 MB ≈1.0 MB ≈1.0 MB ≈1.0 MB
4 MB ≈1.0 MB ≈4.0 MB ≈4.0 MB ≈4.0 MB

N=1 时被 1 MB 的 stream 窗口卡住,N 再大也越不过连接窗口。stream 数真正放大的是每 stream 的固定开销——header map、stream 状态、service future,这部分的上界才按 每 stream 开销 × max_concurrent_streams × 连接数 相乘。

还要记住,这个上界只管到"h2 里还没交给应用"为止:数据一旦被 poll_frame 取走,窗口就已归还,应用把它攒进 Vec 还是写进磁盘,流控都管不到(第 10 章的 Limited 才是给应用侧封顶的工具)。

实操调参的几条经验:

  • 流量小(<1000 QPS):默认(连接窗口 1 MB、stream 窗口 1 MB、200 并发)就用,不担心。
  • 公网入口(可能被攻击):调低 max_concurrent_streams(比如 50)压的是每 stream 固定开销;想压缓冲的 body 字节,调的是 initial_connection_window_size;initial_stream_window_size 调小(比如 64 KB)只是限制单条 stream 能占多少,防止一条慢 stream 独吞连接窗口。
  • 内网高吞吐:要么 adaptive_window(true) 让 hyper 自己探到 BDP(上限 16 MB)、要么手动给大 window(4-16 MB)——两者互斥、见 §16.3.10。

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 只有几十字节。
  4. server 端收到 HEADERS 后需要:
    • 解析 HPACK 分配 header map。
    • 触发 service.call(req) —— 可能已经调 call 了 —— 分配了各种资源。
    • 然后收到 RST_STREAM —— 取消 service future、回收资源。
  5. 攻击者的发送 cost 小(几十 bytes/op),但 server 的处理 cost 大(分配 + service future + 取消)。

此前很多 HTTP/2 实现不对这个组合做限流——攻击者可以在一条 TCP 连接上高频重复 HEADERS+RST_STREAM 循环,把 server CPU 打满。

CVE-2023-44487 攻击被 Google、Amazon、Cloudflare 同时观察到——按 Google 2023-10-10 的披露、峰值达到 3.98 亿 requests/second、远超其之前记录。

16.6.2 hyper 的防御

hyper/h2 在这次 CVE 上的时间线和别家不同:针对 "HEADERS 后立刻 RST" 的 pending-accept 限制、h2 早在 0.3.17(2023-04-13、CVE-2023-26964、hyper issue #2877)就加了;CVE-2023-44487 披露时 h2 已在防御范围内。后续 h2 0.3.24(2024-01-17)又加了 "Limit error resets for misbehaving connections"、hyper 1.2.0(2024-02-21)把两个开关都接到 server Builder 上(#3507、#3530)。hyper 1.9.0 proto/h2/server.rs 的 Config 里就是这两个字段:

max_pending_accept_reset_streams: Option<usize>,  // 默认 None(沿用 h2 默认 20)
max_local_error_reset_streams: Option<usize>,     // 默认 Some(1024)
  • max_pending_accept_reset_streams:客户端可以 RST 而我们还没开始 accept 的 stream 最大数量。超过时 h2 发 GOAWAY(ENHANCE_YOUR_CALM)关连接。
  • 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 —— 意思是 hyper 不调用 h2 的 setter、沿用 h2 的默认 20(§16.2.5)。Builder 的签名是 max_pending_accept_reset_streams(&mut self, max: impl Into<Option<usize>>)、所以既可以传 50 也可以传 Some(50)——"允许多少个未 accept 的正常取消,超过就判为攻击"。

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

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

协议层没有为此改规范——防御留给实现:对 "stream 开启 / 取消" 的比例或绝对数做限流。hyper/h2 实现了 pending accept 和 local error 两个 counter,是这类防御的实现。

这是一个经典的 "协议、库、运维" 三层共同承担 DoS 的案例——协议修复要时间,库能发防御补丁,运维要确认防御参数生效。如果你的服务暴露在公网,必须确认这两道防线都在:hyper 默认两者都开着(tonic 的 Server 默认却不设第二道上限,见第 22 章 §22.4.1),但 max_local_error_reset_streams(None) 会把第二道关掉——h2 拿到 None 就不设上限(h2-0.3.27/src/proto/streams/counts.rs:82-88)。

16.6.4 max_local_error_reset_streams 和 Reason::FLOW_CONTROL_ERROR 的绑定

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

Client → HEADERS (stream 1)
Client → DATA (stream 1, 16 KB) ← 该 stream 剩余窗口只有 1 KB
Server (h2) → RST_STREAM (stream 1, FLOW_CONTROL_ERROR)

每次这种违规、h2 都要:

  1. 检测违规(Recv::recv_data 发现 stream.recv_flow.window_size() < sz、返回 Error::library_reset(stream.id, Reason::FLOW_CONTROL_ERROR)——h2-0.3.27/src/proto/streams/recv.rs:607-616;注意如果违反的是连接级窗口、consume_connection_window 直接返回 library_go_away、整条连接关掉、不走 reset 计数)
  2. 在 Actions::reset_on_recv_stream_err 里记录 "这个 stream 要 reset"(h2-0.3.27/src/proto/streams/streams.rs:1582-1615)
  3. 下次 poll 时把 RST_STREAM 发出去
  4. 把 stream 放进 reset 过期队列(enqueue_reset_expiration),到期后再清理状态

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

max_local_error_reset_streams = 1024 让 h2 计数(Counts::can_inc_num_local_error_resets、h2-0.3.27/src/proto/streams/counts.rs:82-88)——本连接内已经因为本地错误 reset 了多少 stream。超过后 h2 不再 reset、而是直接发 GOAWAY(ENHANCE_YOUR_CALM)关连接——"你这客户端有问题、不陪你玩了"。

这和 §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

第 12 章提过 HTTP/1 的 pipelining:RFC 允许,但实际很少开启。这里对照一下为什么 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.8 每个 stream 的 FlowControl 独立实例

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

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

加上连接级的两个 FlowControl——发送侧在 Prioritize.flow(h2-0.3.27/src/proto/streams/prioritize.rs:49)、接收侧在 Recv.flow(h2-0.3.27/src/proto/streams/recv.rs:19)——一条 HTTP/2 连接同时存在:

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

总共 2 + 2N 个 FlowControl 实例、每个就是两个 i32——这部分开销可以忽略不计。

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

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

每个维度都有各自的初始值、各自的 WINDOW_UPDATE、各自的消耗轨迹(stream 级初始值由 SETTINGS_INITIAL_WINDOW_SIZE 决定,连接级只能靠 WINDOW_UPDATE 调整)——必须独立追踪。合并任意两个都会导致语义错误。

这种 "多维度状态分别承载" 在 h2 里是贯彻到底的——第一眼看到感觉代码重、但每个维度都在做自己的计数、不会相互污染。要说明的是,四者是同一个 FlowControl 类型,编译器并不会阻止你把 stream_send 的 available 加到 conn_recv 上——把它们分开的是结构上的归属:send_flow / recv_flow 挂在 Stream 上,连接级的两个分别归 Prioritize 和 Recv 所有。

16.9 与 Tokio Semaphore 的对照

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

特征 Semaphore HTTP/2 Window
单位 许可 字节
获取 acquire() reserve_capacity(n) + poll_capacity()
释放 drop 掉 permit 对端发 WINDOW_UPDATE 归还
范围 进程内 跨网络
分级 无 Connection + Stream

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

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

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

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

16.10 本章与其他章节的呼应

与第 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, 40, 48, ...])都是 "粗粒度阶梯 + 容忍一定浪费换掉大量小操作" 的工程权衡——一个在网络帧、一个在 GPU 启动——问题空间不同、优化直觉完全一样。

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

16.11 生产推荐配置

基于上面的讨论,给一个生产级 hyper 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())
    
    // 流控 - 内网服务可调大到 4 MB
    // (注意:adaptive_window(true) 会把下面两个 initial 值重置回 65535、二选一)
    .initial_connection_window_size(2 * 1024 * 1024)  // 2 MB
    .initial_stream_window_size(1 * 1024 * 1024)       // 1 MB
    
    // 并发 - 公网低,内网高
    .max_concurrent_streams(200)
    
    // CVE-2023-44487 相关:h2 默认 20,这里为合法取消留余量放宽到 50(§16.2.5)
    .max_pending_accept_reset_streams(50)
    .max_local_error_reset_streams(1024)
    
    // 保活(第 17 章详讲)
    .keep_alive_interval(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)     // 限制单条 stream 能占的缓冲(总量仍由连接窗口封顶)

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

16.12 FlowControl 的三条不变式

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

① 双字段偏差有上界——以 stream 的接收方向为例,available - window_size 就是"已释放、未通告"的字节,最多等于一个窗口(整窗数据都被取走但还没发 UPDATE)、不会更多:对端最多只能发一个窗口的数据,release_capacity 又不许释放超过在途数据的量(h2-0.3.27/src/proto/streams/recv.rs:392-394,否则返回 ReleaseCapacityTooBig)。连接窗口是例外——set_target_window_size 调大目标时会把差额一次性记进 available(h2-0.3.27/src/proto/streams/recv.rs:472-473),hyper 握手后那个 983041 字节(1 MB − 65535)的连接级 WINDOW_UPDATE 就是这么来的。

② window_size 可负、available 可负——都是 i32。负数代表 "我/对端已经超发,必须补发 UPDATE 才能恢复正常发送" 的暂态。但两个字段的加减运算都走 checked_sub / checked_add(inc_window 用 overflowing_add)——溢出返回 FLOW_CONTROL_ERROR,由调用方决定 reset 该 stream 还是 GOAWAY 整条连接;也有不少调用点只拿 debug_assert! 检查一下结果。

③ WINDOW_UPDATE 的判定是幂等的——unclaimed_capacity 是 &self 的纯查询,多次调用返回同一个值,只有 assign_capacity、inc_window、send_data 这类修改字段的方法调用后才会变。发 UPDATE 的决策(由上层做)也是由这个 pure 查询驱动——这让判定逻辑很容易单独拿出来做单元测试。

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

不变式显式化的另一个好处是模糊测试(fuzzing)友好——写一个 fuzzer、随机调 FlowControl 的所有方法、每次调用后检查三条不变式是否成立——就能大范围覆盖状态空间。h2 仓库的 fuzz/fuzz_targets/ 里有 fuzz_client、fuzz_e2e、fuzz_hpack 三个 target、是在连接与 HPACK 层面做的 fuzz,FlowControl 本身没有单独的 fuzz target。不变式是代码的 contract、是 fuzz 的 oracle——两者相辅相成。

16.13 落到你键盘上

  • 读 h2-0.3.27/src/proto/streams/flow_control.rs——真正的 flow control 算法在那里。它是 HTTP/2 flow control spec 的直接 Rust 实现,带 counter 和限制检测,只有 269 行。
  • 用 h2load(nghttp2 自带的 HTTP/2 压测工具)对你的服务做压力测试——观察不同 initial_window_size 下 throughput 的差别。你会看到 64 KB vs 1 MB 在高带宽、高延迟链路上差距明显。
  • 模拟 rapid reset 攻击(在受控环境里)——用 Go/Rust 写一个 client,不停发 HEADERS + RST_STREAM。h2 默认(20)下 server 很快就会以 GOAWAY 关掉这条连接(实测:用 h2 0.4.7 客户端对 hyper 1.9.0 默认配置连发 "请求后立刻丢弃" 的 stream,服务端日志报 remotely-reset pending-accept streams reached limit (20)、以 ENHANCE_YOUR_CALM 断开,四次运行里 service.call 都不到 20 次);把上限调大再对比 CPU 占用、直观感受防御效果。

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

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

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

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

③ overflowing_add 顺带守住协议上限(§16.3.3)——窗口上限 2^31 - 1 恰好是 i32::MAX,一次溢出检查就够;超限按作用域转成 RST_STREAM 或 GOAWAY。

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

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

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

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

⑧ 按剩余窗口的 1/2 聚合 UPDATE(§16.4.3)——运营经验量化成协议优化常量。

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

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

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

16.15 本章阅读推荐

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

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

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

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

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

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

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

  • hyper::server::conn::http2::Builder 的 6 个 const + 十几个 setter——定义协议级行为
  • tower::ServiceBuilder::new().timeout(..).concurrency_limit(..).rate_limit(..) ——定义中间件级行为
  • http_body_util::Limited::new(body, 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 / 超时管理如何在同一套类型系统里协作。