Rust in Everything
基于 reqwest 0.13.5

连接池与 HTTP 版本

主线位置:请求主线与连接主线的交点。请求主线走到 hyper-util legacy Client::request,先到池里找这个 host 的空闲连接,找不到才调连接主线(连接器)建一条;新连接的 ALPN 结果决定它是 HTTP/1.1 还是 HTTP/2,以及池子能不能让别的请求共用它。本页讲 reqwest 这一侧做了哪些选择,池子本身的实现见 hyper 卷的连接池。

flowchart TB
  req["hyper-util request"] --> race{"checkout 与 connect 赛跑"}
  race -- 池里有空闲 --> reuse["复用"]
  race -- 新建 --> lock{"ver 是 Http2"}
  lock -- 是 --> one["先拿 connecting 锁"]
  lock -- 否 --> conn["连接器建连接"]
  one --> conn
  conn --> alpn{"ALPN 是 h2"}
  alpn -- 否 --> h1["HTTP/1 独占"]
  alpn -- 是 --> up{"alpn_h2 补锁"}
  up -- 拿到 --> h2["HTTP/2 共享"]
  up -- 别人先拿到 --> drop["丢弃 等共享连接"]

reqwest 自己没有连接池。ClientBuilder::build 建一个 hyper-util 的 legacy Client,把几个配置项传过去,池子从此归 hyper-util 管;Client 克隆时共享同一个池,见一个 Client。下层版本:hyper 1.12.0、hyper-util 0.1.21、h2 0.4.20,和 hyper 卷锁的版本相同,那边的行号可以直接对照。唯一的例外是 HTTP/3:它不走 hyper-util,有一个 reqwest 自己写的池,放在最后一节。

实验平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5 默认 feature,release 构建。源站是本机的 hyper-util auto 服务器(同一端口既收 HTTP/1.1 也收 h2c),HTTPS 源站用 rcgen 自签证书、tokio-rustls 接 TLS,ALPN 列表可配。源站数三个数:接受的 TCP 连接(accepted)、完成的 TLS 握手(tls)、至少处理过一个请求的连接(served),并把连接编号写进响应体。客户端一律 .no_proxy()。实验 crate 在 exp-reqwest/connb-lab。

reqwest 给池配了什么

两项默认值在 ClientBuilder::new 里:

                pool_idle_timeout: Some(Duration::from_secs(90)),
                pool_max_idle_per_host: usize::MAX,

build 里原样交给 hyper-util 的构建器:

        builder.timer(hyper_util::rt::TokioTimer::new());
        builder.pool_timer(hyper_util::rt::TokioTimer::new());
        builder.pool_idle_timeout(config.pool_idle_timeout);
        builder.pool_max_idle_per_host(config.pool_max_idle_per_host);
  • pool_idle_timeout:空闲连接放多久就不要了,默认 90 秒,None 表示永不过期。
  • pool_max_idle_per_host:每个 host 最多留几条空闲连接,默认不限。它不限制同时在用的连接数,下面的实验会看到。
  • pool_timer:给池子一个定时器,池子才能起一个后台任务定期清理过期连接。没有它,过期连接只在下次 checkout 时才被发现。

这两个默认值和 hyper-util 自己的默认值完全一样(Builder::new 里也是 Some(Duration::from_secs(90)) 和 usize::MAX),reqwest 只是把它们写成自己的 Config 字段,好让 ClientBuilder 能改。池的键是目标的 scheme 加 authority,走代理时也是目标而不是代理(代理见代理)。

reqwest 没有转出来的 hyper-util 选项:同时在用的连接数上限(hyper-util 本来就没有)、retry_canceled_requests(保持默认 true)、set_host(保持默认 true)。需要限制并发连接,得在外面限制并发请求数,hyper 卷 legacy Client 一页讲了为什么 poll_ready 永远就绪。

空闲连接:复用、过期、上限

每次请求后读完响应体,连接回池(body 不读完会怎样见响应体)。程序 connb-lab/src/bin/idle.rs,源站把连接编号写进响应体:

let c = b().pool_idle_timeout(Duration::from_secs(1)).build().unwrap();
let first = conn_of(&c, &url).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let after_500ms = conn_of(&c, &url).await;
tokio::time::sleep(Duration::from_millis(2500)).await;
let after_2500ms = conn_of(&c, &url).await;

连跑 3 次,输出相同:

