一次请求
主线位置:三条主线各走一遍。一个
Client,对本机 HTTPS 服务器发两次 GET。第一次走完整的请求主线、连接主线(DNS、TCP、TLS、ALPN、HTTP/2 握手)和响应主线;第二次只走请求主线和响应主线,连接从池里取。每一步都用tracing日志和服务端的计数核实,细节链到 02 到 04。
sequenceDiagram participant App as 应用 participant Cl as Client participant Stk as tower 栈 participant HU as hyper-util participant Cn as Connector participant Srv as 服务端 App->>Cl: get first send Cl->>Stk: execute_request Stk->>HU: HyperService call HU->>HU: 池里没有空闲 HU->>Cn: call uri Cn->>Srv: TCP 与 TLS 握手 Cn-->>HU: Conn ALPN h2 HU->>HU: h2 握手 进池 HU->>Srv: HEADERS first Srv-->>HU: 200 响应 HU-->>App: Response App->>HU: text 读完 body App->>Cl: get second send Cl->>Stk: execute_request Stk->>HU: HyperService call HU->>HU: 复用空闲连接 HU->>Srv: HEADERS second Srv-->>HU: 200 响应 HU-->>App: Response
实验
服务端是本卷共用的测试服务器:rustls 服务端,rcgen 现签的 CA 和 localhost 证书,ALPN 同时报 h2 和 http/1.1,只监听 127.0.0.1,记录接受的连接数、完成的握手数和请求数。客户端信任那张 CA,.no_proxy(),其余全是默认值。日志过滤成 reqwest=trace,hyper_util=trace:reqwest 用 log 打日志,hyper-util 用 tracing,tracing-subscriber 默认带 tracing-log 桥,两边都收得到。
#[tokio::main]
async fn main() {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "intro_one=info,reqwest=trace,hyper_util=trace".into()),
)
.with_target(true)
.with_timer(tracing_subscriber::fmt::time::uptime())
.with_ansi(false)
.init();
let srv = start(Opts { tls: true, ..Default::default() }).await;
let client = reqwest::Client::builder()
.no_proxy()
.add_root_certificate(srv.reqwest_ca())
.build()
.unwrap();
tracing::info!("client built");
for path in ["/first", "/second"] {
let t = Instant::now();
tracing::info!("send {path}");
let resp = client.get(srv.url(path)).send().await.unwrap();
let head = t.elapsed();
tracing::info!("got head {} {:?} after {:?}", resp.status(), resp.version(), head);
let text = resp.text().await.unwrap();
tracing::info!("body {text:?}, total {:?}", t.elapsed());
}
tracing::info!(
"server: conns={} tls_handshakes={} reqs={}",
srv.conns(),
srv.handshakes(),
srv.reqs()
);
}
release 构建,完整输出(行首是从初始化日志起的秒数):
0.000460708s INFO intro_one: client built
0.000541625s INFO intro_one: send /first
0.000600917s TRACE hyper_util::client::legacy::pool: checkout waiting for idle connection: ("https", localhost:56726)
0.000621708s DEBUG reqwest::connect: starting new connection 'Some("localhost")'
0.000637833s TRACE hyper_util::client::legacy::connect::http: Http::connect; scheme=Some("https"), host=Some("localhost"), port=Some(Port(56726))
0.001516500s DEBUG hyper_util::client::legacy::connect::http: connecting to [::1]:56726
0.002549625s TRACE hyper_util::client::legacy::connect::http: connect error for [::1]:56726: ConnectError("tcp connect error", Os { code: 61, kind: ConnectionRefused, message: "Connection refused" })
0.002571500s DEBUG hyper_util::client::legacy::connect::http: connecting to 127.0.0.1:56726
0.003282417s DEBUG hyper_util::client::legacy::connect::http: connected to 127.0.0.1:56726
0.034494917s TRACE hyper_util::client::legacy::client: ALPN negotiated h2, updating pool
0.034570625s TRACE hyper_util::client::legacy::client: http2 handshake complete, spawning background dispatcher task
0.034585000s TRACE hyper_util::client::legacy::pool: put; add idle connection for ("https", localhost:56726)
0.034593917s DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("https", localhost:56726)
0.034605750s TRACE hyper_util::client::legacy::pool: checkout dropped for ("https", localhost:56726)
0.034856458s TRACE reqwest::retry: shouldn't retry!
0.034882458s INFO intro_one: got head 200 OK HTTP/2.0 after 34.340916ms
0.034900917s INFO intro_one: body "hello https://localhost:56726/first (conn #1 req #1 total #1)", total 34.359666ms
0.034908083s INFO intro_one: send /second
0.034931833s TRACE hyper_util::client::legacy::pool: take? ("https", localhost:56726): expiration = Some(90s)
0.034941292s DEBUG hyper_util::client::legacy::pool: reuse idle connection for ("https", localhost:56726)
0.035103875s TRACE reqwest::retry: shouldn't retry!
0.035150833s INFO intro_one: got head 200 OK HTTP/2.0 after 242.458µs
0.035171875s INFO intro_one: body "hello https://localhost:56726/second (conn #1 req #2 total #2)", total 263.75µs
0.035179750s INFO intro_one: server: conns=1 tls_handshakes=1 reqs=2
最后一行是结论:两个请求,服务端只接受了 1 个 TCP 连接、完成了 1 次 TLS 握手。下面按三条主线逐段读这份日志。
请求主线:从 send 到 hyper-util
client.get(url) 只是 Client::request 把 URL 解析成 Url、连同一份 Client 的克隆放进 RequestBuilder;send() 进 execute_request:
pub(super) fn execute_request(&self, req: Request) -> Pending {
let (method, url, mut headers, body, version, extensions) = req.pieces();
if url.scheme() != "http" && url.scheme() != "https" {
return Pending::new_err(error::url_bad_scheme(url));
}
// ...
// insert default headers in the request headers
// without overwriting already appended headers.
for (key, value) in &self.inner.headers {
if let Entry::Vacant(entry) = headers.entry(key) {
entry.insert(value.clone());
}
}
// ...
let in_flight = match version {
// ...
_ => {
let mut req = builder.body(body).expect("valid request parts");
*req.headers_mut() = headers.clone();
let mut hyper = self.inner.hyper.clone();
ResponseFuture::Default(hyper.call(req))
}
};
// ...
self.inner.hyper 是 build 时组装好的整个 tower 栈。日志在这一段什么都没打:解压层、重定向层、cookie 层(这个客户端没开 cookie store,层在但直接放行)都不打日志,直到重试层在响应回来后打出 shouldn't retry!。请求离开 reqwest 时带了哪些头,让服务端回显一次就知道(GET /echo,同一个客户端):
GET https://localhost:56643/echo HTTP/2.0
accept: */*
accept-encoding: gzip
body: 0 bytes
accept: */* 是 ClientBuilder::new 放进默认头的;accept-encoding: gzip 不是 reqwest 的代码加的,而是 tower-http 的 Decompression 层在 call 里看到请求没有这个头才补上,值只含打开了的算法(实验模板开了 gzip)。没有 user-agent:reqwest 默认不发。HTTP/2 下服务端看到的是完整的 https://localhost:... URI,HTTP/1.1 下才是路径加 host 头。栈的组装顺序和每层做什么见 执行与 Pending。
hyper.call(req) 最终到 HyperService,它调 hyper-util 的 Client::request。日志第一条来自那里:checkout waiting for idle connection,池的键是 ("https", localhost:56726),池是空的,于是 one_connection_for 一边等池里出现空闲连接,一边让连接器新建一条,谁先好用谁,见 hyper 卷的 legacy Client。
PendingRequest 里#2617(d9cf60e,2025-05-28,「refactor: Using FollowRedirect from tower-http to handle the redirect loop」),首次发布于 0.12.16。改之前,PendingRequest 自己带着一个 urls: Vec<Url>,poll 拿到响应后判断状态码是不是重定向、调 redirect_policy.check、调 remove_sensitive_headers、再自己重新发请求;diff 里删掉的就是这一整段(debug!("redirecting '{}' to '{}'", ...) 等)。改之后循环交给 tower-http 的 FollowRedirect,reqwest 只提供策略 TowerRedirectPolicy,PendingRequest::poll 只剩一件和重定向有关的事:从响应扩展里取 RequestUri,更新最终 URL。提交说明只有「Closes #2576」。此后 0.12.23 到 0.12.25 又陆续把重试(#2763)、cookie(#2787)、解压(#2840)改成层,才有今天这四层的栈。
连接主线:DNS、TCP、TLS、ALPN
池里没有连接,hyper-util 调连接器。starting new connection 是 ConnectorService::call 打的;没有代理,走 connect_with_maybe_proxy 的 rustls 分支,它把 HttpConnector 包进 hyper_rustls::HttpsConnector 后调用。接下来的四行都来自 hyper-util 的 HttpConnector:
Http::connect; scheme=Some("https"):0.13.5 构造连接器时调了enforce_http(false),所以https也放行,TLS 由外层做。- 到
connecting to [::1]用了约 0.9 ms,这是 DNS。实验模板开了hickory-dns,所以默认 resolver 是 hickory;它在第一次解析时才建(new_resolver读系统 DNS 配置),并把查询策略设成Ipv6AndIpv4。localhost得到::1和127.0.0.1两个地址。 - 先连
[::1],测试服务器只听 IPv4,约 1 ms 后Connection refused,换127.0.0.1,0.7 ms 后连上。两个地址的先后和回退是 hyper-util 的 Happy Eyeballs,见 DNS 和 hyper 卷的 HttpConnector。 connected to 127.0.0.1到ALPN negotiated h2是 TLS 握手,用了 31 ms。
31 ms 远超 rustls 卷在回环上测到的完整握手(不到 1 ms)。原因是这是进程里第一次用 aws-lc-rs。单独量第一次生成 X25519 密钥对:
round 0 aws-lc-rs X25519 start: 25.825625ms
round 0 aws-lc-rs secp256r1 start: 1.639458ms
round 0 aws-lc-rs secp384r1 start: 131.417µs
round 0 aws-lc-rs X25519MLKEM768 start: 825.708µs
round 0 ring X25519 start: 401.541µs
round 1 aws-lc-rs X25519 start: 10.584µs
...
第一次 25.8 ms,之后 10.6 µs。输出的顺序也顺带说明,这个程序里 aws-lc-rs provider 的第一个密钥交换组是 X25519,X25519MLKEM768 排在最后:reqwest 以 default-features = false 依赖 rustls,实验 crate 也没开 rustls 的默认 feature,prefer-post-quantum 不在,DEFAULT_KX_GROUPS 就按这个顺序排;依赖图里别的 crate 打开 rustls 默认 feature 时,顺序会变。provider 的选择见 TLS 配置。
build 之前先让 aws-lc-rs 生成一次密钥对,第一个请求还要多久?握手完成后,hyper-util 看到 ALPN 是 h2:ALPN negotiated h2, updating pool 把这次「正在建连接」的登记改成 HTTP/2 的,然后做 HTTP/2 握手(http2 handshake complete,后台起一个任务跑连接)。注意下一行:put; add idle connection、pooling idle connection 出现在响应回来之前。HTTP/2 连接可以同时跑多个流,hyper-util 握手一完就把它放进池里共享,请求只是拿走一个发送句柄,见 connect_to 和 连接池与 HTTP 版本。checkout dropped 是赛跑里输掉的那一方(池的 checkout)被丢掉了。
响应主线:头、体、回池
got head 200 OK HTTP/2.0:响应头一到,栈里各层依次处理(重试层判断「不用重试」并打出 shouldn't retry!),PendingRequest::poll 用 Response::new 把它包起来。这个客户端没设超时,body::response 不加任何包装。text() 读完约 40 字节的体只多了 18 µs,再按 Content-Type 的字符集解码(没有就按 UTF-8)。
第二次请求:只走池
/second 一共 0.24 ms:
take? ... expiration = Some(90s):池里有一条空闲连接,pool_idle_timeout默认 90 秒,还没过期;reuse idle connection:直接用它,没有starting new connection,没有 DNS、TCP、TLS;- 响应体里的
conn #1 req #2是服务端写的:同一条连接上的第二个请求。
最后一行 server: conns=1 tls_handshakes=1 reqs=2 从服务端一侧确认了复用。
HTTP/1.1 对照
同一个程序加 http1_only()(ALPN 只报 http/1.1),连接主线后半段和回池的时机不同:
...
0.003380417s DEBUG hyper_util::client::legacy::connect::http: connected to 127.0.0.1:56729
0.030561375s TRACE hyper_util::client::legacy::client: http1 handshake complete, spawning background dispatcher task
0.030582792s TRACE hyper_util::client::legacy::client: waiting for connection to be ready
0.030607958s TRACE hyper_util::client::legacy::client: connection is ready
0.030620417s TRACE hyper_util::client::legacy::pool: checkout dropped for ("https", localhost:56729)
0.030779333s TRACE hyper_util::client::legacy::pool: put; add idle connection for ("https", localhost:56729)
0.030794083s DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("https", localhost:56729)
0.030803208s TRACE reqwest::retry: shouldn't retry!
0.030821958s INFO intro_one: got head 200 OK HTTP/1.1 after 30.462916ms
0.030838083s INFO intro_one: body "hello /first (conn #1 req #1 total #1)", total 30.483583ms
0.030843958s INFO intro_one: send /second
0.030860958s TRACE hyper_util::client::legacy::pool: take? ("https", localhost:56729): expiration = Some(90s)
0.030868042s DEBUG hyper_util::client::legacy::pool: reuse idle connection for ("https", localhost:56729)
0.030980292s TRACE hyper_util::client::legacy::pool: put; add idle connection for ("https", localhost:56729)
0.030996875s DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("https", localhost:56729)
0.031003333s TRACE reqwest::retry: shouldn't retry!
0.031011458s INFO intro_one: got head 200 OK HTTP/1.1 after 167.416µs
0.031023667s INFO intro_one: body "hello /second (conn #1 req #2 total #2)", total 179.666µs
0.031030000s INFO intro_one: server: conns=1 tls_handshakes=1 reqs=2
HTTP/1.1 连接一次只能跑一个请求,所以它在请求结束后才回池,每个请求都有一对 put / pooling idle connection。这里它在「got head」之前就回池了,因为响应体只有几十字节,和响应头在同一次读里到达,hyper 的后台任务已经把整条消息读完、连接回到空闲;用户代码还没调 text(),体已经在通道里等着。响应体大或来得慢时,连接要等体读完才回池,体没读完就丢掉 Response 会怎样,见 响应体。
动脑筋答案
加一个 warm 开关,在 build 之前调一次 rustls::crypto::aws_lc_rs::default_provider().kx_groups[0].start(),连跑三次,三次一致,其中一次:
0.024840375s INFO intro_one: aws-lc-rs warmed
0.024959791s INFO intro_one: client built
...
0.028131041s DEBUG hyper_util::client::legacy::connect::http: connected to 127.0.0.1:55949
0.030577916s TRACE hyper_util::client::legacy::client: ALPN negotiated h2, updating pool
...
0.030945916s INFO intro_one: got head 200 OK HTTP/2.0 after 5.982042ms
...
0.031125041s INFO intro_one: got head 200 OK HTTP/2.0 after 164.458µs
...
```预热本身花了约 25 ms,第一个请求从 30 到 36 ms 降到 5.5 到 6 ms,TLS 握手从约 31 ms 降到约 2.4 ms。总时间没变少,只是挪到了预热上:这 25 ms 是每个进程付一次的 aws-lc-rs 初始化,和 reqwest 无关。剩下的 2.4 ms 握手里,证书交给 macOS 的平台校验器是一大块;换成 `tls_certs_only`(webpki 只信那张 CA),每次新建连接的耗时少约 0.7 ms,见 [一个 Client](/docs/reqwest/01-overview/client-shape)。
## 小结
- 请求主线:`send` → `execute_request`(默认头 `accept: */*`)→ 解压层补 `accept-encoding`(开了 `gzip` 等 feature 时)→ 重定向、cookie(开了 `cookies` 时)、重试 → `HyperService` → hyper-util 的池;reqwest 默认不发 `user-agent`。
- 连接主线:hickory 把 `localhost` 解析成 `::1` 和 `127.0.0.1`,先试 IPv6 被拒再连 IPv4;rustls 握手后 ALPN 选 `h2`,HTTP/2 握手完立刻进池共享。
- 第一次请求 34 ms,其中约 26 ms 是进程里第一次用 aws-lc-rs;预热后第一次约 6 ms。
- 第二次请求 0.24 ms:`reuse idle connection`,服务端 `conns=1 tls_handshakes=1 reqs=2`。
- HTTP/1.1 下连接每个请求结束后回池;小响应在用户读体之前就已经回池。