client::pool:四块积木
主线位置:客户端主线上「拿连接」那一步的另一种做法。legacy
Client的连接池是写死在Client里的一整块;client::pool把它拆成几个tower::Service,由使用者自己拼:按主机分组、HTTP/2 共享一条、HTTP/1 缓存多条、按协商结果分流。legacyClient和 reqwest 0.13.5 都不用它。
flowchart TB req["请求:uri"] --> map["Map:key = (scheme, authority)"] map --> neg["Negotiate:每个 key 一个"] neg -->|"先试(Eager)"| up["upgrade:Singleton"] neg -->|"upgrade 没有现成的"| fb["fallback:Cache"] fb --> h1["H1 握手 + Inspector"] h1 --> conn["连接器:HttpConnector / TLS"] h1 -->|"inspect 判定是 h2:放进 slot"| slot["slot"] slot --> up2["upgrade:Singleton 取 slot,H2 握手"] up --> svc["Negotiated:Fallback(Cached) 或 Upgraded(Singled)"] fb --> svc up2 --> svc
client::pool 在 0.1.19(2025-12-03)加入,feature 是 client-pool(Cargo.toml 第 82 行),在 full 里,不需要 unstable cfg。pool/mod.rs 的模块文档只有一句定位:「various concepts of a connection pool separated into their own concerns」。四个文件共 2 572 行,去掉测试约 1 860 行。没有 example,也没有集成测试;本页最后用它们拼一个能跑的池。
四块积木的分工:
| 积木 | 回答的问题 | 本身是 Service 吗 |
|---|---|---|
singleton | 一条可克隆的连接(HTTP/2),怎么让并发的调用只建一次 | 是,Service<Target> |
cache | 一次只能给一个人用的连接(HTTP/1),用完怎么放回去给下一个人 | 是 |
negotiate | 建好连接才知道是 h1 还是 h2,怎么分流 | 是 |
map | 按什么分组,每组一个池 | 不是 |
它们的「连接器」都是 tower 意义上的 make service:Service<Target>,响应是另一个 Service<Request>。hyper::client::conn 的 SendRequest 本身不是 tower 的 Service,用的时候要自己包一层,见本页最后的实验。
singleton:只建一次
Singleton 包一个 make service,状态只有三种:
pub enum State<F, S> {
Empty,
Making(Batch<F, S>),
Made(S),
}
call 按状态分三路:Empty 时调用内层建连接,自己登记为 driver;Making 时登记为 waiter;Made 时直接返回一个克隆(第 132 行):
State::Made(ref svc) => SingletonFuture::Made {
svc: Some(svc.clone()),
state: Arc::downgrade(&self.state),
},
Made 这一路不看 dst:建好以后,不管传进来的目标是什么,都返回同一条连接的克隆。一个 Singleton 只适合一个目标,按主机分要在外面套 Map。
并发的调用者里,只有 driver 真正 poll 建连接的 future,waiter 只登记 waker 和一个 oneshot。driver 拿到结果后 send_result 发给所有 waiter,出错时所有人收到同一个 Arc 包着的错误(0.1.21 的 #296;之前 waiter 只知道「被取消了」)。错误类型 SingletonError 的 Display 只有 singleton connection error,原因在 source() 里。
driver 被 drop 时(比如它所在的请求超时被取消),Batch::remove 把 driver 的身份交给一个 waiter 并唤醒它,建连接的 future 不丢(0.1.21 的 #299):
if self.driver.as_ref().is_some_and(|driver| driver.id == id) {
if let Some(waiter) = self.waiters.pop() {
let waker = waiter.waker;
self.driver = Some(Driver {
id: waiter.id,
waker,
});
self.wake_driver();
return false;
}
self.driver = None;
self.future = None;
return true;
}
sequenceDiagram participant A as 调用者 A(driver) participant B as 调用者 B(waiter) participant C as 调用者 C(waiter) participant S as Singleton 状态 A->>S: call:Empty → Making,A 是 driver B->>S: call:Making,登记 waiter C->>S: call:Making,登记 waiter A->>S: poll:推进建连接的 future Note over A: A 被取消(drop) A->>S: Drop:remove(A),pop 出 C 当 driver,唤醒 C C->>S: poll:接着推进同一个 future S-->>C: Ok(svc),State = Made S-->>B: oneshot 发 Ok(svc 的克隆)
实测:make service 每次要 100ms,A、B、C 三个调用,A poll 30ms 后被 drop:
== Singleton: the driver is dropped halfway
b ok=true c ok=true makes=1
B、C 都拿到了连接,内层只建了一次。
缓存什么时候清空:只有 Singled::poll_ready 返回错误时,才把状态改回 Empty;外部还可以用 retain 按条件清。拿到连接后不调 poll_ready 直接 call,死掉的连接会一直留在 Made 里。
坑:singleton participant polled after making was canceled
SingletonFuture 的 Drop 不管自己已经完成与否,只要当前状态是 Making,就拿自己的 id 去 remove:
impl<F, S> Drop for SingletonFuture<F, S> {
fn drop(&mut self) {
if let SingletonFuture::Participating { id, state, .. } = self {
if let Ok(mut locked) = state.lock() {
if let State::Making(batch) = &mut *locked {
if batch.remove(*id) {
*locked = State::Empty;
}
}
}
}
}
}
而 id 是每个 Batch 从 0 开始数的(Batch::new 里 next_id: WaiterId(0)),Drop 也不检查此刻的 Making 是不是自己登记过的那一批。于是:一次建连接失败,状态回到 Empty;有人再 call,开始第二批,driver 的 id 又是 0;这时第一批里已经完成、但还没被 drop 的那个 future(id 也是 0)被 drop,remove(0) 把第二批的 driver 当成自己删掉,状态被改成 Empty。第二批真正的 driver 下次被 poll 时,走到 第 288 行:
State::Empty => {
unreachable!("singleton participant polled after making was canceled")
}
最小复现,内层第一次立即失败、之后 50ms 后成功:
let mut s = Singleton::new(Maker(Arc::new(AtomicUsize::new(0))));
let mut a = s.call(());
let ra = (&mut a).await; // a finishes with an error, but is not dropped yet
println!("a: {:?}", ra.map(|_| ()).map_err(|e| e.to_string()));
let b = s.call(()); // starts a new batch
drop(a); // drop the finished participant of the old batch
let rb = tokio::spawn(async move { b.await.map(|_| ()).map_err(|e| e.to_string()) }).await;
println!("b: {rb:?}");
let rc = tokio::spawn(async move { s.call(()).await.map(|_| ()).map_err(|e| e.to_string()) }).await;
println!("c: {rc:?}");
a: Err("singleton connection error")
thread 'tokio-rt-worker' (2127285) panicked at .../hyper-util-0.1.21/src/client/pool/singleton.rs:288:29:
internal error: entered unreachable code: singleton participant polled after making was canceled
b: Err(JoinError::Panic(Id(13), "internal error: entered unreachable code: singleton participant polled after making was canceled", ...))
thread 'tokio-rt-worker' (2127285) panicked at .../hyper-util-0.1.21/src/client/pool/singleton.rs:111:44:
called `Result::unwrap()` on an `Err` value: PoisonError { .. }
c: Err(JoinError::Panic(Id(14), "called `Result::unwrap()` on an `Err` value: PoisonError { .. }", ...))
panic 发生在持有 state 锁的时候,Mutex 被毒化。之后这个 Singleton(和它的所有克隆)上的每一次 call 都在 lock().unwrap() 处 panic,池子永久坏掉。
单独用 Singleton 时,「完成的 future 不马上 drop」不太常见;但下一节的 Negotiate 恰好就这么做:Eager 状态下 Singleton 的 future 返回错误后,它要等切到 Fallback 时才被 drop,而这时别的请求可能已经开始了新的一批。本页最后的组合实验里,并发请求每次都触发了它。这段代码在 0.1.21 之后的上游提交里没有改动(截至本页写作时)。
cache:一条列表
Cache 给「一次只能一个人用」的连接用,比如 HTTP/1 的 SendRequest。共享状态 Shared 只有一个 Vec<S>、一个等待队列和一张预留表:
pub struct Shared<S> {
services: Vec<S>,
waiters: VecDeque<Waiter>,
reservations: Vec<(WaiterId, S)>,
next_waiter: usize,
}
和 legacy 池相比:不分键(一个 Cache 只管一个目标)、没有空闲超时、没有数量上限、没有后台清理。取的时候 take_available 是 pop,后进先出,并且只在没有人排队时才能直接拿;放的时候 put 先看队头有没有人在等,有就预留给它并唤醒,按 FIFO 交付(0.1.21 的 #298)。
call 缓存里有就直接给;没有就新建一条,同时排进等待队列,谁先到用谁,和 legacy 池的赛跑一样。区别在赛跑输掉的那一方:如果是归还的连接先到,第 357 行 把还在建的连接交给 on_race_lost:
if let Some(pool_got) = locked.take_reserved(*waiter) {
events.on_race_lost(BackgroundConnect {
future: future.take().expect("racing future polled after done"),
shared: Arc::downgrade(&shared),
});
return Poll::Ready(Ok(Cached::new(pool_got, Arc::downgrade(&shared))));
}
builder() 默认的事件处理是 events::Ignore,on_race_lost 什么也不做,建了一半的连接随 future 一起被 drop;只有 builder().executor(e) 时才把它 spawn 出去,建完放回缓存。legacy 池总是 spawn。实测:连接器每次要 100ms,第一个调用拿到连接后,第二个调用开始建新连接,20ms 后第一个连接被归还:
== Cache, race lost, builder().executor: false
first: conn #1
second got conn #1
connections finished by the connector: 1
== Cache, race lost, builder().executor: true
first: conn #1
second got conn #1
connections finished by the connector: 2
不给执行器时,第二条连接没有建完。如果连接器已经完成了 TCP 握手、正在做 TLS,这一半的工作就白做了,对端会看到一个刚连上就断开的连接。
Cache::call 返回的是 Cached<S>,drop 时放回缓存,除非它的 poll_ready 报过错(is_closed,Drop for Cached)。和 singleton 一样,判断连接死活只靠 poll_ready:Cache::poll_ready 从列表里拿到一条就算就绪,不检查它还活着没有;拿到的人调 Cached::poll_ready 才发现它已经关了,这次就失败了,Cache 不会替你换一条。HTTP/1 的连接还要注意:Cached 在响应 body 读完前被 drop,连接就带着没读完的 body 回到列表里,下一个人的 poll_ready 要等它读完。
negotiate:按连接的结果分流
negotiate 的模块文档:「It differs from typical routing since it doesn't depend on the request, but the response.」它要解决的是 ALPN:TLS 握手完成才知道对端选了 h2 还是 http/1.1,而这时连接已经建好了。
builder() 要四样东西:连接器 connect、判定函数 inspect、两个 Layer(fallback、upgrade)。build 里两个 layer 包的不是同一个东西:
fallback包的是Inspector { svc: connect, inspect, slot }:调连接器,inspect返回真时把连接放进共享的slot,自己返回一个哨兵错误UseOther(InspectFuture::poll)。upgrade包的是Inspected { slot }:一个Service<()>,call时从slot里取连接,取不到就返回UseOther。
所以 upgrade 一侧自己从不建连接,它只接收 fallback 一侧建好、判定为 h2 的连接。Negotiating::poll 是三个状态:
// States:
// - `Eager`: try the "right" path first; on `UseOther` sentinel, fall back to left.
// - `Fallback`: try the left path; on `UseOther` sentinel, upgrade back to right.
// - `Upgrade`: retry the right path after a fallback.
// If all fail, give up.
stateDiagram-v2 [*] --> Eager: call(dst) Eager --> [*]: upgrade 侧给出连接(已有 h2) Eager --> Fallback: UseOther(upgrade 侧没有现成的) Fallback --> [*]: 连接器结果 inspect 为假,走 h1 Fallback --> Upgrade: UseOther(inspect 为真,连接进 slot) Upgrade --> [*]: upgrade 侧从 slot 取连接,h2 握手
UseOther::is 沿 source() 链找哨兵,所以外面套的 layer(比如 Singleton,错误包在 SingletonError 里)不影响识别。
每次 call 都先走 Eager,也就是每个 HTTP/1 请求都会先去 upgrade 一侧问一次、拿到一个 UseOther。upgrade 一侧是 Singleton 时,这意味着每个 HTTP/1 请求都让 Singleton 经历一次「建连接失败」,正好是上一节那个 panic 的前提。
map:不是 Service
Map 就是一个 HashMap<Key, Service> 加两个闭包:keys 从请求算出键,values 在键第一次出现时造一个服务。模块文档解释了为什么它不实现 Service:路由前不知道该检查哪个内层服务的背压。对外只有 service(&req)(取或建,返回 &mut)、retain 和 clear:
pub fn service(&mut self, req: &Req) -> &mut T::Service {
let key = self.targeter.key(req);
self.map
.entry(key)
.or_insert_with(|| self.targeter.service(req))
}
不会自己淘汰任何键,访问过的主机越来越多,Map 就越来越大。&mut self 也意味着多个任务共用时要自己加锁。
拼一个池
按开头的图拼:Map 按 (scheme, authority) 分,每个键一个 Negotiate,upgrade 一侧是 Singleton 包 h2 握手,fallback 一侧是 Cache 包 h1 握手。本机没有 TLS,ALPN 用对端端口代替:连到 h2 服务端(只说 HTTP/2 的 hyper 服务端)的连接算「协商出了 h2」。
hyper = { version = "1", features = ["full"] } # 1.12.0
hyper-util = { version = "=0.1.21", features = ["full"] }
tokio = { version = "=1.54.1", features = ["full"] }
tower = { version = "=0.5.3", features = ["full"] }
http-body-util = "0.1"
bytes = "1"
http = "1"
SendRequest 要包成 tower 的 Service,握手也要包成 make service(h1 的 H1Make 与此相同,换成 http1::handshake,返回不可克隆的 H1Svc):
#[derive(Clone)]
struct H2Svc(http2::SendRequest<Empty<Bytes>>);
impl Service<Req> for H2Svc {
type Response = Response<Incoming>;
type Error = hyper::Error;
type Future = Pin<Box<dyn Future<Output = Result<Response<Incoming>, hyper::Error>> + Send>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), hyper::Error>> { self.0.poll_ready(cx) }
fn call(&mut self, req: Req) -> Self::Future { Box::pin(self.0.send_request(req)) }
}
#[derive(Clone)]
struct H2Make<S>(S);
impl<S, D> Service<D> for H2Make<S>
where
S: Service<D, Response = TokioIo<TcpStream>>,
S::Error: Into<BoxError>,
S::Future: Send + 'static,
{
type Response = H2Svc;
type Error = BoxError;
type Future = BoxFut<H2Svc>;
fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), BoxError>> { Poll::Ready(Ok(())) }
fn call(&mut self, dst: D) -> Self::Future {
let fut = self.0.call(dst);
Box::pin(async move {
let io = fut.await.map_err(Into::into)?;
let (tx, conn) = http2::handshake(TokioExecutor::new(), io).await?;
tokio::spawn(conn);
Ok(H2Svc(tx))
})
}
}
组装和发请求:
// stand-in for ALPN: the connection "negotiated h2" if it goes to the h2 server
let make_pool = move |_dst: &Uri| {
negotiate::builder()
.connect(HttpConnector::new())
.inspect(move |io: &TokioIo<TcpStream>| io.inner().peer_addr().map(|a| a.port() == h2_port).unwrap_or(false))
.fallback(layer_fn(|s| cache::builder().build(H1Make(s))))
.upgrade(layer_fn(|s| Singleton::new(H2Make(s))))
.build::<Uri>()
};
let map = Arc::new(Mutex::new(
Map::builder::<Uri>()
.keys(|uri: &Uri| (uri.scheme().cloned(), uri.authority().cloned()))
.values(make_pool)
.build(),
));
let send = |uri: Uri| {
let map = map.clone();
async move {
let mut pool = map.lock().unwrap().service(&uri).clone();
let mut conn = pool.ready().await?.call(uri.clone()).await?;
let kind = if conn.upgraded_ref().is_some() { "upgraded(h2)" } else { "fallback(h1)" };
let req = Request::get(uri).body(Empty::new()).unwrap();
let resp = conn.ready().await?.call(req).await?;
let v = resp.version();
resp.into_body().collect().await?;
Ok::<_, BoxError>(format!("{kind} {v:?}"))
}
};
两个服务端各数自己 accept 了几次,每个响应等 20ms。先一次一个请求,各发 3 个;再换一个新键(用 localhost 代替 127.0.0.1),8 个请求同时发,每个套 2 秒超时。跑了 3 次:
-- run 1
h1 sequential #1 -> Ok("fallback(h1) HTTP/1.1"), server accepts: 1
h1 sequential #2 -> Ok("fallback(h1) HTTP/1.1"), server accepts: 1
h1 sequential #3 -> Ok("fallback(h1) HTTP/1.1"), server accepts: 1
h2 sequential #1 -> Ok("upgraded(h2) HTTP/2.0"), server accepts: 1
h2 sequential #2 -> Ok("upgraded(h2) HTTP/2.0"), server accepts: 1
h2 sequential #3 -> Ok("upgraded(h2) HTTP/2.0"), server accepts: 1
h2 8 concurrent -> {"panic": 8}, new accepts: 6
h1 8 concurrent -> {"fallback(h1) HTTP/1.1": 4, "panic": 4}, new accepts: 4
-- run 2
h2 8 concurrent -> {"panic": 8}, new accepts: 3
h1 8 concurrent -> {"fallback(h1) HTTP/1.1": 8}, new accepts: 8
-- run 3
h2 8 concurrent -> {"panic": 8}, new accepts: 3
h1 8 concurrent -> {"fallback(h1) HTTP/1.1": 6, "panic": 2}, new accepts: 6
(第 2、3 次的串行部分和第 1 次相同,略去。)
- 串行时一切如设计:h1 的三个请求复用一条连接(
Cache),h2 的三个请求共用一条连接(Singleton),第一个 h2 请求走了Eager → Fallback → Upgrade全程。 - 并发时,h2 的 8 个请求全部 panic,panic 信息就是上一节的
singleton participant polled after making was canceled,随后是PoisonError。h1 的 8 个请求也有一部分 panic:它们同样每次先经过Singleton。h1 全部成功的那次,8 个请求建了 8 条连接,和 legacy 池冷启动时一样,Cache没有并发上限。 - h2 一侧在 panic 之前已经建了 3 到 6 条 TCP 连接。即使没有 panic,
slot只有一格:多个 fallback 同时判定为 h2,后放进去的会覆盖先放的(被覆盖的连接直接 drop),冷启动时并发请求仍会多建连接,最后只有一条被Singleton用上。这一点是读源码得出的,上面的数字因为 panic 混在一起,不能单独证明。
在第一版组合实验里,没有给请求套超时,3 次运行中有 1 次没有 panic 而是一直挂住,没有进一步定位。Batch::remove 删错的也可能是新一批里的某个 waiter:它的 oneshot 被 drop,它回头去 poll 时不是 driver,只登记 waker,而它已经不在 waiter 列表里,不会再被唤醒。这是从代码推断的,没有单独复现。
结论:0.1.21 的 client::pool 用串行流量验证组合方式没问题,但 Negotiate + Singleton 在并发下不能用。如果要在生产里用这些积木,至少不要让 Singleton 经历「建连接失败后马上有人重试」:比如 upgrade 一侧不用 Singleton,或者在外面先判断 slot / 已有 h2 连接再决定走哪边。
和 legacy 池对照
legacy Pool(连接池) | client::pool | |
|---|---|---|
| 分组 | 内置,(Scheme, Authority) | Map,键自己定,不会淘汰 |
| HTTP/1 空闲连接 | 每个键一个 Vec,后进先出 | Cache,一条 Vec,后进先出 |
| 空闲超时 / 上限 | pool_idle_timeout、max_idle_per_host,后台清理任务 | 没有 |
| HTTP/2 | 同一个池,connecting 锁保证一个键只建一条 | Singleton,Made 后共享克隆 |
| 判断连接死活 | is_open():is_ready() 且没被 poison | 只看 poll_ready 是否报错 |
| 赛跑输掉的新连接 | 总是 spawn,建完进池 | 默认丢掉,给了 executor 才 spawn |
| h1 / h2 分流 | 连接器的 Connected::negotiated_h2() | Negotiate 的 inspect |
| 持锁时 panic | 本页未核实 | Singleton 的 unreachable! 在持锁时 panic |
两边都大量用 Mutex::lock().unwrap()(legacy 池只在 Drop 一类的路径上用 if let Ok),锁一旦被毒化,后来的调用者都会 panic。差别在于 client::pool 自己就有一条会在持锁时 panic 的路径,也就是上面的 unreachable!。
小结
client::pool(0.1.19 起,featureclient-pool,在full里)把连接池拆成四个 tower 积木:Singleton共享一条可克隆的连接,Cache缓存一次一用的连接,Negotiate按建好的连接分流,Map按键分组。legacyClient和 reqwest 都不用它。Singleton建好后不看目标,返回同一个克隆;driver 被取消时把接力棒交给 waiter;错误共享给所有 waiter。Cache没有空闲超时和上限,赛跑输掉的新连接默认被丢掉,要builder().executor(..)才建完放回。Negotiate每次先问 upgrade 一侧,失败用哨兵错误UseOther切到 fallback,fallback 判定为 h2 时把连接经slot交回 upgrade 一侧。- 实测:串行时组合池工作正常,h2 只建一条连接;并发时
Singleton因为跨批次的 id 冲突走到unreachable!而 panic,锁被毒化,池子永久不可用。0.1.21 的这几块积木还不适合直接上生产。