idle_timeout=1s : first=conn#1 +500ms=conn#1 +2500ms=conn#2
default (90s)   : first=conn#3 +2500ms=conn#3
max_idle=0      : sequential ["conn#4", "conn#5", "conn#6"]
idle_timeout=1s : no new request, server sees closed=0
idle_timeout=1s : no new request, server sees closed=1
idle_timeout=1s : no new request, server sees closed=1
...
  • 超时 1 秒:空闲 500 毫秒后复用了 conn#1;空闲 2.5 秒后它已过期,新建了 conn#2。默认 90 秒下,2.5 秒后照样复用。
  • pool_max_idle_per_host(0):一条空闲连接都不留,顺序发的 3 个请求用了 3 条连接,等于关掉了连接池。
  • 中间三行:超时 1 秒,发一个请求后不再发任何请求,在 0.5 秒、1.5 秒、2.5 秒时看服务端。0.5 秒时连接还在,1.5 秒时服务端已经看到它关了。关它的是 pool_timer 起的后台清理任务,不是下一次 checkout。
  • 最后两行(... 处)留给下面的动脑筋。
❗IMPORTANT
动脑筋:pool_max_idle_per_host(2),同一个 Client 先后发两波请求,每波 10 个并发,源站每个请求延迟 50 毫秒。两波结束后,源站一共接受了几条连接?平台:M2 Max、macOS 27,reqwest 0.13.5,明文 HTTP/1.1。

过期在哪两处检查、max_idle_per_host 满了怎么丢,见 hyper 卷连接池的 idle_timeout 和 max_idle_per_host 两节。

📝NOTE
冷知识:reqwest 有好几个版本的空闲超时只在下次取连接时生效

#2434「fix: pass pool_timer to hyper_util to enable the idle cleanup task」(baf9712,首次发布在 v0.12.8)。reqwest 0.12.0 换到 hyper 1 + hyper-util,从 0.12.0 到 0.12.7,build 里只调了 builder.timer(...),没调 builder.pool_timer(...)。hyper-util 的池子没有定时器就不起后台清理任务(spawn_idle_interval 拿不到 timer 就直接 return),过期的空闲连接一直挂着,直到下一个请求来取时才被丢掉;如果再也没有请求,连接就一直开着,pool_idle_timeout 形同虚设。这次的改动就是上面引用的 pool_timer 那一行,外加一个集成测试 close_connection_after_idle_timeout:超时设 1 秒,发一个请求,等 2 秒,断言服务端看到了 ConnectionClosed。上面「no new request」那三行就是同一件事在 0.13.5 上的复现。

HTTP 版本:HttpVersionPref

ClientBuilder 里只有一个字段决定 HTTP 版本,HttpVersionPref:

enum HttpVersionPref {
    Http1,
    #[cfg(feature = "http2")]
    Http2,
    #[cfg(feature = "http3")]
    Http3,
    All,
}

默认 All。http1_only 设成 Http1,http2_prior_knowledge 设成 Http2,http3_prior_knowledge 设成 Http3;后设的覆盖先设的。它在 build 里影响两处。

第一处是 TLS 的 ALPN 列表。rustls 分支:

                    // ALPN protocol
                    match config.http_version_pref {
                        HttpVersionPref::Http1 => {
                            tls.alpn_protocols = vec!["http/1.1".into()];
                        }
                        #[cfg(feature = "http2")]
                        HttpVersionPref::Http2 => {
                            tls.alpn_protocols = vec!["h2".into()];
                        }
                        #[cfg(feature = "http3")]
                        HttpVersionPref::Http3 => {
                            // h3 ALPN is not valid over TCP
                        }
                        HttpVersionPref::All => {
                            tls.alpn_protocols = vec![
                                #[cfg(feature = "http2")]
                                "h2".into(),
                                "http/1.1".into(),
                            ];
                        }
                    }

native-tls 分支做同样的事(request_alpns),但要 __native-tls-alpn 打开、并且没开 http3 时才编译进来;同时开 native-tls 和 http3 的构建,native-tls 连接不带 ALPN,只能 HTTP/1.1。TLS 配置的其余部分见 TLS 配置。

第二处是 hyper-util 构建器:

            if matches!(config.http_version_pref, HttpVersionPref::Http2) {
                builder.http2_only(true);
            }

http2_only(true) 把 hyper-util 的 Config::ver 从 Ver::Auto 改成 Ver::Http2:不管连接器报告什么,一律按 HTTP/2 握手。Ver::Auto 时按连接器在 Connected 里报告的 ALPN 结果:协商出 h2 用 HTTP/2,否则 HTTP/1.1。

合起来:

