Rust in Everything
基于 reqwest 0.13.5

一个 Client

主线位置:请求主线的起点。每个请求都从一个 Client 出发,它在 build 时就把连接主线要用的东西(resolver、TLS 配置、连接器、连接池)和请求主线要过的 tower 栈全部准备好。本页看 Client 里装着什么、ClientBuilder::build 按什么顺序拼出它们,再实测「共享一个 Client」和「每个请求新建一个」差多少,差在哪。

flowchart TB
  client["Client 8 字节"] --> arc["Arc ClientRef"]
  arc --> hdr["默认头 超时 代理表"]
  arc --> stack["LayeredService"]
  stack --> dec["Decompression"]
  dec --> fr["FollowRedirect"]
  fr --> ck["CookieService"]
  ck --> rt["Retry"]
  rt --> hs["HyperService"]
  hs --> hu["hyper-util Client"]
  hu --> pool["连接池"]
  hu --> conn["Connector"]
  conn --> tlscfg["rustls ClientConfig"]
  conn --> res["DynResolver"]

Client 是一个 Arc

#[derive(Clone)]
pub struct Client {
    inner: Arc<ClientRef>,
}

(Client)类型上的文档写得很直白:「The Client holds a connection pool internally ... so it is advised that you create one and reuse it」,以及「You do not have to wrap the Client in an Rc or Arc to reuse it, because it already uses an Arc internally」。实测:

size_of::<Client>() = 8
clone+drop x1e6: 4.034959ms

一个指针,克隆加丢弃一次约 4 ns,就是一次原子加一次原子减。克隆出来的 Client 共享同一个 ClientRef,也就共享同一个连接池。client.get(url) 内部就克隆了一次:Client::request 把 self.clone() 放进 RequestBuilder,execute_request 又把 self.inner.clone()(Arc<ClientRef>)放进 PendingRequest,所以一个在飞的请求会让客户端一直活着。Client 还为自己和 &Client 实现了 tower 的 Service<Request>,poll_ready 永远就绪,可以直接放进别的 tower 栈。

ClientRef 的字段:

struct ClientRef {
    accepts: Accepts,
    #[cfg(feature = "cookies")]
    cookie_store: Option<Arc<dyn cookie::CookieStore>>,
    headers: HeaderMap,
    hyper: LayeredService<HyperService>,
    #[cfg(feature = "http3")]
    h3_client: Option<LayeredService<H3Client>>,
    referer: bool,
    total_timeout: RequestConfig<TotalTimeout>,
    read_timeout: Option<Duration>,
    proxies: Arc<Vec<ProxyMatcher>>,
    proxies_maybe_http_auth: bool,
    proxies_maybe_http_custom_headers: bool,
    https_only: bool,
    redirect_policy_desc: Option<String>,
}

能分成三类:

  • 每个请求在 execute_request 里读的:headers(默认头)、https_only、total_timeout 和 read_timeout(装进 PendingRequest 的两个计时器)、proxies 和两个 proxies_maybe_* 标志(只有走 HTTP 代理的明文请求才加 Proxy-Authorization,两个布尔值让没有代理认证的客户端一步跳过这段逻辑)。
  • 整个栈:hyper。连接池、连接器、TLS 配置、resolver 都藏在它最里面的 hyper-util Client 里,不在 ClientRef 的顶层。
  • 只给 Debug 用的:accepts、referer、redirect_policy_desc、cookie_store。它们真正起作用的副本已经交给了对应的层;怎么读这一卷 里打印的 Client { accepts: Accepts { gzip: true }, referer: true, default_headers: {"accept": "*/*"} } 就来自这里。

build 做了什么

ClientBuilder::build 一个函数七百行,消耗 Config(六十多个字段),按顺序做这些事:

