怎么读这一卷
主线位置:两条主线的总览。本页把服务端主线和客户端主线各画一遍,标出每一步落在哪个函数上;下一部分的「一个请求的一生」用一段程序把两条线同时跑一遍。
flowchart TB
start[从这里开始] --> q{读这一卷为了什么}
q -->|会用| use1[01 全景]
use1 --> use2[02 运行时抽象]
use2 --> use3[07 hyper-util 服务端]
use3 --> use4[10 常见坑]
q -->|读懂实现| all[00 到 12 按顺序]
锁定版本
全卷只对着一个版本写:
| 项 | 值 |
|---|---|
| crate | hyper 1.12.0,hyper-util 0.1.21 |
| tag | hyper v1.12.0,hyper-util v0.1.21 |
| commit | hyper 7f67699c(2026-10-06),hyper-util 23a8689(2026-09-24) |
| 用到的依赖 | http 1.5.0、http-body 1.1.0、http-body-util 0.1.5、h2 0.4.20、httparse 1.10.1、want 0.3.2 |
| 实验用的 tokio | 1.54.1,和 tokio 卷相同 |
源码链接都用 tag,形如 https://github.com/hyperium/hyper/blob/v1.12.0/src/...#L123 和 https://github.com/hyperium/hyper-util/blob/v0.1.21/src/...#L123。不是 hyper 仓库里的依赖(h2、httparse、http-body-util 等)只写 crate 名和版本,不给行号。
想在本地对照着读:
git clone https://github.com/hyperium/hyper
git -C hyper checkout v1.12.0
git clone https://github.com/hyperium/hyper-util
git -C hyper-util checkout v0.1.21
两个仓库的 master 都已经走过了这两个 tag。写作时 hyper 的 master 比 tag 多 4 个提交,其中一个改了本卷重点讲的 Dispatcher(#4222,「drain in-flight requests before dispatcher shutdown」);hyper-util 多 11 个提交,有新的 TokioLocalExecutor(#330)、TokioHandleExecutor(#331),以及把 HttpConnector 放到 feature 后面(#341)。这些都不在本卷的版本里,读 master 会看到对不上的代码和行号。
本站自己锁的是 hyper 1.10.1、hyper-util 0.1.20(框架仓库的 Cargo.lock),比本卷早两个小版本,差异放在「在本站中」一部分讲。
两条主线
hyper 只做一件事:把一条字节流变成一串 Request / Response,或者反过来。字节流在 hyper 眼里只是实现了 hyper::rt::Read 和 Write 的东西,TCP、TLS、Unix socket 都一样。全卷沿这件事的两个方向展开,每页开头都会标出本页在哪条主线上。
服务端主线(字节 → 请求 → 响应 → 字节):
flowchart TB
A["accept 得到 TcpStream"] --> B["TokioIo::new 包一层"]
B --> C{"auto::Builder:看前 24 字节"}
C -->|HTTP/1| D["http1::Builder::serve_connection"]
C -->|HTTP/2 前言| H2["http2 连接,见 05"]
D --> E["Connection future,即 Dispatcher"]
E --> F["poll_read:读请求头,parse"]
F --> G["recv_msg:service.call(req)"]
G --> W["poll_write:等响应,encode"]
W --> FL["poll_flush:写回 socket"]
FL -->|keep-alive| F
- 你的代码
accept一条连接,用 hyper-util 的TokioIo把 tokio 的TcpStream包成 hyper 认识的 IO。 - 用 hyper-util 的
auto::Builder时,它先读最多 24 个字节和 HTTP/2 的连接前言比较,不一样就当 HTTP/1;只要 HTTP/1 的话直接用 hyper 的http1::Builder::serve_connection。 serve_connection返回的Connection是一个 future,里面就是Dispatcher。这条连接上的所有请求都在这一个 future 里处理。Dispatcher的poll_loop每圈做三件事:poll_read读出请求头(role::Server::parse,底下是 httparse),通过Dispatch::recv_msg调用你的service.call(req);poll_write等 service 返回的 future 完成,把响应头编码写进缓冲区,再按需写 body;poll_flush把缓冲区写进 socket。- 一个请求处理完,读写状态回到初始状态,同一条连接上读下一个请求(keep-alive)。
客户端主线(请求 → 字节 → 响应):
flowchart TB
A["legacy::Client::request"] --> B{"连接池 checkout"}
B -->|没有空闲连接| C["HttpConnector:DNS 再 TCP"]
C --> D["http1::handshake"]
D --> E["SendRequest 留给调用方"]
D --> F["Connection 交给后台任务"]
B -->|有空闲连接| E
E --> G["send_request:请求放进通道"]
G --> F
F --> H["写请求,读响应头"]
H --> I["回调把 Response 交回"]
I --> J["body 读完,连接回池"]
- hyper-util 的
legacy::Client::request先去连接池里找空闲连接,同时准备新建一条,两者赛跑。 - 新建连接时,
HttpConnector做 DNS 解析和 TCP 连接,再调用 hyper 的client::conn::http1::handshake,得到一对东西:SendRequest和Connection。Connection被交给执行器在后台跑,SendRequest留给发请求的一方。 SendRequest::send_request不碰 socket,只把请求和一个oneshot回调放进dispatch::channel。这个通道底下是 tokio 的无界mpsc,再用want限制成「连接空闲时才收下一个请求」。- 后台任务里跑的同样是
Dispatcher,只是Dispatch换成了客户端的实现:从通道取出请求、编码写出、读回响应头,通过回调把Response交回send_request的调用方。 - 响应 body 读完,连接重新就绪,回到池里。
两条主线共用同一个 Dispatcher 和 Conn(src/proto/h1/),区别只在两处:Dispatch trait 的实现(dispatch::Server 调用 service,dispatch::Client 读通道),以及 Http1Transaction 的实现(role::Server 解析请求、编码响应,role::Client 反过来)。所以「03 HTTP/1 连接」是全卷的地基,后面的客户端、body、升级都要链回那里。
阅读路线
- 只想会用:读 01 全景、02 运行时抽象与核心类型、07 hyper-util 服务端,以及常见坑。读完能自己写 accept 循环、知道
TokioIo/TokioExecutor/TokioTimer各管什么、会用auto::Builder和优雅停机,也知道最常见的几条报错从哪里来。 - 想读懂实现:按顺序读。03 是重点,
Dispatcher的循环和Conn的读写状态机在那里;04 到 06 都建立在它上面;09 把各章的取舍汇总。 - 用 hyper-util 的边角件:要给后台任务带 tracing span,读 02 的 rt::tracing;要经 HTTP 代理或 SOCKS 连出去,读 06 的 代理连接器 和两个相应的坑;想自己拼连接池,先读 06 的 连接池,再读 client::pool。这三章都建立在 02 和 06 前几章上,可以跳着读。
摘自 hyper 的代码片段一律保留原来的格式(4 空格缩进),方便和原文件对照;本站自己写的示例用 2 空格缩进。每个示例上方写出它需要的依赖和 feature,只开用到的那几个。实验的平台是 M2 Max、rustc 1.99.0。
前置知识
这一卷默认你会写 async Rust。hyper 不绑定运行时,但示例都跑在 tokio 上;hyper 1.x 的 Service 和 tower 的 Service 也要对照着看。用到的东西在前两卷讲过,这里只链过去:
| 概念 | 讲它的页 | 本卷用到它的地方 |
|---|---|---|
Future::poll、Waker | poll 与唤醒 | Dispatcher 返回 Pending 前,读写两边都要留下唤醒 |
socket 读到 Pending | 从 TcpStream::read 到挂起、反应器 | Conn 读请求头、读 body 时挂起,数据到了由反应器唤醒连接任务 |
tokio::spawn | spawn 全路径 | 每条连接一个任务;HTTP/2 每个流再一个任务 |
Sleep | Sleep 的一生 | header_read_timeout、HTTP/2 keep-alive ping |
mpsc、oneshot | mpsc、oneshot、watch、broadcast、Notify | 客户端把请求送进连接任务、把响应送回来 |
tower 的 Service 与 poll_ready | Service trait | hyper 自己的 Service 去掉了 poll_ready,call 只取 &self |
Oneshot | ServiceExt 与 Oneshot | TowerToHyperService 每个请求克隆一次服务,再走一遍 Oneshot |
MakeService | Steer 与 MakeService | hyper 0.14 和 axum 每条连接造一个服务 |
hyper 官网的 guides 有服务端和客户端的入门,lib.rs 的 crate 文档第一段也让新手先去读它。先照着写一个 hello world,再来看源码会轻松很多。
目录
| 部分 | 内容 |
|---|---|
| 00 导读 | 本页;源码地图 |
| 01 全景 | hyper 解决什么问题、1.0 为什么拿掉 Server 和 Client;一个请求的一生 |
| 02 运行时抽象与核心类型 | rt::Read / Write、Executor 与 Timer、hyper 的 Service 与 body、给执行器带 span |
| 03 HTTP/1 连接 | 本卷重点:服务端 Builder、Dispatcher、Conn 状态机、解析与编码、body 编解码、缓冲 IO、升级 |
| 04 body | Incoming 与容量为 1 的通道;http-body-util |
| 05 HTTP/2 | hyper 怎么用 h2:服务端、客户端、ping |
| 06 客户端 | client::conn、legacy Client、连接池、HttpConnector、代理连接器、client::pool |
| 07 hyper-util 服务端 | auto:一个端口两个协议;优雅停机 |
| 08 错误 | hyper::Error 的种类和自动回的状态码 |
| 09 设计取舍 | 把各章的取舍汇总到一起 |
| 10 常见坑 | 用报错原文做标题,讲它在源码里怎么产生 |
| 11 在本站中 | 本站(开源)代码里怎么用 hyper,和本卷版本的差异 |
| 12 动手 | 不用 hyper,从零写一个最小的 HTTP/1.1 服务端 |
小结
- 全卷锁定 hyper
v1.12.0(7f67699c)和 hyper-utilv0.1.21(23a8689);两边的 master 都已走过 tag,行号以 tag 为准。 - 服务端主线:accept →
TokioIo→(auto判断版本)→serve_connection→Dispatcher每圈读、写、刷;一条连接一个 future。 - 客户端主线:池里取连接或新建 →
handshake得到SendRequest和Connection→send_request把请求放进通道 → 后台的Dispatcher写请求、读响应、经回调交回。 - 两条主线共用
proto/h1的Dispatcher和Conn,差别只在Dispatch和Http1Transaction两个 trait 的实现。 - 只想会用读 01、02、07 和常见坑;想读懂实现按顺序读,03 是重点。