Hyper 与 Tower:工业级 HTTP 栈
第11章 HTTP/1 wire:parser、encoder、chunked 编码
第11章 HTTP/1 wire:parser、encoder、chunked 编码
11.1 一个 TCP 包的变形记
假设你在浏览器里敲回车访问 https://example.com/foo。你看到的是页面——但这背后实际上是一段 ASCII 字节流飞向服务器:
GET /foo HTTP/1.1\r\n
Host: example.com\r\n
Accept: text/html\r\n
User-Agent: curl/7.88.1\r\n
\r\n
这段字节到了服务器那一侧,需要被”翻译”成一个 http::Request<Incoming> 才能交给 Axum / Tonic / 你的业务代码。翻译的过程分三步:
- 解析 request line:
GET /foo HTTP/1.1→method=GET, uri=/foo, version=HTTP/1.1。 - 解析 headers:每行
Name: Value\r\n→HeaderMap。 - 准备 body decoder:根据
Content-Length或Transfer-Encoding头,构造出能读后续 body 字节的 decoder。
再反过来,处理完业务之后,Response 又要被”编码”回字节流发回客户端。这一章我们读 Hyper 的 proto::h1 模块——Rust 工业级 HTTP/1 实现的核心。
源码来自 hyper 1.9.0(commit 0d6c7d5),文件在 hyper/src/proto/h1/:
conn.rs 1531 行 连接状态机(下一章)
dispatch.rs 808 行 请求分发(下一章)
decode.rs 1254 行 body decoder(本章重点)
encode.rs 672 行 body encoder(本章重点)
role.rs 3173 行 Server/Client 双角色抽象 + header 解析调度(本章)
io.rs 967 行 buffered I/O 抽象(第 14 章)
我们这一章聚焦 decode.rs / encode.rs / role.rs 的前半部分,把字节翻译的细节读清楚。
11.2 httparse:SIMD 加持的底层解析器
很多人以为 hyper 自己写了 HTTP header parser。实际上不是——hyper 依赖 httparse crate。它是 Sean McArthur(hyper 作者)自己做的一个独立小库,专门解决”HTTP 请求/响应头部的 SIMD 加速解析”。
httparse 的接口不大:
let mut headers = [httparse::EMPTY_HEADER; 64];
let mut req = httparse::Request::new(&mut headers);
let status = req.parse(bytes)?;
match status {
Status::Complete(n) => { /* 消费 n 字节 */ }
Status::Partial => { /* 数据不足,等更多 */ }
}
给它一个字节切片,它告诉你是否能完成解析。如果能,返回解析位置(n 字节被消费);不能,返回 Partial 等下一次。
11.2.0 httparse 的公开 API 只有 10 个函数
httparse crate 作为 hyper 的底层依赖、公开 API 极其精简——大致只有:
Request::new(headers)/Request::parse(buf)/Request::parse_with_uninit_headersResponse::new(headers)/Response::parse(buf)Status::Complete(n)/Status::PartialErrorenum
这 ~10 个 symbol 就是整个 HTTP/1 parser 的对外门面——内部可能有几千行 SIMD / Unicode / edge case 代码、但用户只需要理解这一把钥匙。
极简 API 的价值——
- 学习成本低——新 maintainer 接手 hyper、几分钟就能搞懂 httparse 接口
- stability 容易保证——API 少、breaking change 少、用户升级顺
- measured 测得准——所有热路径只有这几个入口、benchmark 结果可信
这和第 9 章 bytes crate 的极简 API(主要就是 Bytes / BytesMut 两个类型)一脉相承——Rust 生态的基础库普遍 API 极简、留出巨大空间给实现变化。
对比某些”功能丰富”的 C++ HTTP library(public API 几百个函数、各种 deprecated 的历史包袱)、Rust 小库的克制让组合式架构成为可能。hyper / tower / http / bytes / httparse ——每个都是小精悍的 crate、但它们联合起来是工业级 HTTP 能力。
这种生态协作比一个大库做所有事更健康——每个 crate 自己维护者负责、演进独立、失败隔离。
11.2.1 为什么 SIMD 重要
header 解析的热点在于字符扫描——寻找 :、\r、\n、校验”每个字节是 valid token char”等。朴素地用 for byte in bytes.iter() 每次 loop 处理一个字节——成本 4-5 CPU cycles/byte。在 10Gbps 网卡的极限场景下,光 header 解析就能吃掉一个 CPU 核。
httparse 用 SIMD 指令(AVX2 / NEON)一次处理 16-32 字节——用 _mm256_cmpeq_epi8 同时和多个 sentinel 字符比较,返回一个位图;用 tzcnt 找最低有效位(最早出现的 sentinel)。单字节扫描降到 0.1-0.3 cycles/byte——15 倍的速度提升。这是 hyper 能在 1M QPS 场景下依然 CPU 富余的重要原因。
SIMD 这块不是本书重点——但你需要知道”Hyper 的 header 解析不是普通代码”,它站在一个 SIMD 小引擎之上。httparse 的 hot path 实现全部是 unsafe Rust + intrinsics,经过严格 fuzzing 测试。如果你好奇,去 GitHub 看它的 src/simd/ 目录。
11.2.2 hyper 如何使用 httparse
// hyper/src/proto/h1/role.rs: Server::parse 摘录
fn parse(buf: &mut BytesMut, ctx: ParseContext<'_>) -> ParseResult<RequestLine> {
// 分配 headers 数组(按配置,默认 100)
let mut headers_indices = [...; DEFAULT_MAX_HEADERS];
let mut headers = [httparse::EMPTY_HEADER; DEFAULT_MAX_HEADERS];
// 调用 httparse
let mut req = httparse::Request::new(&mut headers);
let res = req.parse_with_uninit_headers(buf, &mut headers_indices);
match res {
Ok(httparse::Status::Complete(len)) => {
// 拿到 method / path / version / headers
// 构造 http::Method / http::Uri / http::HeaderMap
// ... 把字节范围翻译成 http:: 类型
}
Ok(httparse::Status::Partial) => Ok(None), // 数据不足
Err(e) => Err(e.into()),
}
}
hyper 拿到 httparse 的”字节范围”之后,用 BytesMut::split_to(len) 把请求头部分从缓冲切出来——这里得到一个独立的 Bytes。然后 HeaderName、HeaderValue 都是这个 Bytes 的子切片(通过 Bytes::slice() 零拷贝)。整个过程没有一次 memcpy——header values 只是 Bytes 里的 offset + len 引用。
这再次印证了第 9 章的观点:bytes::Bytes 是整个 Rust HTTP 栈的零拷贝基础。从 TCP socket 读出字节到一个 BytesMut 缓冲,解析成 HeaderMap,传到 handler,再写回 socket——全程没有把 header 复制过一次。
11.2.2.5 parse_with_uninit_headers 和零初始化规避
看 hyper 真实调用 httparse 的代码(role.rs 里的 Server::parse):
let mut req = httparse::Request::new_with_config(headers, &parser_config);
let res = req.parse_with_uninit_headers(buf, &mut headers_indices);
注意是 parse_with_uninit_headers、不是普通的 parse。为什么要 “uninit”?——因为 [httparse::EMPTY_HEADER; 100] 这个数组的初始化本身在高频场景是有成本的——100 个 EMPTY_HEADER 结构、每个有几个字段要置零、即使 JS 下的数组填充、在 Rust 也要走 memset。
parse_with_uninit_headers 告诉 httparse “数组里的内容我不保证初始化过、你只读你写过的那些”——httparse 内部用 MaybeUninit 写入、然后返回 “parse 成功、真的被写入了 N 个”。hyper 只读前 N 个、保证不读未初始化内存。
这和 §14.5.1.5 讲的 “栈数组 vs 堆分配” 的 100 个 EMPTY_HEADER 是配套的——既然默认走栈、那连初始化这个 100 次写入都要省。每一步都不放过、才是 hyper 作为 Rust 工业库的姿态。
注意 MaybeUninit 是 Rust 类型系统里的纯 runtime 构造——编译出来没任何额外指令、只是告诉 borrow checker “这块内存可能未初始化”。如果代码读没写过的槽位、是 UB——所以 httparse 内部必须严格按照 “返回 N 后只保证前 N 个可读” 的契约。
11.2.3 DEFAULT_MAX_HEADERS
const DEFAULT_MAX_HEADERS: usize = 100;
默认最多 100 个 header。超过这个数 httparse 会返回 TooManyHeaders 错误——hyper 把它映射成 413 / 400 响应。100 是一个相对宽松的默认——Chrome 发送的请求大约 20-30 个 header,API 网关的内部请求可能多到 50-80 个。100 给了合理的冗余,同时防止攻击者塞几万个 header 让 server 分配大内存。
Hyper 的 http1::Builder::max_headers(n) 允许覆盖这个值。生产服务强烈建议降到 30 或 50——攻击者如果能让你分配更多 headers 数组,在大连接场景下会累积显著内存。
11.3 body Decoder:三种长度语义
HTTP/1.1 允许三种”body 到哪里结束”的约定:
Content-Length: 12345→ 读 12345 字节就结束。Transfer-Encoding: chunked→ 按 chunk 格式读,最后一个 0 长度 chunk 表示结束。- 都没有(仅响应合法)→ 读到连接关闭为止。
Hyper 把这三种统一成 Decoder::Kind:
// hyper/src/proto/h1/decode.rs:38-66
enum Kind {
/// A Reader used when a Content-Length header is passed with a positive integer.
Length(u64),
/// A Reader used when Transfer-Encoding is `chunked`.
Chunked {
state: ChunkedState,
chunk_len: u64,
extensions_cnt: u64,
trailers_buf: Option<BytesMut>,
trailers_cnt: usize,
h1_max_headers: Option<usize>,
h1_max_header_size: Option<usize>,
},
/// A Reader used for responses that don't indicate a length or chunked.
///
/// The bool tracks when EOF is seen on the transport.
Eof(bool),
}
三个 variant,三种协议状态,各有各的 hot path。
11.3.1 Length(n):最简单的情况
Length(n) 只需要从网络 buffer 读 n 字节就完事。源码里对应的 read 方法:
// 精简版
match self.kind {
Length(remaining) => {
if remaining == 0 {
// 已经读完
return Poll::Ready(Ok(None));
}
let buf = ready!(body.read_mem(cx, remaining as usize))?;
let read_len = buf.len();
self.kind = Length(remaining - read_len as u64);
Poll::Ready(Ok(Some(Frame::data(buf))))
}
...
}
核心逻辑:
remaining记录还需要读多少字节。- 每次
read_mem(cx, n)拉一块出来(可能一次只拉到一部分)。 - 扣减 remaining,构造
Frame::data(buf)返回。 - remaining 到 0 时,下次 poll 返回
None结束流。
注意 read_mem 的返回值是 Bytes——不是 &[u8]。这是因为 hyper 的底层 buffer 可以”切出一块零拷贝的 Bytes”传给上层。Body decoder 读到的数据直接包装成 Frame 交出去,不复制字节。
11.3.1.5 Length(0) 特殊处理:零长度 body 的 fast-path
source 的 Length 分支里还有一个边界——Length(0)。这意味着 Content-Length: 0——合法的空 body。hyper 对此有 fast-path:
Length(0) => {
// 立即返回 None、不再去 read socket
return Poll::Ready(Ok(None));
}
看起来 trivial、实际上意义重大:
- GET / HEAD / DELETE 请求通常无 body——Content-Length 要么不存在要么是 0
- HTTP/1.1 spec 允许
Content-Length: 0显式声明 “我就是没 body” - 某些 204 / 304 响应必须显式 Content-Length: 0(否则对端不知道是 no-body 还是 close-delimited)
如果没有 fast-path、decoder 对 Length(0) 也会尝试 read_mem(0)——虽然最终返回空、但走了 tokio 的 waker 调度。每秒几万个空 body 请求、光这些无效的调度就是可观开销。
Length(0) → None 一行代码、把最常见的 HTTP 请求模式变成零 syscall。这和 §6.4.4 讲的 React subtreeFlags & MutationMask 位检查跳过无 effect 子树是同样的工程直觉——最常见的 trivial case 必须有最短路径。
11.3.2 Chunked:13 状态的状态机
chunked encoding 的 wire format 是这样的:
4\r\n <-- chunk size in hex
data\r\n <-- chunk data (4 bytes)
5\r\n
hello\r\n
0\r\n <-- final chunk (size 0)
X-Trail: foo\r\n <-- optional trailer headers
\r\n <-- end of stream
解析这个 format 不是一行一行对——它是 byte-by-byte 的状态机。看 Hyper 的 ChunkedState enum:
// hyper/src/proto/h1/decode.rs:69-83
enum ChunkedState {
Start,
Size,
SizeLws,
Extension,
SizeLf,
Body,
BodyCr,
BodyLf,
Trailer,
TrailerLf,
EndCr,
EndLf,
End,
}
13 种状态。每个字节都可能触发一次状态转移。
每个状态对应一个 read_xxx 函数。比如 read_size:
// hyper/src/proto/h1/decode.rs: read_size 简化
fn read_size<R: MemRead>(cx: &mut Context<'_>, rdr: &mut R, size: &mut u64)
-> Poll<Result<ChunkedState, io::Error>>
{
let radix = 16;
match byte!(rdr, cx) {
b @ b'0'..=b'9' => {
*size = or_overflow!(size.checked_mul(radix));
*size = or_overflow!(size.checked_add((b - b'0') as u64));
}
b @ b'a'..=b'f' => {
*size = or_overflow!(size.checked_mul(radix));
*size = or_overflow!(size.checked_add((b + 10 - b'a') as u64));
}
b @ b'A'..=b'F' => { ... }
b'\t' | b' ' => return Poll::Ready(Ok(ChunkedState::SizeLws)),
b';' => return Poll::Ready(Ok(ChunkedState::Extension)),
b'\r' => return Poll::Ready(Ok(ChunkedState::SizeLf)),
_ => invalid,
}
Poll::Ready(Ok(ChunkedState::Size))
}
每个 byte! 宏从 reader 里拿一个字节——可能返回 Pending(数据不够)。如果拿到了:
- hex 数字:累积到
size上(checked_mul/checked_add防溢出)。 - 空白:切到 SizeLws 状态。
;:切到 Extension(chunk 可以有扩展,一般忽略)。\r:切到 SizeLf。- 其他:非法,返回错误。
每次调用 return 的是**“下一个状态”**——调用者的主循环会重新进入 step,根据新状态调不同的 handler。
这是一个非常 classical 的 async state machine:整个 parser 的 state 被编码在一个 enum,driver 通过 Poll::Pending/Poll::Ready 与外界交互。每次 poll 尽量推进状态,数据不够就挂起等 waker。
11.3.2.5 read_extension 对 LF-only 的显式拒绝
read_extension 的源码(decode.rs:430-460)里有一段微妙的分支:
fn read_extension<R: MemRead>(...) -> Poll<Result<ChunkedState, io::Error>> {
// We don't care about extensions really at all. Just ignore them.
// They "end" at the next CRLF.
//
// However, some implementations may not check for the CR, so to save
// them from themselves, we reject extensions containing plain LF as
// well.
match byte!(rdr, cx) {
b'\r' => Poll::Ready(Ok(ChunkedState::SizeLf)),
b'\n' => Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid chunk extension contains newline",
))),
_ => {
*extensions_cnt += 1;
if *extensions_cnt >= CHUNKED_EXTENSIONS_LIMIT {
Poll::Ready(Err(...))
} else {
Poll::Ready(Ok(ChunkedState::Extension))
}
}
}
}
RFC 9112 只规定 extension 必须以 \r\n 结尾——理论上 extension 里可以包含任意字节(除了 \r 和 \n)。hyper 的注释承认:“We don’t care about extensions really at all.”——它就 skip 这些字节。
但 b'\n' 被单独拒绝——返回 InvalidData error。为什么?注释说得很清楚:
some implementations may not check for the CR, so to save them from themselves, we reject extensions containing plain LF as well.
某些实现不严格检查 CR(只看 LF)——它们会以为一个 含 LF 的 extension 已经结束了。如果 hyper 容忍 LF-in-extension、发出的请求到这类 buggy server 会被错误解析、甚至触发 HTTP request smuggling 攻击。
hyper 主动比 spec 更严格——“为了救这些实现”。这是典型的 “对端可能有 bug、我不给他们出错的机会” 的防御性协议实现。
这种 “主动加 constraint 防止下游误解” 在网络协议实现里非常有价值——每一次 “RFC 没说不能、但我们拒绝” 都是针对一个真实世界的兼容问题或安全漏洞。
11.3.2.6 read_body 里的 rem > usize::MAX as u64 保护
read_body(line 483-516)里有一段看似不可能触发的检查:
// cap remaining bytes at the max capacity of usize
let rem_cap = match *rem {
r if r > usize::MAX as u64 => usize::MAX,
r => r as usize,
};
rem 是 u64(chunk size 可以到 16 exabytes)、usize 在 32-bit 平台只有 4GB。如果一个 chunk 声明自己是 10GB、在 32-bit 目标上 rem as usize 就会静默截断——读到错误的字节数。
rem > usize::MAX as u64 的检查把 rem cap 到 usize::MAX——如果真的有这么大的 chunk、hyper 会分多次 read 慢慢啃。当然 32-bit 服务器处理 GB 级 chunk 的场景很罕见——但 hyper 作为通用库不能假设目标架构。
这种 “跨平台整数宽度” 的细节是系统编程的常见坑——不只 hyper、Linux kernel 的 ioctl、Rust 的 std::io::Read 都要小心。u64 → usize 的转换永远要加 guard。
hyper 在 64-bit 目标(99% 的生产服务器)上、usize::MAX == u64::MAX、这个分支永远不会命中——代价是零。但 32-bit 嵌入式 / mobile / WASM 目标、这个 guard 就救了命。
11.3.3 几个工业级细节
源码里有几个宏不能错过:
const CHUNKED_EXTENSIONS_LIMIT: u64 = 1024 * 16;
const TRAILER_LIMIT: usize = 1024 * 16;
chunk extension 和 trailer 都有 16KB 的上限。没这个上限,攻击者可以构造一个请求 1; <巨大 extension>; 0... 让 server 读 GB 级的字符串。
macro_rules! or_overflow {
($e:expr) => (
match $e {
Some(val) => val,
None => return Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidData,
"invalid chunk size: overflow",
))),
}
)
}
chunk size 的累加必须用 checked_mul / checked_add——一个 20 位 hex 字符串 FFFFFFFFFFFFFFFFFFFF 在 u64 上会溢出。溢出就是非法请求,直接错误。这个 or_overflow! 宏在每一处做算术都用到了,体现了”绝不信任网络输入”的一贯态度。
macro_rules! byte (
($rdr:ident, $cx:expr) => ({
let buf = ready!($rdr.read_mem($cx, 1))?;
if !buf.is_empty() {
buf[0]
} else {
return Poll::Ready(Err(io::Error::new(io::ErrorKind::UnexpectedEof,
"unexpected EOF during chunk size line")));
}
})
);
byte! 宏不只是”拿一个字节”——它内置了”意外 EOF 的错误处理”。如果 read_mem 返回空(连接关闭但数据未完),立即产出 UnexpectedEof 错误。这类”协议层 EOF”不是”正常结束”——它是”对端在不该关的时候关了”。
11.3.3.5 read_size_lf 和 size == 0 的特殊跃迁
read_size_lf(line 461-481)在读完 size 后的 \n 时有一个关键分支:
fn read_size_lf<R: MemRead>(...) -> Poll<Result<ChunkedState, io::Error>> {
match byte!(rdr, cx) {
b'\n' => {
if size == 0 {
Poll::Ready(Ok(ChunkedState::EndCr)) // 终止块、跳转到 EndCr
} else {
Poll::Ready(Ok(ChunkedState::Body)) // 正常 chunk、读 body
}
}
_ => Poll::Ready(Err(...)),
}
}
size == 0 的特殊跃迁——零长度 chunk 是 chunked encoding 的终止信号(RFC 9112 §7.1)。它不是 “读 0 字节 body”、而是 “整个 body 流到此结束”。
如果没有这个分支、状态机会走到 Body 状态、试图 read 0 字节——返回空、再走 BodyCr/BodyLf 把紧跟的 \r\n 吞掉——最终也能正确结束、但多走 3-4 个状态。直接跳 EndCr 省掉这几步、更重要是语义正确——\r\n(在 size 的 \r\n)是 chunk header 的一部分、而第 0 chunk 后接的 \r\n\r\n 是流结束的一部分(对应 EndCr → EndLf → End)。
这是 状态机设计里 “特殊情况特殊对待” 的典型——不能强行把 0-chunk 塞进通用 chunk 流程、否则 trailer 处理会错位。size == 0 → EndCr 这一个判断把”chunk 流的 body 阶段”和”chunk 流的终止阶段”明确分开。
11.3.3.6 为什么 BodyCr 之后读 CR 是 InvalidInput、读 body 短了是 UnexpectedEof
两个相似但语义不同的错误:
fn read_body_cr<R: MemRead>(...) -> Poll<...> {
match byte!(rdr, cx) {
b'\r' => Poll::Ready(Ok(ChunkedState::BodyLf)),
_ => Poll::Ready(Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Invalid chunk body CR", // ← InvalidInput
))),
}
}
fn read_body<R: MemRead>(...) -> Poll<...> {
if count == 0 {
*rem = 0;
return Poll::Ready(Err(io::Error::new(
io::ErrorKind::UnexpectedEof, // ← UnexpectedEof
IncompleteBody,
)));
}
// ...
}
两种 error kind 对应两种不同的协议错误:
InvalidInput——对端发了合法字节、但不符合协议。比如 chunk body 之后该是\r、结果对端发的是x——对端协议实现有 bug 或恶意。UnexpectedEof——对端把连接关了、我们还没读完。比如 chunk 声明自己 100 字节、对端发了 50 就关连接——要么对端崩了、要么中间网络有问题。
两种错误上层处理不同:
- InvalidInput → 发 400 Bad Request、关连接
- UnexpectedEof → 不能发响应(socket 已关)、只能 log + 清理
hyper 用 io::ErrorKind 区分 就让这两种场景在 io::Error 这一层就分流——调用方根据 kind 做不同反应。这种**“把语义信息编进错误类型**“的做法比 “字符串匹配 error message” 健壮得多——即使 message 文案变了、kind 保持一致。
11.3.3.7 CHUNKED_EXTENSIONS_LIMIT vs TRAILER_LIMIT 两个 16KB 常量的来源
decode.rs 的开头有两个 16KB 的常量:
const CHUNKED_EXTENSIONS_LIMIT: u64 = 1024 * 16;
const TRAILER_LIMIT: usize = 1024 * 16;
两个数字相同、但类型不同(一个 u64、一个 usize)、语义也不同——
CHUNKED_EXTENSIONS_LIMIT是 单个 chunk 的 extension 字节数总和上限——防止攻击者塞几 MB 的 extension 让 server “读个字节扔个字节”消耗 CPU。TRAILER_LIMIT是 trailer 总字节数上限——防止 body 发完后塞大量 trailer header 让 server OOM。
为什么都是 16KB 而不是别的数字?——因为:
① 16KB 对应一个 TCP recv window 常见上限——很多 server 的 TCP recv buffer 默认就是 16KB、超过它意味着 “你已经触发了多次 kernel 调度”——不合理的场景。
② 16KB 也是 HTTP/2 的 MAX_FRAME_SIZE 默认值(§16.4.1)——协议栈各层 threshold 保持一致、方便运维和调试。
③ 16KB 足够正常用例——typical chunk 的 extension 只有几十字节(Content-Signature 几个)、典型 trailer 只有 grpc-status + grpc-message 共 ~60 字节——16KB 是 256x 冗余空间。
为什么 CHUNKED_EXTENSIONS_LIMIT 是 u64、TRAILER_LIMIT 是 usize?——
- extension 字节数是通过 chunk size 累加的(§11.3.2 讲过 chunk size 也是 u64)、为了和 chunk_len 一致用 u64
- trailer 是保存在
BytesMut里、而 BytesMut 的 capacity 是 usize——直接用 usize 省一次 cast
这种 “类型跟着使用场景走” 是 Rust 源码里常见的 pragmatic 选择——不追求 “所有字节数都用 usize” 的统一、而是每个字段选最自然的类型。
11.3.4 Eof(bool):HTTP/1.0 的遗产
第三种 Decoder 是 Eof(bool)——读到连接关闭为止。这是 HTTP/1.0 时代的设计(没有 Transfer-Encoding、没有 Content-Length),实际上HTTP/1.1 里只允许响应用,请求不能用(因为请求不能让 client 关连接来表示结束——server 无法区分 “client 故意关” 和 “client 还在发”)。
bool 字段跟踪是否已看到 EOF。读到 EOF 之后下次 poll 返回 None。
源码注释里有一段罕见的规范引文(第 54-65 行):
/// > If a Transfer-Encoding header field is present in a response and
/// > the chunked transfer coding is not the final encoding, the
/// > message body length is determined by reading the connection until
/// > it is closed by the server. If a Transfer-Encoding header field
/// > is present in a request and the chunked transfer coding is not
/// > the final encoding, the message body length cannot be determined
/// > reliably; the server MUST respond with the 400 (Bad Request)
/// > status code and then close the connection.
这是 RFC 9112 §6.3 的直接引用——hyper 把规范条文嵌在源码注释里,让读代码的人能直接比对。这种**“源码即规范导航”**的风格是工业级 RFC 实现的标志。读卷二《MCP 协议设计与实现》时也能在 TypeScript SDK 里看到类似做法——规范实现者把 spec 条款拷在代码旁边,让代码和规范形成双向索引。
11.3.4.5 Trailer 解析也通过 httparse + 独立缓冲
Chunked kind 的结构(decode.rs:38-66)里有:
Chunked {
state: ChunkedState,
chunk_len: u64,
extensions_cnt: u64,
trailers_buf: Option<BytesMut>, // ← 独立的 trailer 缓冲
trailers_cnt: usize,
h1_max_headers: Option<usize>,
h1_max_header_size: Option<usize>,
}
trailers_buf 是一个独立的 BytesMut——只在读到终止 chunk(size=0)之后开始积累 trailer 数据。为什么要独立缓冲?因为:
① trailer 的格式和 header 相同——可以直接复用 httparse 的 header parser。但 httparse 期望输入是一整段连续字节(包含终止 \r\n\r\n),不是每读一个字节给它一个字节。所以要积累完整、再一次性 parse。
② trailer 是流式出现的——可能第一次 read 拿到一半、第二次 read 才拿到 \r\n\r\n。trailers_buf 就是”攒 header 攒到能 parse 为止”的缓冲。
③ h1_max_headers / h1_max_header_size 同时作用于 header 和 trailer——trailer 的数量和总大小也被限制。否则 trailer 可以被用作攻击——在 body 发完后塞无限 trailer 让 server OOM。
这就是为什么 chunked 的状态机有 13 个状态——不只是 chunk 本身的格式、还包括 trailer 的读取。Trailer 在 HTTP/1.1 里是 optional 的、但在 HTTP/2 和 gRPC 里是核心——grpc-status 这种元信息只能放 trailer。hyper 的 chunked decoder 必须正确处理 trailer 才能支持 gRPC over HTTP/1。
11.4 body Encoder:反向过程
// hyper/src/proto/h1/encode.rs:35-47
enum Kind {
/// An Encoder for when Transfer-Encoding includes `chunked`.
Chunked(Option<Vec<HeaderName>>),
/// An Encoder for when Content-Length is set.
Length(u64),
/// An Encoder for when neither Content-Length nor Chunked encoding is set.
#[cfg(feature = "server")]
CloseDelimited,
}
三个 variant 对称地对应 Decoder:
Chunked:出数据时包裹成 chunk 格式——<size>\r\n<data>\r\n,结束发0\r\n\r\n。Length(n):强制检查 “用户不能写超过 n 字节” ——超了 encoder 截断或报错,防止对端 parse 错误。CloseDelimited:写完 body 之后关连接。
11.4.1 Length(n) 的强制约束
// hyper/src/proto/h1/encode.rs Encoder::encode 精简
match self.kind {
Length(remaining) => {
let amt = cmp::min(remaining, msg.remaining() as u64);
self.kind = Length(remaining - amt);
BufKind::Limited(msg.take(amt as usize))
}
...
}
msg.take(amt) 这一行保证”每次最多写 amt 字节”——即使上层给了更多的数据,encoder 也只截取前 amt 字节。剩下的被丢弃。
为什么这样做?因为 HTTP/1.1 严格规定 Content-Length 声明多少字节就必须有多少字节。如果声明 100 但实际发了 150——客户端读到第 100 字节就认为请求/响应结束了,后面 50 字节会被当成下一个请求,造成”HTTP desync 攻击”。通过 encoder 层的强制截断,hyper 消除了这整个漏洞面。
11.4.1.5 msg.take(amt) 后原 msg 还剩什么
msg.take(amt) 返回 “前 amt 字节”——那么后面的字节去哪了?
msg 是 impl Buf(bytes::Buf trait)。Buf 的 take(n) 方法返回一个 “限制视图”——原 msg 不被消费、只是告诉调用方只看前 n 字节。这个视图在 encoder 的 BufKind::Limited 里被 wrap、最后交给底层 IO 做 write。
剩下的字节没有被写——用户下次再调 poll_frame 时 msg 已经完全推进(Buf 的 advance 语义)、但 encoder 层面会报错 “你声明的 Content-Length 是 N、但你实际要发 N+k 字节”——这就是 §11.4.1 讲的 “HTTP desync 防御”。
具体行为 hyper 源码里有分支:
- 如果用户实际写的 <= 声明的长度——一切正常、发完就结束
- 如果用户实际写的 > 声明的长度——encoder
take(remaining)截断、多余的字节被丢弃、并内部记录错误状态 - 如果用户实际写的 < 声明的长度且用户 signal end(
poll_frame → None)——encoder 可能发 0 字节 dummy 或直接关连接、让 client 知道这是个 broken response
第三种情况最 subtle——用户声明 Content-Length: 100、但只给了 50 字节就说 “我完了”。这是应用 bug、不是协议 bug。hyper 不能帮用户补 50 个空字节(那会污染语义)、只能通知 client “这个 response 不完整”——最直接的办法是关连接、让 client 的 Content-Length parser 失败(读到 100 字节前就 EOF)。
Go 的 net/http 处理这种情况会自动切到 chunked encoding——透明地补救用户 bug。hyper 不这样做——“用户声明了什么就按什么发”、错了就报错、不帮忙隐藏问题。这两种哲学各有道理——Go 偏”让程序跑起来”、Rust 偏”让错误显现”。
11.4.2 Chunked 的封装
enum BufKind<B> {
Chunked(Chain<Chain<ChunkSize, B>, StaticBuf>),
ChunkedEnd(StaticBuf),
Trailers(Chain<Chain<StaticBuf, Bytes>, StaticBuf>),
...
}
Chain<Chain<ChunkSize, B>, StaticBuf> 是 bytes::Buf 的零拷贝 chain——三段数据拼接成一个逻辑 Buf,但底层没有任何 memcpy。
三段分别是:
ChunkSize:chunk size 的 hex 字符串 +\r\n,写在 stack 上的 15 字节数组。B:用户的 body 数据(来自Body::poll_frame的 Frame)。StaticBuf:\r\n,两个字节的常量。
当 hyper 要把这段数据写到 socket 时,用 vectored write(writev 系统调用)一次性提交三个 slice——内核 scatter-gather 完成,零拷贝。这就是为什么 chunked encoding 的开销可以接近 Content-Length——额外开销只是 size 字符串(每 chunk 几字节)加一次 writev。
11.4.2.5 ChunkSize 的 stack-allocated 15 字节
ChunkSize 是 hyper encoder 用来临时构造 chunk size 字符串的工具。源码里它是一个固定 15 字节的 on-stack 结构:
pub(crate) struct ChunkSize {
buf: [u8; 15], // ← 栈上的 15 字节
pos: u8,
len: u8,
}
15 字节从哪来?——最大的 u64 在 16 进制下占 16 个字符(FFFFFFFFFFFFFFFF)、加上 \r\n 两字节——需要 18。但实际上 HTTP 实现中 chunk size 极少超过 10 MB(chunk 越大越不利于多路复用)、FFFFFF\r\n 就是 8 字符、15 字节完全够。
如果真的用户 write 了个超过 10^15 字节的 chunk、hyper 会把它切成多个 chunk——每个小于 15 位十六进制表示的上限。这让 ChunkSize 永远不用 overflow。
为什么不直接用 heap-allocated String?——因为 ChunkSize 每个 chunk 用一次、每秒几千个 chunk 就是几千次 malloc/free。栈数组是零分配的——fill 字节、用完释放时什么都不做(就是 stack frame 弹掉)。
这和 §14.5.1.5 讲的 hyper 默认 header 数组栈分配、§8.3.1.8 讲的 vllm 用 pinned memory 都是同一种优化哲学——在热路径上消灭不必要的堆分配。
为什么是 [u8; 15] 而不是 [u8; 16]?——因为 Rust 对 15 字节和 16 字节数组都是 word-aligned、拿空间没差别。选 15 的唯一可能原因是配合 pos: u8 + len: u8 凑到 17 字节——对齐到 8-byte boundary 是 24 字节。选 16 的话是 18 → 对齐到 24——两个配置最终占用相同内存。可能是作者选 15 有其他内部考虑、也可能纯粹是”对 u64 hex 够用”的判断。
11.4.2.6 BufKind enum 的设计:把不同 write 路径统一成一个类型
encoder 的 write 方法返回一个 BufKind<B> enum、把几种不同的 write 路径统一:
enum BufKind<B> {
Exact(B),
Limited(Take<B>),
Chunked(Chain<Chain<ChunkSize, B>, StaticBuf>),
ChunkedEnd(StaticBuf),
Trailers(Chain<Chain<StaticBuf, Bytes>, StaticBuf>),
Eof,
}
6 种 variant 对应 6 种写入模式——
- Exact——直接写 user 数据、不限制、不包装(close-delimited 场景)
- Limited——写 user 数据但截断到 remaining(Length(n) 场景)
- Chunked——包装成 chunk(size + data + \r\n)
- ChunkedEnd——发终止 chunk(
0\r\n\r\n) - Trailers——发 trailer 块(在
0\r\n后) - Eof——没什么可写、返回 Poll::Ready(None)
统一成 enum 的好处——上层代码(Conn 的 write 循环)只用一个函数签名处理所有情况:
match encoder.encode(data)? {
BufKind::Exact(b) => io.write_vectored(&[b])?,
BufKind::Limited(t) => io.write_vectored(&[t])?,
BufKind::Chunked(c) => io.write_vectored(&[c])?,
// ...
}
相比之下、如果不用 enum、encoder 就得返回 Box<dyn Buf>——每次分配一个 trait object、非常昂贵。
enum 让 vectored write 可以一次完成——因为每个 variant 内部已经是 Chain<Chain<..>> 或 Bytes、都实现了 Buf trait——可以直接传给 write_vectored。
这种 “把分支路径统一成 enum、dispatch 在 write 点集中” 的模式是 closed-type-set dispatch——性能远优于 Box<dyn Trait>、代码可读性也好。React 的 Fiber tag、hyper 的 KA 三态、LangGraph 的 Command._update_as_tuples 都用同一种思路——能用 enum 就不用 dyn。
11.4.3 Trailer 的编码
/// An Encoder for when Transfer-Encoding includes `chunked`.
Chunked(Option<Vec<HeaderName>>),
Option<Vec<HeaderName>> 是一个白名单——HTTP/1.1 规定 trailer 里出现的 header name 必须在请求/响应的 Trailer header 里提前声明。hyper 用这个 Vec 校验 user 提供的 trailer 是否都在白名单里。
gRPC over HTTP/1 的响应长这样:
HTTP/1.1 200 OK
Content-Type: application/grpc
Trailer: grpc-status, grpc-message
Transfer-Encoding: chunked
<protobuf frames>
0\r\n
grpc-status: 0\r\n
grpc-message: \r\n
\r\n
hyper 的 encoder 对 into_chunked_with_trailing_fields(vec!["grpc-status", "grpc-message"]) 这种配置专门开了一条路径——trailer 的存在被编入 Encoder 的 chunked kind 里。
11.4.3.5 into_chunked_with_trailing_fields 的 builder 风格
hyper 的 encoder 提供 API into_chunked_with_trailing_fields(trailer_names: Vec<HeaderName>)——把一个普通的 “Chunked(None)” encoder 升级为 “Chunked(Some(whitelist))”。
这是消费 + 重建模式:
pub fn into_chunked_with_trailing_fields(self, fields: Vec<HeaderName>) -> Self {
match self.kind {
Kind::Chunked(_) => Self { kind: Kind::Chunked(Some(fields)) },
_ => panic!("cannot add trailers to non-chunked encoder"),
}
}
self 而不是 &mut self——意味着调用后旧的 encoder 被 move 消费、返回一个新 encoder。这个模式比 &mut self + setter 更 idiomatic:
&mut self——用户可能忘记调(得到一个 “有 trailer 意图但没注册” 的 encoder)self → Self——调 API 就必须拿到新 encoder 替换旧的、漏不掉
“消耗所有权 + 返回新实例” 是 Rust builder pattern 的精髓——保证用户必须完成配置才能得到可用的对象。如果配置不对、编译期就失败(_ => panic! 的分支意味着运行时也不会错过)。
这种模式在 hyper 的 Builder、reqwest 的 ClientBuilder、各种 pipeline 构造里普遍存在——让”构造到可用的路径”在类型层可追踪。
11.4.4 Option<Vec<HeaderName>> 作为 trailer 白名单的设计
§11.4.3 提到 Chunked variant 第一个泛型参数是 Option<Vec<HeaderName>>——这是 trailer 白名单。拆开看三层嵌套:
① Option——区分 “没有 trailer” 和 “有空白名单”——
None→ 这个响应根本不用 trailerSome(vec![])→ 声明会有 trailer、但目前名单为空(可能稍后添加)Some(vec!["grpc-status", "grpc-message"])→ 确定的 trailer 名单
None vs Some(empty) 的语义区分很重要——None 情况下 encoder 不发 Trailer: 声明 header、Some 情况下发。如果用单一 Vec 表达、无法区分这两种情况——就像 §14.1.2 讲的 Dur 三态对 Option 的扩展、这里是 Option 对单一 Vec 的扩展。
② Vec<HeaderName> 而不是 HashSet<HeaderName>——
- Vec 的
contains()是 O(n)、HashSet 是 O(1) - 但 trailer 白名单通常只有 1-5 个条目(grpc 就 2 个)——O(n=5) 和 O(1) 在实际性能上几乎没差
- Vec 的分配和访问更轻量——HashMap 有 hash 计算、rehash、bucket 等 overhead
这是 “小 N 情况 Vec 胜 HashSet” 的典型——性能不是算法复杂度的最优值决定、而是在 N 的典型值下的 constant factor。
③ HeaderName 而不是 String——
HeaderName是 http crate 的类型、内部做了 case normalization(全部小写)和 interning- 直接存
String需要每次比较时 case-insensitive compare、太重 HeaderName比较是指针级比较(intern 后)或小写字符串比较——快
三层嵌套 Option<Vec<HeaderName>> 每一层的选择都是针对具体使用模式的最优解。这种精雕细琢的类型组合在 Rust 里非常常见——类型选错会体现在性能、正确性、语义多个维度上。
11.5 Http1Transaction:Server/Client 的对称抽象
role.rs 最精彩的部分是它的对称设计。HTTP/1 parser 要同时支持 Server(收请求)和 Client(收响应)——它们的 wire format 非常对称:
| Server 看到的 | Client 看到的 | |
|---|---|---|
| 第一行 | Request line: GET /foo HTTP/1.1 | Status line: HTTP/1.1 200 OK |
| 头部 | 一堆 Name: Value | 一堆 Name: Value |
| body 决定 | 读 Content-Length / Transfer-Encoding | 读 Content-Length / Transfer-Encoding(同) |
hyper 用一个 trait 把这两种抽象出来:
// hyper/src/proto/h1/role.rs:131 / 1006
impl Http1Transaction for Server {
type Incoming = RequestLine; // Server 收的是请求行
type Outgoing = StatusCode; // Server 发的是状态码
fn parse(buf: &mut BytesMut, ctx: ParseContext<'_>) -> ParseResult<RequestLine> { ... }
fn encode(enc: Encode<'_, Self::Outgoing>, dst: &mut Vec<u8>) -> crate::Result<Encoder> { ... }
}
impl Http1Transaction for Client {
type Incoming = StatusCode; // Client 收的是状态码
type Outgoing = RequestLine; // Client 发的是请求行
fn parse(buf: &mut BytesMut, ctx: ParseContext<'_>) -> ParseResult<StatusCode> { ... }
fn encode(enc: Encode<'_, Self::Outgoing>, dst: &mut Vec<u8>) -> crate::Result<Encoder> { ... }
}
同一个 trait,两个对称的 impl。Server 和 Client 是两个空 enum(pub(crate) enum Server {})——纯粹的类型级别标记。上层代码泛型化成 Dispatcher<T: Http1Transaction>,可以统一处理两侧。
Connection 的顶层也是泛型:
pub struct Connection<T, S> where S: HttpService<...> {
conn: Http1Dispatcher<T, ..., S, ServerTransaction>,
// T = 底层 IO,S = user service,ServerTransaction = Server impl
}
这种”通过 type state 分离服务端/客户端逻辑”的做法是 Rust 类型系统的典型用法——两条代码路径在类型层被分开,但共享所有实现模板。不用动态 dispatch、不用 if/else branching——编译期单态化完成 “同一段代码服务两个场景” 的工程目标。
11.5.0 pub(crate) enum Server {} 空 enum 作为 type tag
Server 和 Client 在 hyper 里是空 enum:
pub(crate) enum Server {}
pub(crate) enum Client {}
没有 variant、不能实例化。它们唯一的作用是作为 Http1Transaction trait 的类型参数——让编译器在 monomorphize 时选择不同的 impl。
为什么用空 enum 而不是空 struct?——空 struct struct Server; 可以实例化(虽然实例没有任何数据)、可能被人误认为 “有对象语义”。空 enum 根本不能实例化——强制它只能作为 type marker 出现——编译期保证它不承载 runtime 状态。
这种 enum ZeroVariant {} 在 Rust 里叫 uninhabited type(无居民类型)、配合 ! never type 是类型系统里表达”不可能”的原语。如果你写:
fn never_returns() -> Server {
todo!() // panic 或 loop 永远不 return
}
编译器知道这个函数永远不返回——可以做更多优化。
hyper 利用零大小的类型参数实现 trait-based dispatch、单态化后完全消除任何运行时存在——生成的机器码里看不到 Server/Client 的任何痕迹、只有”Server::parse 对应的那段代码”和”Client::parse 对应的那段代码”。
这就是 Rust zero-cost abstraction 的具体体现——你写的 type-level code 在 runtime 是消失的。和 TypeScript 的 enum Foo {}(运行时有对象)、Java 的泛型(擦除但保留 cast)完全不同——Rust 的空 enum 是真的编译后为零。
11.5.1 is_complete_fast:提前判断
// hyper/src/proto/h1/role.rs:93-109
fn is_complete_fast(bytes: &[u8], prev_len: usize) -> bool {
let start = prev_len.saturating_sub(3);
let bytes = &bytes[start..];
for (i, b) in bytes.iter().copied().enumerate() {
if b == b'\r' {
if bytes[i + 1..].chunks(3).next() == Some(&b"\n\r\n"[..]) {
return true;
}
} else if b == b'\n' && bytes.get(i + 1) == Some(&b'\n') {
return true;
}
}
false
}
这是一个小优化——当一次 TCP read 没读完整请求头(需要下次 read 补齐)时,hyper 会先扫描”最近追加的字节”看是否已经到达 \r\n\r\n(header 结束标志)。只有检测到这个标志才重新调 httparse 做完整解析;没检测到的话直接返回 Partial,避免每次 partial read 都让 httparse 跑完整遍。
对慢连接特别有效——想象一个 mobile 客户端一个字节一个字节发 header(理论上合法,实战可能存在),没有 fast-path 的话 hyper 要对每个字节跑完整 header parse。这个简单的扫描把慢连接场景的 CPU 成本降到可忽略。
11.5.2 saturating_sub(3) 在 is_complete_fast 里的边界处理
is_complete_fast 的第一行(role.rs:94):
let start = prev_len.saturating_sub(3);
let bytes = &bytes[start..];
saturating_sub(3) 意思是 “减 3、但不小于 0”。prev_len 如果是 0、1、2——减完变成 negative——普通 usize - 3 会 underflow panic。saturating_sub 把这个 case 处理成 0。
为什么是 3?——因为我们要检查 “最后 4 个字节里有没有 \r\n\r\n”——从最后 4 字节的起点开始扫描、就是 len - 4。但 “新追加的字节” 是 prev_len 之后的部分、要算上 prev_len 之前的 3 个字节(覆盖跨边界情况:上一次 read 结尾是 \r\n\r、这次 read 开头是 \n、四字节组合起来才是 \r\n\r\n)。
所以要检查的范围是 [prev_len - 3, new_len]——回退 3 字节、加上新读到的所有字节。
这种 “跨 chunk 边界的标记检测” 是流式 parser 的通用模式——任何 “N 字节的定长 marker” 都要 回退 N-1 字节才能覆盖所有跨边界情况。hyper 这里处理得很严谨——saturating_sub 保证 prev_len < 3 时也不 underflow。
这种 “流式 parser 的边界一致性” 问题在 HTTP/2 HPACK(第 15 章)、vite 的 CSS parser(第 10 章)、vllm 的 tokenizer streaming 里都会遇到——跨 chunk 的 stateful marker 是数据流处理的永恒主题。
11.6 Header 编码:两种大小写策略
细节之一:hyper 编码 HTTP/1 header 时,header name 的大小写怎么处理?
HTTP 协议规定大小写不敏感,但真实客户端对大小写敏感(IIS、一些老旧 lib)。hyper 提供两种策略:
- LowercaseWriter:所有 name 全小写(HTTP/2 兼容、更紧凑)。
- OrigCaseWriter:按照原始大小写(如果 parser 保留了)或者按照
title-case规则(content-type→Content-Type)。
源码里用 HeaderNameWriter trait 把两种策略抽象:
// role.rs:528-580
trait HeaderNameWriter {
fn write_full_header_line(&mut self, dst: &mut Vec<u8>, line: &str, name_val: (HeaderName, &str));
fn write_header_name(&mut self, dst: &mut Vec<u8>, name: &HeaderName);
...
}
struct LowercaseWriter;
impl HeaderNameWriter for LowercaseWriter { ... }
struct OrigCaseWriter<'a> { ... }
impl HeaderNameWriter for OrigCaseWriter<'_> { ... }
Builder 配置:
hyper::server::conn::http1::Builder::new()
.preserve_header_case(true) // 用 OrigCaseWriter
.serve_connection(io, svc)
生产服务一般不开 preserve_header_case——因为小写更紧凑(HTTP/2 兼容)、缓存命中率更好(规范化)。只有需要和老旧客户端/服务端兼容才开。
11.6.1 OrigCaseWriter 的 per-header-name 查找
OrigCaseWriter 的实现需要”写某个 header 时按原始 case”——但 HeaderName 在内部是统一 lowercased 的。这个信息从哪里找?
答案是 hyper 在 parse 时额外维护一个 “原始 case 映射”:
// 概念性:保存 parse 时看到的每个 header name 的原始字节范围
struct HeaderCaseMap {
names: HashMap<HeaderName, Vec<Bytes>>,
// 小写化 key → 所有看到过的原始 case
}
一个请求里 Content-Type 出现 3 次、原始 case 分别是 “Content-Type”、“content-type”、“CONTENT-TYPE”——Map 里 value 会有 3 个 Bytes 引用。write header 时依次用不同的 case(虽然这种重复 case 极少见)。
为什么要 Vec<Bytes> 而不是 Bytes?——因为header 可以重复(比如 Set-Cookie 有多个)、每个实例可能有不同 case。要保持完全 “preserve” 必须记住每一个。
代价是 parse 阶段多一次 names.entry().or_insert() 插入——对比 lowercase-only 策略、每个 header 多分配一次 entry + Vec。生产推荐关闭 preserve_header_case、就是为了避这个开销。
这个功能的存在让 hyper 能够 proxy 某些老旧后端——Amazon S3 的 signing v2 算法对 header case 敏感、如果你 hyper 作为 proxy 中间层把 S3 请求的 header case 归一化了、signature 会 invalid。开 preserve_header_case 就让 proxy 透明——客户端怎么写、upstream 就收到怎么写。
11.7 一次完整的 roundtrip
把这一章讲的全部组件串起来,看一次”请求进、响应出”的完整字节流:
1. TCP recv 到 BytesMut buffer
↓
2. is_complete_fast 扫描是否有 \r\n\r\n
↓ 有
3. httparse 解析 request line + headers (SIMD)
↓
4. 构造 http::Request<Incoming> + Decoder
↓
5. 交给 Service::call(req)
↓
6. user handler 处理返回 Response
↓
7. role::encode_headers 把 Response 写到 Vec<u8>
↓
8. Encoder (Chunked/Length) 处理 body
↓
9. writev 把 header_vec + chunked_size + body + crlf 一起写到 TCP socket
↓
10. body 结束后发送 final chunk
10 步之间每一步都有挂起恢复点——调用方的 Service 慢、网络慢、客户端读得慢,都会让某一步挂在 Pending 上,waker 被正确注册,task 被正确唤醒。整个链条没有一处”轮询检查”、没有一处 busy wait。
这就是 Rust 对”异步 HTTP 服务”这件事最优雅的落地——协议逻辑 + IO 等待在同一个状态机上,通过 Future 的 Poll 模型优雅交织。
11.7.1 roundtrip 里三个”看不见的 Bytes 流转”
§11.7 那 10 步里、有三次 Bytes 流转值得特别关注、都是零拷贝精髓:
① TCP → BytesMut——kernel 通过 read(fd, buf, len) 把字节拷到用户空间的 BytesMut。这一次拷贝无法避免——kernel/userspace 边界必须过。BytesMut 内部是 Arc<RawBuf>、append 是 O(1) amortized。
② BytesMut → Bytes (通过 split_to)——httparse 告诉 hyper “这段字节范围是 header”、hyper buf.split_to(n) 把前 n 字节从 BytesMut 分离成独立 Bytes——零拷贝、只是切分一个 Arc 引用。原 BytesMut 继续指向后面的字节(即将是 body)、新 Bytes 指向被切出的前面。
③ Bytes → HeaderMap——HeaderName 和 HeaderValue 都是 Bytes 的子切片(sub-slice)。再次零拷贝——HeaderValue::from_bytes(slice) 只是保存一个 offset + len。
整个从 TCP 字节到 HeaderMap 的变换、只有 1 次物理 memcpy(TCP → 用户空间)。之后所有 “切分、解析、传递” 都是 ref-count 游戏——字节本身一直在原地。
发送路径同样——从 HeaderMap 到 TCP socket、Response 的 body 字节被 Bytes::clone() 的方式(零拷贝、ref-count+1)传给 encoder、encoder 通过 vectored write 一次性交给 kernel——又是 1 次物理 memcpy(用户空间 → kernel)。
整个 roundtrip 只有 2 次 memcpy——两个 kernel 边界。这就是 hyper 能在单核跑 1M QPS 的秘密——数据本身几乎不动、只有 metadata 在 CPU 里流转。
这个成就不是 hyper 一家的——是 bytes crate + http crate + hyper 三者联合 的结果。bytes 提供零拷贝切分、http 提供类型安全的 header 存储、hyper 把它们编织成协议实现。这种”多个小库各司其职、组合成工业级能力”是 Rust 生态的美学——和 vite 第 15 章讲的 Module Runner / DevEnvironment / fetchModule 三层协作是同样的生态模式。
11.8 关联与对照
这一章读的内容有几个地方值得回溯:
- 卷四《Tokio 源码深度解析》第 8 章(I/O Driver):hyper 的
MemRead抽象最终底层是 tokio 的AsyncRead。TCP 字节怎么到 BytesMut 缓冲——依赖卷四讲过的 epoll/kqueue 事件机制。 - 卷三《Rust 编译器与运行时揭秘》第 9 章(async 状态机):ChunkedState 的状态机展开原理和
async fn的编译期状态机有同构性——只是 ChunkedState 是手写 enum,async fn 是编译器自动生成。二者都是 “状态 + 输入 → 下一状态 + 输出” 的 finite state machine。
对比 Go 的 net/http:Go 的 HTTP/1 parser(http1.go)用 bufio.Reader.ReadLine() 一行一行读——阻塞式、没有 SIMD、状态完全由 Go runtime 的 goroutine 栈隐式保存。Go 的优势是代码易读;Rust 的优势是可预测的 zero-copy + CPU 效率。两种哲学各有优劣——写 Go 的 HTTP server 1-2 天能读完源码,写 Rust 的要 1-2 周,但 Rust 的源码教会你的东西更多。
11.8.5 本章与 React ch6 / LangGraph ch14 的呼应
与 React ch6(Commit 阶段)的呼应——React Commit 的 subtreeFlags 位标记和 hyper Decoder 的 ChunkedState enum 都是在 enum 或位上压缩状态机——React 压在 Fiber 的 flags 里、hyper 压在 ChunkedState 里。两者都追求”状态位本身成为快路径判断的原子单位”——避免 if-else 链的长路径。
与 LangGraph ch14(Runtime)的呼应——LangGraph 的 Runtime 用 execution_info is None 作为”未准备”状态的天然标记、hyper Decoder 用 Length(0) 作为”已完成”的天然标记——两者都不维护独立的 “is_done” 布尔、而是让数据字段的特殊值承担 “完成” 语义。
与 vite ch15 的呼应——vite 的 concurrentModuleNodePromises 去重 + ensureBuiltins 一次性协商、hyper 的 Http1Transaction trait 做单态分发 + 空 enum type-tag——都是 “启动期付成本、稳态零开销” 的实现。跨语言跨领域的同一种直觉。
11.8.6 本章收束的八条工程原则
① 零拷贝从 kernel 边界开始(§11.2.2, §11.7.1)——全链条只有 1 次 read + 1 次 write 的物理拷贝。
② SIMD 在热路径上(§11.2.1)——header 扫描用 AVX2 / NEON 一次处理 16-32 字节。
③ 栈分配 + MaybeUninit(§11.2.2.5)——100 个 EMPTY_HEADER 栈上构造、parse_with_uninit_headers 避免 memset。
④ Length(0) / size==0 fast-path(§11.3.1.5, §11.3.3.5)——最常见的 trivial case 必须最短路径。
⑤ 主动比 spec 更严格(§11.3.2.5)——拒绝 LF-in-extension 防御下游 buggy 实现。
⑥ io::ErrorKind 承载协议语义(§11.3.3.6)——InvalidInput vs UnexpectedEof 决定上层不同反应。
⑦ 空 enum 做 type tag(§11.5.0)——零大小、零运行时、纯编译期分发。
⑧ 跨 chunk 边界回退 N-1(§11.5.2)——saturating_sub 保证边界检测正确且不 underflow。
这八条加起来就是 hyper HTTP/1 wire 层的密度——每一条都 narrow、合起来就是工业级 HTTP parser 的基线。
11.9 落到你键盘上
本章读完:
- 用
strace/ktrace观察一次 HTTP/1 请求:启动一个 hyper-based server,strace看它做了多少次read和write系统调用。你会看到典型请求就 2-3 次read、1-2 次write——这是 buffered I/O + vectored I/O 的收益。 - 读
proto/h1/decode.rs的ChunkedState::read_body:关注它如何处理”一次 read 跨多个 chunk”的边界——这是最容易出 bug 的地方。 - 实验一个 chunked response:用 curl
--raw模式看 hyper 发出的 chunked 字节。你会验证上面讲的 “size\r\n + data + \r\n” 格式。
下一章我们从 byte-level 爬升到 connection-level——读 conn.rs 和 dispatch.rs,看 HTTP/1 连接的顶层状态机如何把 Decoder / Encoder / Service 编织在一起。
11.10 从 byte-level 到 state-machine 的抽象阶梯
本章聚焦 字节级解析,下一章会进入 连接级状态机。两者之间有一条清晰的抽象阶梯:
字节流 (TCP)
↓ [httparse SIMD]
HeaderMap + Decoder/Encoder 抽象 ← 本章
↓ [Conn 状态机]
Request<Incoming> / Response<Body> ← 下一章
↓ [Dispatcher]
Service::call(req) / Future<Response>
↓ [user handler]
业务逻辑
每一层把下层的复杂性封装成上层看到的干净接口——
- TCP socket 字节 → httparse 的
Status::Complete/Partialenum - httparse 的结果 →
http::Request<Incoming>类型 - Incoming body →
Frame流 - Frame 流 → 用户看到的
Body::poll_frame
每一层只做一件事、但做得非常细致——本章展示了 “字节流 → Request” 的那一层、揭示了这一层有 13 状态机、两种 case 策略、2 次 kernel 拷贝、6 种错误种类。这种单一层次内的密度是 Rust HTTP 生态的特征——抽象清晰但不廉价——每一层都把自己该做的工程细节做到极致。
这也是阅读 hyper 源码最有收获的地方——你不会被一堆混乱的逻辑淹死(因为层次分得清)、但每一层都够深(因为所有细节都可追踪)。这种深度 vs 广度的平衡是工业级框架和学习用框架的核心区别。
11.11 一本书里重复出现的几个模式
读到这一章、你应该发现本书前 10 章已经反复出现了几个跨章节的设计模式——它们在 hyper HTTP/1 wire 层再次呈现:
① 位标记 + fast-check——React Fiber flags(ch6)、h2 FLOW_CONTROL_ERROR(ch16)、本章 is_complete_fast——都在 “能用位或简单扫描快速判断时、不走复杂路径”。
② 栈分配优于堆——本章 [httparse::EMPTY_HEADER; 100]、第 14 章 header 数组、第 16 章 Window(i32) 作为 copy type——热路径绝不分配堆。
③ enum 比 Box<dyn Trait> 轻——本章 Decoder::Kind 和 BufKind、第 6 章 React 的 Fiber tag、第 16 章 FlowControl——closed set 用 enum、open set 才用 dyn。
④ typed wrapper 防误用——第 16 章 Window(i32)、本章 ChunkSize(15 字节栈结构)——不让 primitive 直接流转。
⑤ kernel 边界最多一次 memcpy——本章的 TCP → BytesMut、第 8 章 vLLM pin_memory + H2D、第 14 章 non_blocking write——cross-boundary I/O 必须 minimal。
⑥ 基础库 API 极简——本章 httparse 10 symbol、第 16 章 FlowControl 269 行、bytes 的 Bytes/BytesMut——复杂不是能力、简洁才是。
这六条在 hyper / tokio / h2 / bytes / httparse / tower 里反复浮现——是 Rust 系统库的共同审美:位标记优于标志位 struct、栈数组优于 Vec、enum 优于 dyn、newtype 优于 primitive、跨核越界只一次、核心 API 极简。下次看到新库时可以用这六条当 checklist——任何一条严重违反,要么是作者有意打破(值得深究原因),要么是这个库还不成熟。