步骤位置用到的 Config 字段产物去哪
1. 有攒下的错误就直接返回第 412 行error(例如 add_root_certificate 之前某个 builder 方法失败)—
2. 代理表:用户加的 Proxy,再加 ProxyMatcher::system()第 418 行proxies、auto_sys_proxy(no_proxy() 把它关掉)Arc<Vec<ProxyMatcher>>,连接器和 ClientRef 各一份
3. resolver:getaddrinfo 或 hickory,自定义的覆盖它,resolve() 的覆盖表再包一层第 426 行hickory_dns、dns_resolver、dns_overridesDynResolver,见 DNS
4. HttpConnector::new_with_resolver,设连接超时第 452 行connect_timeout连接器的 TCP 部分
5. TLS:按后端分支。rustls 分支筛协议版本,取进程默认 provider(没有就用 aws-lc-rs),选校验器(NoVerifier、IgnoreHostname、平台校验器、webpki 加 CRL 四选一),加客户端证书、SNI、SSLKEYLOGFILE,按 HTTP 版本偏好设 ALPN第 689 行 起tls、min_tls_version / max_tls_version、certs_verification、hostname_verification、root_certs、tls_certs_only、crls、identity、tls_sni、tls_sslkeylogfile、http_version_prefrustls::ClientConfig,交给 ConnectorBuilder::new_rustls_tls,见 TLS 配置
6. 连接器的其余选项第 922 行connect_timeout、connection_verbose、tcp_keepalive*、unix_socket 等ConnectorBuilder,见 连接器
7. hyper-util 的 Client::builder:HTTP/2 参数、两个 TokioTimer、池的空闲超时和每主机上限、HTTP/1 解析选项第 941 行http2_*、pool_idle_timeout(默认 90 秒)、pool_max_idle_per_host(默认不限)、http1_*、http09_responsesHyperClient,见 连接池与 HTTP 版本
8. 包 tower 栈:Retry → CookieService → FollowRedirect → Decompression第 1031 行retry_policy、cookie_store、redirect_policy、referer、https_only、acceptsLayeredService,见 执行与 Pending
9. 装进 Arc<ClientRef>第 1058 行headers、read_timeout、timeout、https_onlyClient

几点要注意的:

  • TLS 配置在 build 时就定了。 第 5 步建好的 rustls::ClientConfig 放进 Arc,之后所有连接共用。会话恢复用的缓存也在这个配置里,所以同一个 Client 的后续连接可以恢复会话,新 Client 不行。
  • 连接池是第 7 步 hyper-util Client 的一部分。 它不在 ClientRef 的顶层,也不能在客户端之间共享;新 Client 就是新的空池。
  • build 不发任何网络请求。 hickory resolver 在第一次解析时才建;但第 2 步的系统代理是在 build 里同步读的,见下文。
  • 第 5 步里配置自相矛盾时 build 返回错误而不是 panic,比如协议版本筛完为空("empty supported tls versions"),只关主机名校验或加了 CRL 却没开 tls_certs_only。rustls-no-provider 下没有进程默认 provider 时则会 panic。

每个请求新建 Client 的代价

build 本身多快?200 次,每种配置在单独的进程里跑(first 是进程里的第一次),release,uptime 负载 6.7:

build builder()  (system proxy on)       first=    3.5ms n=200 min=   80.7µs median=  131.3µs mean=  152.9µs
build no_proxy()                         first=   32.7µs n=200 min=  541.0ns median=  625.0ns mean=  953.0ns
build no_proxy + add_root_certificate    first=  697.5µs n=200 min=    3.6µs median=    3.7µs mean=    7.4µs
build no_proxy + tls_certs_only          first=   32.0µs n=200 min=    2.0µs median=    2.0µs mean=    2.5µs

又跑了两遍,first 分别是 1.9 ms / 32.1 µs / 78.8 µs / 32.1 µs 和 1.9 ms / 18.8 µs / 68.9 µs / 36.4 µs,min 和 median 都在同一量级。读法:

  • 去掉系统代理,build 不到 1 µs。 拼 rustls 配置、hyper-util 客户端、四层 tower 都是分配几个结构体。
  • 默认的 builder() 每次要 80 到 140 µs,多出来的是 Matcher::from_system:读 ALL_PROXY、HTTP_PROXY、HTTPS_PROXY、NO_PROXY(大小写各一)这些环境变量,再在 macOS 上建一个 SCDynamicStore 查系统代理设置。这个结果不缓存,每个 Client 读一次。
  • 加根证书要几 µs(解析 PEM),走平台校验器时进程第一次还要多付几十到几百 µs。
❗IMPORTANT
动脑筋:环境变量里已经有 HTTPS_PROXY 了,hyper-util 还会去查 macOS 的系统设置吗?把六个代理环境变量都 env -u 掉,builder() 那一行会变快还是变慢?

build 便宜不等于新 Client 便宜。真正的代价在后面:新 Client 带着空的连接池,每个请求都要重新解析、建 TCP、做 TLS 握手。对本机测试服务器各发 200 个请求,对比「一个 Client 发 200 次」和「每次新建 Client」(单位是一个请求从开始到读完体,服务端计数是增量;先用一个临时客户端发一次,把 aws-lc-rs 的一次性初始化排除在外):

