连接池
主线位置:客户端主线的第二步。legacy
Client从请求的 URI 取出 pool key 以后,先到这里要连接:池里有空闲的就直接用;没有就一边等池子、一边新建,谁先到用谁。响应 body 读完,连接再回到这里,等下一个请求。
flowchart TB
req["try_send_request"] --> co{"checkout:池里有空闲的?"}
co -- 有 --> reuse["直接用,不新建"]
co -- 没有 --> park["登记 waiter"]
park --> race{"select:谁先到"}
newconn["connect_to:新建连接"] --> race
race -- 池子先到 --> bg["新连接后台建完,进池"]
race -- 新连接先到 --> use2["用新连接"]
reuse --> send["发请求,等响应和 body"]
use2 --> send
send --> put["Pooled drop:put"]
put --> w{"有 waiter?"}
w -- 有 --> hand["直接交给 waiter"]
w -- 没有 --> idle["进空闲列表"]
idle --> ev["过期:取出时或后台清理"]
本页只讲 client/legacy/pool.rs 和 Client 里直接用到它的几段。hyper-util 0.1.21 还有一个新的 client::pool 模块(singleton、cache 等积木),legacy Client 不用它,见 client::pool:四块积木,那一页最后有两者的逐项对照。
数据结构
Pool 只是一个 Option<Arc<Mutex<PoolInner>>>,可以随意 Clone。Pool::new 在 Config::is_enabled 为假(max_idle_per_host == 0)时直接放 None,池子整个关掉。
PoolInner 的字段:
| 字段 | 类型 | 用途 |
|---|---|---|
idle | HashMap<K, Vec<Idle<T>>> | 每个 key 一个空闲列表,Idle 记下连接和进池时刻 idle_at |
waiters | HashMap<K, VecDeque<oneshot::Sender<T>>> | 正在等这个 key 的空闲连接的请求 |
connecting | HashSet<K> | HTTP/2 的「正在建连」锁,见本页最后一节 |
max_idle_per_host | usize | 每个 key 最多留几条空闲连接 |
timeout | Option<Duration> | pool_idle_timeout |
timer | Option<Timer> | pool_timer,后台清理任务要用 |
idle_interval_ref | Option<oneshot::Sender<Infallible>> | 后台清理任务的「池子还在」信号,见下文 |
exec | Exec | 用来 spawn 后台清理任务 |
key 在 Client 里是 PoolKey,即 (http::uri::Scheme, http::uri::Authority)。同一个主机的 http 和 https 是两组;路径、查询串不参与。
池子里放的东西要实现 Poolable:
pub trait Poolable: Unpin + Send + Sized + 'static {
fn is_open(&self) -> bool;
/// Reserve this connection.
///
/// Allows for HTTP/2 to return a shared reservation.
fn reserve(self) -> Reservation<Self>;
fn can_share(&self) -> bool;
}
Client 放进池子的是 PoolClient:一个 SendRequest(HTTP/1 或 HTTP/2)加上连接器给的 Connected 信息。它的 is_open 是 !self.is_poisoned() && self.is_ready()。HTTP/1 的 is_ready 最终是 hyper SendRequest::is_ready,也就是 want::Giver::is_wanting:后台连接任务正在等下一个请求时为真,连接任务结束(对端关闭、出错)后为假。这套信号怎么来的见 连接级客户端。is_poisoned 是调用方可以通过 Connected::poison 打上的标记,打上后这条连接不再复用。
从池子里拿出去的连接包在 Pooled 里。它持有一个指向池子的 Weak,归还靠 Drop,不需要显式调用:
impl<T: Poolable, K: Key> Drop for Pooled<T, K> {
fn drop(&mut self) {
if let Some(value) = self.value.take() {
if !value.is_open() {
// If we *already* know the connection is done here,
// it shouldn't be re-inserted back into the pool.
return;
}
if let Some(pool) = self.pool.upgrade() {
if let Ok(mut inner) = pool.lock() {
inner.put(self.key.clone(), value, &pool);
}
} else if !value.can_share() {
trace!("pool dropped, dropping pooled ({:?})", self.key);
}
// Ver::Http2 is already in the Pool (or dead), so we wouldn't
// have an actual reference to the Pool.
}
}
}
用 Weak 是为了让 Client 全部 drop 之后池子能真正释放:在途的 Pooled 发现池子没了,就直接丢掉连接。
什么时候回到池里
Pooled 一 drop 就归还,所以关键是「什么时候 drop」。try_send_request 拿到响应头以后:
if pooled.is_http2() || !pooled.is_pool_enabled() || pooled.is_ready() {
drop(pooled);
} else {
let on_idle = poll_fn(move |cx| pooled.poll_ready(cx)).map(|_| ());
self.exec.execute(on_idle);
}
HTTP/1 连接这时通常还没 ready:响应 body 还在路上,连接任务要等 body 读完、读写状态回到 Init,才会再去请求通道里取下一个请求、发出 want 信号。所以这里 spawn 一个小任务,一直 poll poll_ready,等它完成就结束,pooled 随之 drop、进池。poll_ready 返回错误(连接关了)也一样结束,但这时 is_open 为假,Drop 里第一个判断就把它挡掉了。
由此推出:响应 body 不读完,连接回不了池。下面让同一个 Client 先请求一次,用四种方式对待第一个响应,50ms 后再发第二个请求,看第二个请求落在哪条连接上。服务端给每条连接编号,在响应头 x-conn 里带回来,/big 回 1 MiB 的 body,其他路径回 5 字节:
hyper = { version = "=1.12.0", features = ["server", "http1"] }
hyper-util = { version = "=0.1.21", features = ["client-legacy", "http1", "tokio"] }
http-body-util = "0.1"
tokio = { version = "=1.54.1", features = ["full"] }
公共的计数服务端(后面几个实验都用它):
// common.rs: numbers every accepted connection, logs accept/close,
// and puts `x-conn: N` on every response.
use std::{
convert::Infallible,
net::SocketAddr,
sync::{Arc, atomic::{AtomicUsize, Ordering}},
time::{Duration, Instant},
};
use http_body_util::Full;
use hyper::{body::{Bytes, Incoming}, server::conn::http1, service::service_fn, Request, Response};
use hyper_util::rt::TokioIo;
use tokio::net::TcpListener;
pub fn clock() -> impl Fn() -> u128 + Clone + Send + 'static {
let start = Instant::now();
move || start.elapsed().as_millis()
}
pub async fn server(ms: impl Fn() -> u128 + Clone + Send + Sync + 'static) -> (SocketAddr, Arc<AtomicUsize>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let accepts = Arc::new(AtomicUsize::new(0));
let counter = accepts.clone();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
let n = counter.fetch_add(1, Ordering::SeqCst) + 1;
let ms = ms.clone();
println!("{:>4}ms server: accept #{n}", ms());
tokio::spawn(async move {
let svc = service_fn(move |req: Request<Incoming>| async move {
let body = match req.uri().path() {
"/slow" => {
tokio::time::sleep(Duration::from_millis(200)).await;
Bytes::from_static(b"slow")
}
"/big" => Bytes::from(vec![b'x'; 1 << 20]),
_ => Bytes::from_static(b"hello"),
};
let mut res = Response::new(Full::new(body));
res.headers_mut().insert("x-conn", n.into());
Ok::<_, Infallible>(res)
});
let _ = http1::Builder::new().serve_connection(TokioIo::new(stream), svc).await;
println!("{:>4}ms server: close #{n}", ms());
});
}
});
(addr, accepts)
}
客户端:
#[path = "../common.rs"]
mod common;
use std::{sync::atomic::Ordering, time::Duration};
use http_body_util::{BodyExt, Empty};
use hyper::{body::Bytes, Uri};
use hyper_util::{
client::legacy::{connect::HttpConnector, Client},
rt::TokioExecutor,
};
#[tokio::main]
async fn main() {
for case in ["read to end", "drop small", "drop 1 MiB", "hold 1 MiB"] {
println!("--- {case}");
let ms = common::clock();
let (addr, accepts) = common::server(ms.clone()).await;
let client: Client<HttpConnector, Empty<Bytes>> =
Client::builder(TokioExecutor::new()).build_http();
let path = if case.contains("MiB") { "/big" } else { "/" };
let first = client.get(format!("http://{addr}{path}").parse::<Uri>().unwrap()).await.unwrap();
let mut held = None;
match case {
"read to end" => { first.into_body().collect().await.unwrap(); }
"hold 1 MiB" => held = Some(first),
_ => drop(first),
}
tokio::time::sleep(Duration::from_millis(50)).await;
let second = client.get(format!("http://{addr}/").parse::<Uri>().unwrap()).await.unwrap();
println!(
"{:>4}ms client: second request on conn #{}, accepts: {}",
ms(),
second.headers()["x-conn"].to_str().unwrap(),
accepts.load(Ordering::SeqCst)
);
drop((held, second, client));
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
M2 Max、rustc 1.99.0、--release,连跑 3 次,连接编号和次数相同,毫秒数差 3ms 以内:
--- read to end
0ms server: accept #1
54ms client: second request on conn #1, accepts: 1
54ms server: close #1
--- drop small
0ms server: accept #1
53ms client: second request on conn #1, accepts: 1
53ms server: close #1
--- drop 1 MiB
0ms server: accept #1
1ms server: close #1
53ms server: accept #2
53ms client: second request on conn #2, accepts: 2
53ms server: close #2
--- hold 1 MiB
0ms server: accept #1
53ms server: accept #2
53ms client: second request on conn #2, accepts: 2
54ms server: close #2
54ms server: close #1
- 读完:连接回池,第二个请求复用 #1。
- drop 5 字节的响应:居然也复用了。客户端
Dispatcher发现 body 的接收方没了,会调 hyper 的Conn::poll_drain_or_close_read:再试一次读 body,读完了(剩下的字节已经在读缓冲里)就算排空,连接照常 keep-alive;没读完就close_read。这 5 字节在读响应头时已经一起进了读缓冲,一次就排空了。 - drop 1 MiB 的响应:排不空,读端关闭,连接任务结束,1ms 时服务端就看到 #1 关了。第二个请求只能新建 #2。
- 拿着 1 MiB 的响应不读也不 drop:#1 没关,但 body 的通道槽满了,连接任务停在那里,
poll_ready一直不完成,连接不在池里。第二个请求新建 #2;held最后 drop 时 #1 才关。
所以「body 没读完」分两种后果:drop 掉的,小 body 可能被顺手排空,大 body 会让连接直接关掉;留着不读的,连接被占住,后面的请求只能开新连接。body 通道和 want 信号的细节见 Incoming 与 body 通道。
checkout:先看池子
Pool::checkout 只是构造一个 Checkout future,poll 时才去拿。它的 poll 先看自己登记过的 waiter 有没有收到连接,再去空闲列表里找;都没有,就在 waiters 里登记一个 oneshot,返回 Pending。
在空闲列表里找的是 IdlePopper::pop:
fn pop(self, expiration: &Expiration, now: Instant) -> Option<Idle<T>> {
while let Some(entry) = self.list.pop() {
// If the connection has been closed, or is older than our idle
// timeout, simply drop it and keep looking...
if !entry.value.is_open() {
trace!("removing closed connection for {:?}", self.key);
continue;
}
// ...
if expiration.expires(entry.idle_at, now) {
trace!("removing expired connection for {:?}", self.key);
continue;
}
let value = match entry.value.reserve() {
// ...
Reservation::Unique(unique) => unique,
};
return Some(Idle {
idle_at: entry.idle_at,
value,
});
}
None
}
三点:
Vec::pop从尾部取,进池时push到尾部,所以是后进先出:最近才空闲的连接先被用。- 已经关掉的(
is_open为假)和过期的(空闲超过idle_timeout)在这里被丢弃,接着找下一条。丢弃就是 drop,SendRequest一 drop,后台连接任务也就结束了。 - 只丢弃一路上弹出来的那几条。找到一条能用的就停,列表前面更旧的条目留着,等下次 checkout 或后台清理。
问: 为什么空闲列表后进先出?先进先出不是更「公平」吗?
答: 连接不需要公平。后进先出拿到的是空闲时间最短的那条,离客户端自己的 idle_timeout 最远,离服务端的 keep-alive 超时也最远,被对端关掉的可能最小。旧的那些留在列表前面,没人用就慢慢过期,正好被清理掉,连接数自然收缩到实际需要的数量。源码在 pop 里也留了一条 TODO:既然总是从尾部 push,尾部的条目过期了,前面的一定也过期了,将来可以整表丢掉。
is_open 这道检查能挡住「空闲期间被服务端关掉」的连接。下面的服务端是手写的:回一个 keep-alive 的响应,100ms 后关掉 socket。客户端关掉 retry_canceled_requests,如果池子把死连接交出来,第二个请求会直接报错:
use std::time::{Duration, Instant};
use http_body_util::{BodyExt, Empty};
use hyper::{body::Bytes, Uri};
use hyper_util::{
client::legacy::{connect::HttpConnector, Client},
rt::TokioExecutor,
};
use tokio::{io::{AsyncReadExt, AsyncWriteExt}, net::TcpListener};
#[tokio::main]
async fn main() {
let start = Instant::now();
let ms = move || start.elapsed().as_millis();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
// Raw server: answer one request with a keep-alive response,
// then close the socket 100ms later.
tokio::spawn(async move {
for n in 1.. {
let (mut s, _) = listener.accept().await.unwrap();
println!("{:>4}ms server: accept #{n}", ms());
tokio::spawn(async move {
let mut buf = [0u8; 1024];
let _ = s.read(&mut buf).await.unwrap();
let res = format!("HTTP/1.1 200 OK\r\ncontent-length: 2\r\nx-conn: {n}\r\n\r\nok");
s.write_all(res.as_bytes()).await.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
drop(s);
println!("{:>4}ms server: closed #{n}", ms());
});
}
});
// No retries: if the dead connection were handed out, we'd see an error.
let client: Client<HttpConnector, Empty<Bytes>> = Client::builder(TokioExecutor::new())
.retry_canceled_requests(false)
.build_http();
let uri: Uri = format!("http://{addr}/").parse().unwrap();
for _ in 0..2 {
let res = client.get(uri.clone()).await.unwrap();
let conn = res.headers()["x-conn"].to_str().unwrap().to_string();
res.into_body().collect().await.unwrap();
println!("{:>4}ms client: ok on conn #{conn}", ms());
tokio::time::sleep(Duration::from_millis(300)).await;
}
}
连跑 3 次,结果相同(毫秒数差 3ms 以内):
0ms server: accept #1
1ms client: ok on conn #1
104ms server: closed #1
304ms server: accept #2
304ms client: ok on conn #2
407ms server: closed #2
#1 在 104ms 被关掉,客户端的连接任务读到 EOF 后结束,is_ready 变假。304ms 的 checkout 弹出它,is_open 为假,丢掉,列表空了,于是新建 #2。
这道检查只看「连接任务还活着吗」。如果服务端恰好在请求写出去的同时关连接,检查已经过了,请求照样失败,那是 connection closed before message completed 那一类问题,「常见坑」一部分单独讲;retry_canceled_requests 能救的只是「请求还没写出去」的情况,见 legacy Client。
和新建连接赛跑
池里没有可用连接时,one_connection_for 不会傻等,也不会只管新建:
let checkout = self.pool.checkout(pool_key.clone());
let connect = self.connect_to(pool_key);
let is_ver_h2 = self.config.ver == Ver::Http2;
// The order of the `select` is depended on below...
match future::select(checkout, connect).await {
// Checkout won, connect future may have been started or not.
//
// If it has, let it finish and insert back into the pool,
// so as to not waste the socket...
Either::Left((Ok(checked_out), connecting)) => {
// ...
if connecting.started() {
let bg = connecting
.map_err(|err| {
trace!("background connect error: {}", err);
})
.map(|_pooled| {
// dropping here should just place it in
// the Pool for us...
});
// An execute error here isn't important, we're just trying
// to prevent a waste of a socket...
self.exec.execute(bg);
}
Ok(checked_out)
}
// Connect won, checkout can just be dropped.
Either::Right((Ok(connected), _checkout)) => Ok(connected),
// ...
}
select 总是先 poll 左边的 checkout。connect_to 返回的是一个 lazy future:没被 poll 过就什么都没做,started() 为假。所以:
| 情形 | 结果 |
|---|---|
| 池里有可用连接 | 第一次 poll checkout 就 Ready,connect 从没被 poll,不建连接 |
| 池里没有,新连接先建好 | 用新连接,checkout 被 drop,它登记的 waiter 被清理 |
| 池里没有,等待中有别的请求归还了连接 | 用归还的连接;已经开始的新建过程被 spawn 到后台,建完后 Pooled 立即 drop,进池当空闲连接 |
新连接由 connect_to 建立:调连接器(默认是 HttpConnector),握手,spawn 后台连接任务,等 SendRequest ready,再用 Pool::pooled 包成 Pooled。握手和后台任务见 连接级客户端。
归还走 PoolInner::put,先看有没有人在等:
if let Some(waiters) = self.waiters.get_mut(&key) {
while let Some(tx) = waiters.pop_front() {
if !tx.is_canceled() {
let reserved = value.take().expect("value already sent");
let reserved = match reserved.reserve() {
// ...
Reservation::Unique(uniq) => uniq,
};
match tx.send(reserved) {
Ok(()) => {
if value.is_none() {
break;
} else {
continue;
}
}
Err(e) => {
value = Some(e);
}
}
}
trace!("put; removing canceled waiter for {:?}", key);
}
// ...
}
waiter 是先来先得(pop_front),已经放弃的(is_canceled,Checkout 被 drop 了)跳过。交给 waiter 的连接根本不进空闲列表。没人等,才放进空闲列表,下一节的 max_idle_per_host 在这一步起作用。
实验:让每次建连接都慢 300ms(自定义一个包着 HttpConnector 的连接器,连接器就是 Service<Uri>,见 HttpConnector)。请求 A 走 /slow(服务端睡 200ms),350ms 时发请求 B,B 结束后 300ms 再发请求 C:
hyper = { version = "=1.12.0", features = ["server", "http1"] }
hyper-util = { version = "=0.1.21", features = ["client-legacy", "http1", "tokio"] }
http-body-util = "0.1"
tokio = { version = "=1.54.1", features = ["full"] }
tower = { version = "=0.5.3", features = ["util"] }
#[path = "../common.rs"]
mod common;
use std::{future::Future, pin::Pin, sync::atomic::Ordering, time::Duration};
use http_body_util::{BodyExt, Empty};
use hyper::{body::Bytes, Uri};
use hyper_util::{
client::legacy::{connect::HttpConnector, Client},
rt::TokioExecutor,
};
use tower::{service_fn, ServiceExt};
type Connecting = Pin<Box<dyn Future<Output = Result<hyper_util::rt::TokioIo<tokio::net::TcpStream>, Box<dyn std::error::Error + Send + Sync>>> + Send>>;
#[tokio::main]
async fn main() {
let ms = common::clock();
let (addr, accepts) = common::server(ms.clone()).await;
// Every connect takes 300ms longer than it should.
let ms2 = ms.clone();
let connector = service_fn(move |dst: Uri| -> Connecting {
let ms = ms2.clone();
Box::pin(async move {
println!("{:>4}ms client: connect start", ms());
tokio::time::sleep(Duration::from_millis(300)).await;
let io = HttpConnector::new().oneshot(dst).await?;
println!("{:>4}ms client: connect done", ms());
Ok(io)
})
});
let client: Client<_, Empty<Bytes>> = Client::builder(TokioExecutor::new()).build(connector);
let get = |path: &'static str| {
let client = client.clone();
let ms = ms.clone();
async move {
let uri: Uri = format!("http://{addr}{path}").parse().unwrap();
let res = client.get(uri).await.unwrap();
let conn = res.headers()["x-conn"].to_str().unwrap().to_string();
res.into_body().collect().await.unwrap();
println!("{:>4}ms client: {path} done on conn #{conn}", ms());
}
};
let a = tokio::spawn(get("/slow"));
tokio::time::sleep(Duration::from_millis(350)).await;
let b = tokio::spawn(get("/"));
a.await.unwrap();
b.await.unwrap();
tokio::time::sleep(Duration::from_millis(300)).await;
get("/").await;
println!("accepts: {}", accepts.load(Ordering::SeqCst));
}
连跑 4 次,顺序和连接编号相同,毫秒数差 2ms 以内:
0ms client: connect start
303ms client: connect done
303ms server: accept #1
353ms client: connect start
507ms client: /slow done on conn #1
507ms client: / done on conn #1
655ms client: connect done
655ms server: accept #2
812ms client: / done on conn #2
accepts: 2
- A:池子是空的,checkout 登记 waiter,新建连接 300ms 后完成,用 #1。507ms 响应读完。
- B(353ms):#1 还被 A 占着,checkout 登记 waiter,同时开始新建(
connect start)。507ms A 的 body 读完,#1 归还,put发现 B 在等,直接交给它。B 赢在池子这边,只用了 154ms,不用等到 653ms。 - 还在进行的新建在后台继续,655ms 建好成为 #2,
Pooleddrop,进空闲列表。这时列表里是 [#1, #2]。 - C(812ms):checkout 第一次 poll 就从尾部拿到 #2,没有
connect start这一行,说明connect从没被 poll。
服务端一共只 accept 了两次。如果输掉的新连接直接丢掉,这一次 TCP 握手就白做了;放回池里,C 就用上了它。
池子像一个出租车站。站里排着的空车是空闲列表(idle),乘客到站先看有没有空车(checkout)。没有空车,乘客一边排队(登记 waiter),一边打电话叫一辆(connect_to)。哪个先到坐哪个:要是有车送完客回到站里(put),先给排队的乘客;电话叫来的车晚到了,也不退回,停进站里排着(后台建完、进池)。站里最多停几辆是 max_idle_per_host,停太久的车被赶走是 idle_timeout。
比方到这里为止:真的车站是先到的车先走,这里是最后停进来的车先走(后进先出)。而且如果站里本来就有空车,乘客根本不会打电话,connect 一次都不会被 poll。
赛跑的另外两个分支处理「被取消」:checkout 以 CheckedOutClosedValue 失败(waiter 收到的连接已经关了),就只等新建;新建以 Canceled 失败(HTTP/2 已经有别人在建,见最后一节),就只等 checkout。其他错误直接返回。
idle_timeout:两处检查
Builder::new 里与池子有关的默认值:
| 选项 | 默认 | 设置方法 |
|---|---|---|
| 空闲超时 | 90 秒 | pool_idle_timeout,None 表示永不过期 |
| 每个 key 的空闲上限 | usize::MAX | pool_max_idle_per_host,0 关掉连接池 |
| 池子用的计时器 | 无 | pool_timer |
另外,build_http 在池子开着时把 HttpConnector 的 TCP keepalive 也设成 idle_timeout(默认 90 秒),见 HttpConnector。
过期在两个地方检查:
- 取出时:上一节的
IdlePopper::pop,用Expiration::expires,now - idle_at > timeout就丢弃。now来自PoolInner::now:有pool_timer用计时器的时间,没有就用Instant::now()。这一处不需要 timer。 - 后台清理:
spawn_idle_interval在第一条连接进入空闲列表时启动一个IdleTask,定期调clear_expired把关闭的、过期的连接清出去。
spawn_idle_interval 有四个不启动的条件:
fn spawn_idle_interval(&mut self, pool_ref: &Arc<Mutex<PoolInner<T, K>>>) {
if self.idle_interval_ref.is_some() {
return;
}
let dur = if let Some(dur) = self.timeout {
dur
} else {
return;
};
if dur == Duration::ZERO {
return;
}
let timer = if let Some(timer) = self.timer.clone() {
timer
} else {
return;
};
// While someone might want a shorter duration, and it will be respected
// at checkout time, there's no need to wake up and proactively evict
// faster than this.
const MIN_CHECK: Duration = Duration::from_millis(90);
let dur = dur.max(MIN_CHECK);
// ...
self.exec.execute(interval.run());
}
已经有一个在跑、没设超时、超时为 0、没有 timer。默认的 Builder 没有 pool_timer,所以默认不做后台清理:过期连接只在下一次 checkout 弹到它时才被丢掉,在那之前 socket 一直开着。reqwest 0.13.5 构造 Client 时调了 builder.pool_timer(hyper_util::rt::TokioTimer::new()),用 reqwest 的话后台清理是开着的。
清理间隔是 max(idle_timeout, 90ms)。90ms 下限来自 #217(0.1.16),PR 的说法是超时设得极小时没必要那么频繁地醒来,过期连接在 checkout 时照样不会被使用,只是晚几毫秒才从数据结构里清掉。timeout == 0 不启动是 #215(同在 0.1.16)。
async fn run(self) {
use futures_util::future;
let mut sleep = self.timer.sleep_until(self.timer.now() + self.duration);
let mut on_pool_drop = self.pool_drop_notifier;
loop {
match future::select(&mut on_pool_drop, &mut sleep).await {
future::Either::Left(_) => {
// pool dropped, bah-bye
break;
}
future::Either::Right(((), _)) => {
if let Some(inner) = self.pool.upgrade() {
if let Ok(mut inner) = inner.lock() {
trace!("idle interval checking for expired");
inner.clear_expired();
if inner.idle.is_empty() {
inner.idle_interval_ref = None;
trace!("pool empty, canceling idle interval");
return;
}
}
}
let deadline = self.timer.now() + self.duration;
self.timer.reset(&mut sleep, deadline);
}
}
}
// ...
}
任务只拿着池子的 Weak,另外拿着一个永远不会被发送的 oneshot::Receiver(pool_drop_notifier),发送端存在 PoolInner::idle_interval_ref 里。池子整个 drop 时发送端跟着 drop,select 左边立刻完成,任务退出,不用等下一次醒来。
清理到空闲列表全空时,run 把 idle_interval_ref 置成 None 并返回,下一条连接进池时 spawn_idle_interval 再启动一个新任务。这几行是 #292 加的,首次发布在 0.1.21,也就是本卷的版本。
之前任务只在池子 drop 时退出。PR 描述的原话:「The IdleTask is started on first connection when pool_idle_timeout is set however it is not stopped when the pool is empty leaving the event loop spinning every pool_idle_timeout ms.」它是在 Deno 里发现的:一个长期存在但已经没有空闲连接的 Client,仍然每隔 pool_idle_timeout 唤醒一次事件循环。CHANGELOG 的写法是「Fix client::legacy::Client to cancel the idle interval once its pool empties.」同一个 PR 给 test_pool_timer_removes_expired_inner 加了断言:清空后 idle_interval_ref 为 None,再放一条连接进去又变回 Some。
文档说 pool_idle_timeout「A Timer is required for this to take effect」。从上面两处检查看,这句话只对后台清理成立。
M2 Max、rustc 1.99.0。同一个计数服务端,pool_idle_timeout 设 200ms,一次带 pool_timer,一次不带。每次请求完成后睡 600ms:
#[path = "../common.rs"]
mod common;
use std::{sync::atomic::Ordering, time::Duration};
use http_body_util::{BodyExt, Empty};
use hyper::{body::Bytes, Uri};
use hyper_util::{
client::legacy::{connect::HttpConnector, Client},
rt::{TokioExecutor, TokioTimer},
};
#[tokio::main]
async fn main() {
for with_timer in [true, false] {
println!("--- pool_idle_timeout(200ms), pool_timer: {with_timer}");
let ms = common::clock();
let (addr, accepts) = common::server(ms.clone()).await;
let mut builder = Client::builder(TokioExecutor::new());
builder.pool_idle_timeout(Duration::from_millis(200));
if with_timer {
builder.pool_timer(TokioTimer::new());
}
let client: Client<HttpConnector, Empty<Bytes>> = builder.build_http();
let uri: Uri = format!("http://{addr}/").parse().unwrap();
for _ in 0..2 {
let res = client.get(uri.clone()).await.unwrap();
let conn = res.headers()["x-conn"].to_str().unwrap().to_string();
res.into_body().collect().await.unwrap();
println!("{:>4}ms client: done on conn #{conn}", ms());
tokio::time::sleep(Duration::from_millis(600)).await;
}
println!("accepts: {}", accepts.load(Ordering::SeqCst));
}
}
两段输出里,第二个请求各落在哪条连接上?server: close #1 各出现在第几毫秒?答案在本页末尾。
max_idle_per_host
put 在没有 waiter 时才往空闲列表里放:
let idle_list = self.idle.entry(key.clone()).or_default();
if self.max_idle_per_host <= idle_list.len() {
trace!("max idle per host for {:?}, dropping connection", key);
return;
}
满了就丢掉这一条(value 在 return 时被 drop,连接任务随之结束)。它只限制空闲连接,不限制同时在用的连接数:同时发 100 个请求,就可能有 100 条连接。
设成 0 更彻底:Pool::new 根本不创建 PoolInner,one_connection_for 的第一行发现池子关着,直接 connect_to,不再 checkout,Pooled 也不持有池子的引用,响应一结束就 drop。
实验:每轮同时发 3 个 /slow,两轮,看服务端一共 accept 几次:
#[path = "../common.rs"]
mod common;
use std::{sync::atomic::Ordering, time::Duration};
use futures::future::join_all;
use http_body_util::{BodyExt, Empty};
use hyper::{body::Bytes, Uri};
use hyper_util::{
client::legacy::{connect::HttpConnector, Client},
rt::TokioExecutor,
};
#[tokio::main]
async fn main() {
for max_idle in [usize::MAX, 1, 0] {
let label = if max_idle == usize::MAX { "usize::MAX".to_string() } else { max_idle.to_string() };
println!("--- pool_max_idle_per_host({label})");
let ms = common::clock();
let (addr, accepts) = common::server(ms.clone()).await;
let client: Client<HttpConnector, Empty<Bytes>> = Client::builder(TokioExecutor::new())
.pool_max_idle_per_host(max_idle)
.build_http();
let uri: Uri = format!("http://{addr}/slow").parse().unwrap();
for round in 1..=2 {
// three requests at the same time
join_all((0..3).map(|_| async {
let res = client.get(uri.clone()).await.unwrap();
res.into_body().collect().await.unwrap();
}))
.await;
tokio::time::sleep(Duration::from_millis(50)).await;
println!("{:>4}ms client: round {round} done, accepts so far: {}", ms(), accepts.load(Ordering::SeqCst));
}
drop(client);
tokio::time::sleep(Duration::from_millis(50)).await;
println!("{:>4}ms client: dropped", ms());
}
}
(多加一行依赖 futures = "0.3"。)一次运行的完整输出:
--- pool_max_idle_per_host(usize::MAX)
0ms server: accept #1
0ms server: accept #2
0ms server: accept #3
257ms client: round 1 done, accepts so far: 3
514ms client: round 2 done, accepts so far: 3
514ms server: close #1
514ms server: close #2
514ms server: close #3
565ms client: dropped
--- pool_max_idle_per_host(1)
0ms server: accept #1
0ms server: accept #2
0ms server: accept #3
203ms server: close #2
203ms server: close #1
256ms client: round 1 done, accepts so far: 3
257ms server: accept #4
257ms server: accept #5
461ms server: close #5
461ms server: close #4
514ms client: round 2 done, accepts so far: 5
514ms server: close #3
566ms client: dropped
--- pool_max_idle_per_host(0)
0ms server: accept #1
0ms server: accept #2
0ms server: accept #3
203ms server: close #2
203ms server: close #3
204ms server: close #1
257ms client: round 1 done, accepts so far: 3
257ms server: accept #4
257ms server: accept #5
257ms server: accept #6
459ms server: close #6
459ms server: close #4
459ms server: close #5
510ms client: round 2 done, accepts so far: 6
564ms client: dropped
又连跑 3 次,accept 次数每次都是 3/3、3/5、3/6;上限为 1 时留下哪一条(谁先归还谁留下)在不同运行之间会变。
- 不限:3 条连接都留下,第二轮全部复用,直到
Clientdrop 才关。 - 上限 1:第一轮结束时只留一条,另外两条在 203ms 立即关掉;第二轮复用 1 条、新建 2 条,一共 5 次。
- 上限 0:池子关闭,每个请求一结束连接就关,两轮 6 次。
HTTP/2:一条连接大家共用
HTTP/2 的 SendRequest 可以克隆,多个请求并发跑在同一条连接上(见 HTTP/2 客户端)。池子用 Reservation 区分两种连接:PoolClient::reserve 对 HTTP/1 返回 Unique,对 HTTP/2 返回 Shared(a, b),两份是同一个 SendRequest 的克隆。
- 取出:
pop拿到Shared时,一份推回空闲列表(idle_at刷新为现在),一份交出去。HTTP/2 连接因此一直留在池里,谁来都能拿一份。 - 新建:
pooled遇到Shared,一份put进池,一份返回,返回的那份不持有池子引用,用完 drop 就是 drop。 - 归还:
put开头if value.can_share() && self.idle.contains_key(&key) { return; },池里已经有这个 key 的 HTTP/2 连接就不再放第二条。交给 waiter 时也是reserve一次,每个 waiter 分到一份克隆,value一直不为空,所以一条新的 HTTP/2 连接能一次满足所有排队的请求。
还剩一个问题:池里还没有 HTTP/2 连接时,同时来的 10 个请求会不会各建一条?Pool::connecting 就是为此准备的锁:Ver::Http2 时往 connecting 集合里插入 key,插入成功的拿到 Connecting 去建连接,插入失败的 connect_to 立即以 Canceled 结束,one_connection_for 于是只等 checkout,等第一条连接建好后 put 给它们分克隆。HTTP/1 不加锁,Connecting 里不持有池子引用。
这把锁对两种情况生效:http2_only(true) 的 Client(ver 是 Http2),以及连接器在 Connected 里报告 ALPN 协商出了 h2(connect_to 里调 Connecting::alpn_h2 补拿锁,拿不到说明别人已经升级,这次新建以 Canceled 结束)。Connecting drop 时调 connected 放锁,同时清掉这个 key 的所有 waiter:源码注释说,如果此时还有 waiter,说明这次建连接没有成功,它们永远等不到连接了。
动脑筋答案
一次运行的输出(连跑 5 次,连接编号和次数相同,close #1 的时刻每次都是 204ms 和 602–603ms,其余毫秒数差 20ms 以内):
--- pool_idle_timeout(200ms), pool_timer: true
0ms server: accept #1
0ms client: done on conn #1
204ms server: close #1
605ms server: accept #2
605ms client: done on conn #2
808ms server: close #2
accepts: 2
--- pool_idle_timeout(200ms), pool_timer: false
0ms server: accept #1
0ms client: done on conn #1
603ms server: close #1
603ms server: accept #2
604ms client: done on conn #2
accepts: 2
- 两种配置下第二个请求都开了新连接 #2:空闲了 600ms,超过 200ms,checkout 时
Expiration::expires判定过期。没有 timer 时now用的是Instant::now(),照样能判断。 - 有 timer 时,#1 在 204ms 关闭:第一条连接进池时启动了
IdleTask,间隔max(200ms, 90ms),第一次醒来就发现它空闲超过 200ms,清掉;列表空了,任务退出。#2 进池又启动一个新任务,808ms 清掉 #2。 - 没有 timer 时,#1 一直开到 603ms,直到第二个请求的 checkout 把它弹出来才丢掉,紧接着 accept #2。#2 在程序结束前一直没关,因为再也没有 checkout 来弹它。
所以 pool_idle_timeout 不设 timer 也保证「不会用到过期连接」;timer 管的是「过期连接的 socket 什么时候真正关掉」。
小结
Pool是Arc<Mutex<PoolInner>>,按(Scheme, Authority)分组,每组一个空闲Vec(后进先出)和一个 waiter 队列(先进先出)。max_idle_per_host == 0时池子不存在。- 连接包在
Pooled里,drop 即归还;HTTP/1 要等响应 body 读完、连接任务重新 want 下一个请求才归还。drop 掉没读完的 body,小 body 可能被顺手排空,大 body 会让连接关闭;留着不读,连接被占住。 - checkout 弹出时丢弃
is_open为假(连接任务已结束、被标记 poisoned)和过期的连接。 - 池里没有可用连接时,checkout 与
connect_to用select赛跑,checkout 先 poll;池里有就根本不新建,后来居上的归还连接赢了的话,已经开始的新建在后台完成并进池。 idle_timeout(默认 90 秒)在 checkout 时总会检查;后台清理要有pool_timer,间隔不小于 90ms,池子空了任务就退出(0.1.21 起)。默认Builder没有pool_timer。- HTTP/2 连接以
Shared形式留在池里供所有请求克隆使用,「connecting 锁」保证同一 key 同时只建一条。