配置https://http://
默认(All)ALPN 给 h2、http/1.1,服务端选哪个用哪个HTTP/1.1(明文没有 ALPN,reqwest 不做 Upgrade: h2c)
http1_only()ALPN 只给 http/1.1HTTP/1.1
http2_prior_knowledge()ALPN 只给 h2,按 HTTP/2 握手直接发 HTTP/2 连接前言(h2c)

http2_prior_knowledge 碰上只说 HTTP/1.1 的 HTTPS 服务器,握手就失败了。服务端 ALPN 只给 http/1.1(connb-lab/src/bin/h2pk.rs):

error: error sending request for url (https://localhost:64641/) <- client error (Connect) <- received fatal alert: NoApplicationProtocol
server: accepted/served/requests = (1, 0, 0)

rustls 服务端找不到双方都支持的协议,回了 no_application_protocol 警报。

并发 100 个请求:几条连接

同一个 Client,同时发 100 个请求到同一个 host,源站每个请求延迟 50 毫秒再回,保证它们在时间上重叠(connb-lab/src/bin/conc.rs):

let futs = (0..100).map(|_| {
  let c = client.clone();
  let url = format!("{base}/?ms=50");
  async move {
    let r = c.get(url).send().await.unwrap();
    let v = r.version();
    r.bytes().await.unwrap();
    v
  }
});
let versions = join_all(futs).await;

每种配置换新的源站和新的 Client,各跑 3 轮。测量时 uptime 的负载在 13 到 17 之间(同机还有别的构建在跑),毫秒数只看量级:

http  default                        round 1: version=HTTP/1.1 accepted=100 tls=0   served=100 requests=100 77ms
http  default                        round 2: version=HTTP/1.1 accepted=100 tls=0   served=100 requests=100 90ms
http  default                        round 3: version=HTTP/1.1 accepted=100 tls=0   served=100 requests=100 91ms
http  http2_prior_knowledge          round 1: version=HTTP/2.0 accepted=1   tls=0   served=1   requests=100 58ms
http  http2_prior_knowledge          round 2: version=HTTP/2.0 accepted=1   tls=0   served=1   requests=100 55ms
http  http2_prior_knowledge          round 3: version=HTTP/2.0 accepted=1   tls=0   served=1   requests=100 60ms
https default (server h2,http/1.1)   round 1: version=HTTP/2.0 accepted=100 tls=100 served=1   requests=100 146ms
https default (server h2,http/1.1)   round 2: version=HTTP/2.0 accepted=100 tls=100 served=1   requests=100 118ms
https default (server h2,http/1.1)   round 3: version=HTTP/2.0 accepted=100 tls=100 served=1   requests=100 124ms
https http1_only                     round 1: version=HTTP/1.1 accepted=100 tls=100 served=100 requests=100 126ms
https http1_only                     round 2: version=HTTP/1.1 accepted=100 tls=100 served=100 requests=100 125ms
https http1_only                     round 3: version=HTTP/1.1 accepted=100 tls=100 served=100 requests=100 335ms
https http2_prior_knowledge          round 1: version=HTTP/2.0 accepted=1   tls=1   served=1   requests=100 65ms
https http2_prior_knowledge          round 2: version=HTTP/2.0 accepted=1   tls=1   served=1   requests=100 59ms
https http2_prior_knowledge          round 3: version=HTTP/2.0 accepted=1   tls=1   served=1   requests=100 58ms
https default (server http/1.1)      round 1: version=HTTP/1.1 accepted=100 tls=100 served=100 requests=100 228ms
https default (server http/1.1)      round 2: version=HTTP/1.1 accepted=100 tls=100 served=100 requests=100 315ms
https default (server http/1.1)      round 3: version=HTTP/1.1 accepted=100 tls=100 served=100 requests=100 204ms
https default, 1 warm-up request     round 1: version=HTTP/2.0 accepted=1   tls=1   served=1   requests=101 53ms
https default, 1 warm-up request     round 2: version=HTTP/2.0 accepted=1   tls=1   served=1   requests=101 53ms
https default, 1 warm-up request     round 3: version=HTTP/2.0 accepted=1   tls=1   served=1   requests=101 54ms

HTTP/1.1:100 条。 一条 HTTP/1 连接同一时刻只能有一个请求在飞,池里又没有空闲连接,每个请求都去新建。pool_max_idle_per_host 只管回池以后留几条,管不到这里。

HTTP/2 prior knowledge:1 条。 Ver::Http2 时,hyper-util 的 connect_to 在建连接之前先调 Pool::connecting 拿「正在建连接」的锁,同一个 key 只有一个请求拿得到;其余 99 个的 connect_to 立即以 Canceled 结束,转而等 checkout,第一条连接建好后各分到一份 SendRequest 的克隆。明文和 TLS 都一样。

默认配置 + 支持 h2 的 HTTPS 服务器:100 次 TCP 连接、100 次 TLS 握手,只有 1 条连接干活。 默认是 Ver::Auto,建连接之前不知道会不会是 h2,connect_to 拿锁这一步对它不生效,100 个请求各自开始连接。每条连接都要做完 TLS 握手才知道 ALPN 结果,然后才检查:

                        // If ALPN is h2 and we aren't http2_only already,
                        // then we need to convert our pool checkout into
                        // a single HTTP2 one.
                        let connecting = if connected.alpn == Alpn::H2 && !is_ver_h2 {
                            match connecting.alpn_h2(&pool) {
                                Some(lock) => {
                                    trace!("ALPN negotiated h2, updating pool");
                                    lock
                                }
                                None => {
                                    // Another connection has already upgraded,
                                    // the pool checkout should finish up for us.
                                    let canceled = e!(Canceled, "ALPN upgraded to HTTP/2");
                                    return Either::Right(future::err(canceled));
                                }
                            }

第一个握手完的拿到锁,成为共享的 HTTP/2 连接;后面 99 条握手完发现锁已被拿走,以 Canceled 结束,连接被丢掉,它们的请求改走那条共享连接。最终 requests=100 全由一条连接处理,served=1。客户端多付了 99 次 TCP 建连和 99 次完整的 TLS 握手,服务端也陪着做了;总耗时比 prior knowledge 慢一倍左右。这条锁的来龙去脉,hyper 卷连接池的 HTTP/2 一节讲过,这里看到的是它落在 reqwest 默认配置上的代价。

先发一个请求再并发:1 条。 最后一组先单独发一个请求,池里有了一条已知是 h2 的连接,后面 100 个请求 checkout 直接拿到它的克隆,一条新连接都不开(requests=101 包括预热那一个)。对一个已知支持 HTTP/2 的上游,突发流量之前先预热一个请求,就能省掉这批白做的握手。

服务端只给 http/1.1 时,默认配置和 http1_only 一样,100 条连接。

📝NOTE
没有蠢问题

问: 既然 prior knowledge 只开 1 条连接,是不是该总是打开 http2_prior_knowledge?

答: 只有确定对端说 HTTP/2 时才行。它把 ALPN 改成只给 h2,对只说 HTTP/1.1 的 HTTPS 服务器握手直接失败(上面的 NoApplicationProtocol);对明文服务器则直接发 HTTP/2 连接前言,服务器不支持 h2c 就连不上。它对 Client 里的所有 host 都生效,不能按 host 区分。另外,一条 HTTP/2 连接上能同时跑多少个请求,由服务端的 SETTINGS_MAX_CONCURRENT_STREAMS 决定,超出的请求在 h2 里排队;0.13.5 的 ClientBuilder 没有调这个的选项,http2_max_concurrent_streams 是 tag 之后才加的(2230ed3,#3105,2026-10-02,还没有发布)。

HTTP/3 的池(只读源码)

http3 feature 是实验性的:lib.rs 里有一条 compile_error!,没设 RUSTFLAGS='--cfg reqwest_unstable' 就编译失败。本节没有跑代码。

HTTP/3 跑在 QUIC(UDP)上,hyper-util 的池用不上,reqwest 在 async_impl/h3_client/ 里自己写了一个。H3Client 是独立的 tower Service,和 hyper-util 的 Client 并排挂在 ClientRef 上,选哪个由请求的版本决定(execute_request):

        let in_flight = match version {
            #[cfg(feature = "http3")]
            http::Version::HTTP_3 if self.inner.h3_client.is_some() => {
                // ...
                ResponseFuture::H3(h3.call(req))
            }
            _ => {
                // ...
                ResponseFuture::Default(hyper.call(req))
            }
        };

http3_prior_knowledge() 不会自动把请求改成 HTTP/3:Request 的版本默认是 HTTP/1.1,要在请求上 .version(Version::HTTP_3),reqwest 自己的 tests/http3.rs 每个测试都这么写。http3_prior_knowledge() 的作用只有两个:TCP 那边的 rustls 配置不设 ALPN(「h3 ALPN is not valid over TCP」);QUIC 连接器建不起来时,build 报错,而不是像默认那样不要 HTTP/3、照常返回 Client。QUIC 那边的 TLS 配置只要开了 http3 feature 就总是 ALPN h3,和这个开关无关。

Pool 是 Arc<Mutex<PoolInner>>,PoolInner 两张表:connecting: HashMap<Key, watch::Sender<Option<PoolClient>>> 和 idle_conns: HashMap<Key, PoolConnection>,键是 (Scheme, Authority)。和 hyper-util 的池对照:

  • 每个 host 一条连接。 HTTP/3 和 HTTP/2 一样多路复用,idle_conns 一个 key 只放一条,没有空闲连接列表,也不看 pool_max_idle_per_host。
  • 建连接的锁。 get_pooled_client 先 try_pool,没有再调 connecting:拿到 Acquired(ConnectingLock) 的去建连接,拿到 InProgress(ConnectingWaiter) 的订阅 watch 通道等结果。new_connection 建好后把客户端广播给所有等待者,再放进 idle_conns。ConnectingLock 的 Drop 删掉 connecting 里的条目,建连接失败时等待者收到 None,报「failed to establish connection for HTTP/3 request」。HTTP/3 是事先就知道的协议,没有 ALPN 那种「建完才知道能不能共享」的问题,不会白建连接。
  • 失效检查。 new_connection 起一个任务等 h3 驱动的 poll_close,结果从一个 std::sync::mpsc 通道送回;try_pool 时 is_invalid 用 try_recv 看一眼,连接已关就从表里删掉。
  • 空闲超时用的是 pool_idle_timeout,从「最近一次取用」算起。 PoolConnection::pool 每次被取用时把 idle_timeout(这个字段存的是时间点)重置为现在,try_pool 比较的是距那一刻过了多久。没有后台清理任务,过期只在下次取用时发现;正在跑长请求的连接,从取出那一刻起照样计时。QUIC 层另有自己的空闲超时(http3_max_idle_timeout)。
📝NOTE
冷知识:HTTP/3 的建连接锁,一开始只有「锁」没有「等」

#2544「http3: fix syncronization of connecting process」(b63234d,首次发布在 v0.12.13)。PR 描述列了两个问题:第一次连接失败后锁没有释放,之后对这个 host 的所有请求都报 HTTP/3 connecting already in progress;连接建立期间,其他并发请求不等,直接拿到同样的错误。描述里说修法「more or less the same as in hyper-util」:加了 ConnectingLock,Drop 时释放;加了 ConnectingWaiter,正在建连接时其他请求订阅等待。也就是上面 Connecting::InProgress / Acquired 那一对。

tag 之后,仓库里有一个未合并的 pool-ng 分支,最新提交是「wip: use pool-ng」(0ea9235,2025-09-30),改动集中在 async_impl/client.rs。0.13.5 没有它,本卷不跟。

动脑筋答案

max_idle=2      : after wave 1 (10 concurrent) accepted=10 requests=10
max_idle=2      : after wave 2 (10 concurrent) accepted=18 requests=20

18 条。第一波 10 个请求同时在飞,池里没有空闲连接,开了 10 条(pool_max_idle_per_host 不限制在用的连接)。它们读完响应体陆续回池,池里留满 2 条后,后面 8 条回来时被丢弃、关闭。第二波 10 个并发,2 个拿到池里的空闲连接,另外 8 个新建:10 + 8 = 18。三次运行结果相同。

小结

  • reqwest 没有自己的 HTTP/1、HTTP/2 连接池,只给 hyper-util 的池传 pool_idle_timeout(默认 90 秒)、pool_max_idle_per_host(默认不限)和一个 pool_timer;默认值与 hyper-util 相同。
  • pool_timer 让过期连接被后台任务关掉,不必等下一个请求;它在 v0.12.8 才补上。
  • pool_max_idle_per_host 只限制空闲连接,不限制并发连接。
  • HTTP 版本由 HttpVersionPref 决定:默认靠 ALPN,明文只有 HTTP/1.1;http2_prior_knowledge 只给 h2 且 http2_only(true)。
  • 100 个并发请求:HTTP/1.1 开 100 条;prior knowledge 开 1 条;默认 ALPN 下做了 100 次 TLS 握手、只留 1 条。先预热一个请求可以避免。
  • HTTP/3 有 reqwest 自己的池:每 host 一条连接,建连接时有锁有等待,空闲超时从最近一次取用算起,只在请求的版本是 HTTP_3 时使用。

💬 关联讨论

发起讨论
加载中...