每个请求新建 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。
问: 每个请求的超时、请求头都不一样,不新建 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上。