https h2 reuse      n=200 min=   75.7µs median=   91.9µs mean=  113.4µs  conns+1 tls+1
https h2 new/req    n=200 min=    2.0ms median=    2.5ms mean=    2.6ms  conns+200 tls+200
https h2   of which build() min=    6.2µs median=    8.8µs mean=    9.8µs
https h1 reuse      n=200 min=   73.1µs median=   92.3µs mean=  109.9µs  conns+1 tls+1
https h1 new/req    n=200 min=    2.0ms median=    2.5ms mean=    2.5ms  conns+200 tls+200
https h1   of which build() min=    6.1µs median=    8.0µs mean=    8.9µs
http  h1 reuse      n=200 min=   70.9µs median=   89.5µs mean=   97.2µs  conns+1 tls+0
http  h1 new/req    n=200 min=  248.2µs median=  372.4µs mean=  440.8µs  conns+200 tls+0
http  h1   of which build() min=  958.0ns median=    1.4µs mean=    1.7µs

客户端是 .no_proxy() 加 add_root_certificate(rustls 平台校验器);HTTPS 的 URL 是 https://localhost:port,HTTP 的是 http://127.0.0.1:port。共跑三遍,三遍的中位数都在 ±0.1 ms 内。读法:

  • 复用时每个请求约 0.09 ms,每次新建约 2.5 ms,差 27 倍。 服务端计数说明了原因:复用时 200 个请求 1 个连接 1 次握手,新建时 200 个连接 200 次握手。新 Client 的 build 只占其中 9 µs。
  • 明文 HTTP 也慢 4 倍(0.09 ms 对 0.37 ms),差的是每次一个 TCP 连接的建立和拆除。
  • HTTPS 多出来的约 2.1 ms 里,平台校验器占约 0.7 ms。 换成 tls_certs_only(webpki 只信那张 CA)再跑三遍,https h2 new/req 的中位数是 1.8 / 1.1 / 1.8 ms,https h1 是 1.6 / 1.6 / 1.7 ms,复用的那一行不变。平台校验器每次都把证书链交给 macOS 的信任评估,这是 0.13 默认配置的固定成本,复用连接时只付一次。
  • 每次新建还丢掉了 TLS 会话恢复:rustls 的会话缓存在 ClientConfig 里,新 Client 有新的 ClientConfig,所以 200 次都是完整握手。把 resolver 换成 getaddrinfo(no_hickory_dns())再测,新建那一行的中位数是 2.6 到 3.1 ms,和 hickory 时差不多,DNS 不是大头。

回环上一个往返只要几十微秒,所以 2 ms 几乎全是 CPU:握手的密码学和证书校验。真实网络上,每次新建连接还要再加 TCP 和 TLS 的往返,至少两个 RTT。reqwest 自己的 reqwest::get 就是每次 Client::builder().build()?,它的文档注释特意写了「This function creates a new internal Client on each call, and so should not be used if making many requests」。

动脑筋答案

几乎不变。清掉 HTTP_PROXY、HTTPS_PROXY、http_proxy、https_proxy、ALL_PROXY、all_proxy 后跑三遍:

build builder()  (system proxy on)       first=    1.9ms n=200 min=  104.0µs median=  142.2µs mean=  157.8µs
build builder()  (system proxy on)       first=    2.0ms n=200 min=   97.6µs median=  130.4µs mean=  140.1µs
build builder()  (system proxy on)       first=    1.9ms n=200 min=  104.2µs median=  143.6µs mean=  162.9µs

和设着环境变量时(min 80.7 到 108.9 µs)是同一量级。mac::with_system 不管环境变量有没有给值,都先建 SCDynamicStore、取整张代理字典,然后才在 if builder.http.is_empty() 里决定用不用;贵的是这两步,不是读环境变量。区别只在结果:环境变量在时它们优先,NO_PROXY 也在;清掉后代理地址来自系统设置,例外列表没有被读进来。no_proxy() 把 auto_sys_proxy 设成假、清空代理表,build 就整个跳过了 ProxyMatcher::system(),所以只要 625 ns。这一趟在「每个请求新建 Client」时每次都付,共享一个 Client 时只付一次。它也说明「系统代理」是 build 那一刻的快照:之后改了系统设置,已有的 Client 看不到,见 代理。

小结

  • Client 是 Arc<ClientRef>,8 字节,克隆约 4 ns;克隆共享同一个连接池和 tower 栈,不需要再包 Arc。
  • ClientRef 里真正干活的是 hyper(整个栈,池和连接器在最里面)和 execute_request 每次读的默认头、超时、代理表;其余字段只给 Debug 用。
  • build 的顺序:错误 → 代理表(读系统代理)→ resolver → HttpConnector → TLS 配置 → 连接器选项 → hyper-util 客户端和池 → 四层 tower → Arc。
  • build 不发网络请求;去掉系统代理不到 1 µs,默认要 80 到 140 µs(每次都读 macOS 系统代理)。
  • 每个请求新建 Client:本机 HTTPS 从约 0.09 ms 变成约 2.5 ms,服务端从 1 次握手变成 200 次;其中约 0.7 ms 是平台校验器,会话恢复也一并丢掉。

💬 关联讨论

发起讨论
加载中...