Rust in Everything
基于 reqwest 0.13.5

一次请求

主线位置:三条主线各走一遍。一个 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。

📝NOTE
冷知识:重定向循环原来写在 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 配置。

❗IMPORTANT
动脑筋:在 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 下连接每个请求结束后回池;小响应在用户读体之前就已经回池。

💬 关联讨论

发起讨论
加载中...