Rust in Everything
基于 reqwest 0.13.5

每个请求新建 Client

主线位置:请求主线的起点,加上连接主线的全部。Client 里装着什么、ClientBuilder::build 按什么顺序拼出连接池、TLS 配置和代理表,见 一个 Client。这一页只看一种写法:一个「发个请求」的辅助函数,每次调用都 build 一个新 Client。

flowchart TB
  f["fetch 辅助函数"] --> b["build 新 Client"]
  b --> sys["读系统代理"]
  b --> pool["空连接池"]
  pool --> tcp["新 TCP"]
  tcp --> tls["完整 TLS 握手"]
  tls --> req["一个请求"]
  req --> drop["Client 被丢掉"]
  drop --> close["连接一起关闭"]

实验 crate:exp-reqwest/pita-lab,src/bin/p01_per_request.rs 和 src/bin/p01_log.rs,服务端是本机 testsrv(HTTPS,ALPN h2、http/1.1)。M2 Max、macOS 27、rustc 1.99.0,release。

现象

这种辅助函数很常见,单看没有任何问题:

async fn fetch_new_client(url: &str, ca: reqwest::Certificate) -> reqwest::Result<usize> {
  let client = reqwest::Client::builder().no_proxy().add_root_certificate(ca).build()?;
  Ok(client.get(url).send().await?.bytes().await?.len())
}

对本机 HTTPS 服务器顺序调 200 次,再换成「同一个 Client 发 200 次」,每个请求计时到读完 body;服务端数连接和握手的增量。开始前先发一次,把 aws-lc-rs 的一次性初始化排除在外。最后两行只量 build(),不发请求:

new Client per call  n=200 min=    2.1ms median=    2.5ms  conns+200 tls+200
one shared Client    n=200 min=   75.0µs median=   93.0µs  conns+1 tls+1
build() default      n=200 min=  117.0µs median=  143.6µs
build() no_proxy()   n=200 min=  583.0ns median=  625.0ns

连跑三遍,uptime 负载 26(本机同时有别的编译任务)。新建那一行的中位数三遍是 2.6 / 2.6 / 2.5 ms,共享那一行是 112 / 108 / 93 µs,服务端计数每遍都一样。

  • 慢 27 倍,而且全花在连接上。 200 个请求,服务端接受了 200 条连接、做了 200 次 TLS 握手;共享时只有 1 条、1 次。
  • build() 本身不贵,但默认配置每次要 0.1 ms 以上。 关掉系统代理只要 0.6 µs,差的是读系统代理。

在测试服务器之外,往往看不到服务端计数。hyper-util 的 debug 日志能看出同样的事(RUST_LOG=hyper_util=debug,明文 HTTP,每种写法 3 个请求):

-- shared client, 3 requests
DEBUG hyper_util::client::legacy::connect::http: connecting to 127.0.0.1:50169
DEBUG hyper_util::client::legacy::connect::http: connected to 127.0.0.1:50169
DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("http", 127.0.0.1:50169)
DEBUG hyper_util::client::legacy::pool: reuse idle connection for ("http", 127.0.0.1:50169)
DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("http", 127.0.0.1:50169)
DEBUG hyper_util::client::legacy::pool: reuse idle connection for ("http", 127.0.0.1:50169)
DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("http", 127.0.0.1:50169)
-- new client per request, 3 requests
DEBUG hyper_util::client::legacy::connect::http: connecting to 127.0.0.1:50169
DEBUG hyper_util::client::legacy::connect::http: connected to 127.0.0.1:50169
DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("http", 127.0.0.1:50169)
DEBUG hyper_util::client::legacy::connect::http: connecting to 127.0.0.1:50169
DEBUG hyper_util::client::legacy::connect::http: connected to 127.0.0.1:50169
DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("http", 127.0.0.1:50169)
DEBUG hyper_util::client::legacy::connect::http: connecting to 127.0.0.1:50169
DEBUG hyper_util::client::legacy::connect::http: connected to 127.0.0.1:50169
DEBUG hyper_util::client::legacy::pool: pooling idle connection for ("http", 127.0.0.1:50169)
server conns=4

新建的那一组,每个连接都被「pooling idle」放进了池,却从来没有「reuse」:放进去的是一个马上就要被丢掉的池。

原因

连接池是 Client 的一部分。 Client 只有一个字段 inner: Arc<ClientRef>,池在 ClientRef 里那个 hyper-util Client 的最里面,不在客户端之间共享。辅助函数返回时 client 被 drop,它的池和池里的空闲连接一起没了;下一次调用拿到的是新的空池,只能重新解析、建 TCP、做完整的 TLS 握手。

TLS 会话也跟着丢。 rustls 的会话缓存在 ClientConfig 里,build 每次建一个新的,所以连会话恢复都用不上,200 次全是完整握手。

系统代理每次都读。 build 把 Matcher::system 追加到代理表,它调 hyper-util 的 Matcher::from_system:读八个代理环境变量,在 macOS 上再建一个 SCDynamicStore 取系统代理设置。结果不缓存,每个 Client 读一次(为什么不缓存见 代理 的冷知识)。上面 build() default 那一行的 0.14 ms 就是它。

reqwest 自己的 reqwest::get 也是这个形状,函数体只有一行 Client::builder().build()?.get(url).send().await,文档注释写着「should not be used if making many requests」。

修法

建一次,到处克隆。Client 克隆只是一次原子加,克隆出来的共享同一个池:

struct Api {
  http: reqwest::Client, // built once at startup
}

impl Api {
  async fn fetch(&self, url: &str) -> reqwest::Result<bytes::Bytes> {
    self.http.get(url).send().await?.bytes().await
  }
}

没有地方放的话,用 static HTTP: std::sync::LazyLock<reqwest::Client>。改完就是上面「one shared Client」那一行:200 个请求 1 条连接、1 次握手,中位数 93 µs。

📝NOTE
没有蠢问题

问: 每个请求的超时、请求头都不一样,不新建 Client 怎么配?

答: 这些都能按请求设。RequestBuilder::timeout 覆盖客户端的总超时,只对这一个请求生效;header、bearer_auth、version 也都在 RequestBuilder 上。只有落在连接上的设置必须分开建 Client:代理、TLS(根证书、客户端证书、后端)、重定向策略、池参数、connect_timeout 和 read_timeout。这种情况按配置建几个长期存在的 Client,而不是按请求建。

怎么检查

  • 搜代码: grep -rn 'Client::new()\|Client::builder()\|reqwest::get(' src/,看每一处是不是在一个会被反复调用的函数里。
  • 看日志: 打开 RUST_LOG=hyper_util=debug,同一个主机的请求如果每次都是 connecting to、从来没有 reuse idle connection,就是池没起作用。
  • 测试里数连接: 测试服务器记一下接受了几条连接(本卷的 testsrv 就是这么做的),连发几个请求后断言连接数是 1。

小结

  • 连接池、TLS 会话缓存、系统代理快照都属于一个 Client;每次新建等于每次从零开始。
  • 本机 HTTPS:每次新建约 2.5 ms、200 次握手;共享约 0.1 ms、1 次握手。默认 build() 每次还要约 0.14 ms 读系统代理。
  • reqwest::get 每次都新建 Client,只适合一次性的请求。
  • 建一次、克隆着用;按请求变化的超时和头放在 RequestBuilder 上。

💬 关联讨论

发起讨论
加载中...