ping:保活与 BDP
主线位置:两条主线的旁路。服务端在
poll_server每圈收流之前、客户端在连接任务每次 poll h2 连接之前,先 poll 一次Ponger;读到的每一帧都经Recorder记一笔。它不处理请求,只决定两件事:这条连接是不是死了,流控窗口该不该放大。
flowchart TB frames["读到 HEADERS / DATA / trailers"] --> rec["Recorder:记时间、记字节"] rec --> shared["Shared:Mutex 里的共享状态"] pong["Ponger::poll"] --> shared pong --> ka["keep-alive:到点发 PING"] pong --> bdp["BDP:PONG 回来算带宽"] ka -->|"超时"| dead["KeepAliveTimedOut:关连接"] bdp -->|"窗口变大"| upd["SizeUpdate:调窗口"]
ping.rs 的模块注释开头就说了:hyper 用 HTTP/2 的 PING 做两件事,自适应流控(BDP)和连接保活,两者都是可选的。PING 帧本身由 h2(0.4.20)的 PingPong 发收,对端发来的 PING 也由 h2 自动回 ACK,hyper 只管「什么时候发自己的 PING、PONG 回来后做什么」。
Recorder 与 Ponger
两者都没开时根本不建 ping 通道(Config::is_enabled 为假,服务端 Serving.ping 是 None,客户端拿到的是 ping::disabled(),一个空的 Recorder)。开了任意一个,channel 建一对:
pub(crate) struct Recorder {
shared: Option<Arc<Mutex<Shared>>>,
}
pub(super) struct Ponger {
bdp: Option<Bdp>,
keep_alive: Option<KeepAlive>,
shared: Arc<Mutex<Shared>>,
}
Shared 放在 Arc<Mutex> 里,字段是两件事的状态:ping_pong(h2 的句柄)、ping_sent_at(在途 PING 的发出时间,Some 就说明有一个 PING 没回来)、bytes 和 next_bdp_at(BDP 用)、last_read_at 和 is_keep_alive_timed_out(keep-alive 用)、timer。
Recorder可以克隆,到处都是。连接本身持一个;每个请求或响应的Incoming持一个,record_data在每交出一块 DATA 时调用,record_non_data在收到请求头、响应头、trailers 时调用。客户端的 Send 任务和 Pipe 任务也各持一个,即使它们不记录什么。Ponger只有一个,由驱动连接的那个 future 持有:服务端在poll_ping里 poll 它,客户端在包着 h2 连接的Conn::poll里 poll 它。结果是Ponged的两个变体之一:SizeUpdate(wnd)或KeepAliveTimedOut。
Recorder 的数量顺带回答了「连接空不空闲」。is_idle:
fn is_idle(&self) -> bool {
Arc::strong_count(&self.shared) <= 2
}
Ponger 一个引用、连接自己的 Recorder 一个,超过 2 就说明还有流的 body、Send 任务或 Pipe 任务活着。不用另外维护一个计数器,代价是这个「空闲」的定义比「没有打开的流」略宽:只要某个 Incoming 还没被 drop,连接就不算空闲。
keep-alive:读不到帧就发 PING
KeepAlive 带一个 sleep(所以要 Timer,没有就在建通道时 panic,见 Executor 与 Timer)和一个三态的 KeepAliveState:
stateDiagram-v2 state "Init:没排期" as Init state "Scheduled:等间隔到期" as Scheduled state "PingSent:等 PONG" as PingSent [*] --> Init Init --> Scheduled: 不空闲或 while_idle Scheduled --> Init: 期间读到过帧 Scheduled --> PingSent: 到点,发 PING PingSent --> Scheduled: PONG 回来 PingSent --> [*]: 超时
Ponger::poll 每次先走 keep-alive 的两步,再看 PONG:
if let Some(ka) = &mut self.keep_alive {
ka.maybe_schedule(is_idle, &locked);
ka.maybe_ping(cx, is_idle, &mut locked);
}
if !locked.is_ping_sent() {
// XXX: this doesn't register a waker...?
return Poll::Pending;
}
match locked.ping_pong.poll_pong(cx) {
Poll::Ready(Ok(_pong)) => {
// ... 清掉 ping_sent_at,算 rtt
if let Some(ka) = &mut self.keep_alive {
locked.update_last_read_at();
ka.maybe_schedule(is_idle, &locked);
ka.maybe_ping(cx, is_idle, &mut locked);
}
// ... BDP,见下文
}
// ...
Poll::Pending => {
if let Some(ka) = &mut self.keep_alive {
if let Err(KeepAliveTimedOut) = ka.maybe_timeout(cx) {
self.keep_alive = None;
locked.is_keep_alive_timed_out = true;
return Poll::Ready(Ponged::KeepAliveTimedOut);
}
}
}
}
maybe_ping 是关键:
KeepAliveState::Scheduled(at) => {
if Pin::new(&mut self.sleep).poll(cx).is_pending() {
return;
}
// check if we've received a frame while we were scheduled
if shared.last_read_at() + self.interval > at {
self.state = KeepAliveState::Init;
cx.waker().wake_by_ref(); // schedule us again
return;
}
if !self.while_idle && is_idle {
trace!("keep-alive no need to ping when idle and while_idle=false");
return;
}
trace!("keep-alive interval ({:?}) reached", self.interval);
shared.send_ping();
self.state = KeepAliveState::PingSent;
let timeout = self.timer.now() + self.timeout;
self.timer.reset(&mut self.sleep, timeout);
}
- 排期按
last_read_at + interval算(schedule),不是「每隔 interval 发一次」。sleep醒来时如果这段时间里读到过帧,last_read_at已经往后挪了,就回到Init、唤醒自己重新排期,不发 PING。忙碌的连接上数据一直在来,keep-alive 一个 PING 也不会发。 - 发 PING 之后,同一个
sleep被reset成now + timeout,从「等间隔」变成「等 PONG」。PONG 回来算作读到一帧,update_last_read_at后重新排期;sleep先醒就是超时,maybe_timeout返回错误。 - 注释里的
XXX: this doesn't register a waker...?说的是:没有 PING 在途时直接返回Pending,不在poll_pong上注册。这时它再次被 poll,靠的是 keep-alive 的sleep(Scheduled时注册了 waker)或连接上的其他事件。
「读到帧」只包括 hyper 通过 Recorder 看到的帧:头、数据、trailers,以及自己 PING 的 PONG。对端发来的 PING、SETTINGS、WINDOW_UPDATE 由 h2 内部处理,不更新 last_read_at。
while_idle = false 曾经只管第一次#3380(issue,「eliminates unnecessary ping when the connection is idle」)报告:对着 gRPC 的 Go 服务端,keep_alive_while_idle(false) 的客户端仍在空闲时发 PING,服务端「otherwise output too many ping」。issue 里贴出的修改建议就是在 maybe_ping 里加一个 if !self.while_idle && is_idle { return; }。
在那之前,空闲检查只在 maybe_schedule 的 Init 分支里:空闲时不排期。可是一旦在有流的时候排过期,sleep 到点时 maybe_ping 不再看是否空闲,照发不误;PONG 回来后的 PingSent 分支又直接重新排期,于是请求结束后 PING 一直按间隔发下去。#3381(v1.0.0,「don't send keep-alive ping when idle」)给 maybe_ping 加了 is_idle 参数和上面代码里那三行检查。
默认值与两端的差别
| 服务端 | 客户端 | |
|---|---|---|
keep_alive_interval | 无(关) | 无(关) |
keep_alive_timeout | 20 秒 | 20 秒 |
keep_alive_while_idle | 写死为 true,没有这个选项 | 默认 false,可配 |
| 超时后 | abrupt_shutdown(NO_ERROR):发 GOAWAY 再关 | 连接任务直接结束,socket 关闭 |
| 报错 | Connection 返回 Err,is_timeout() 为真 | Connection 和在途请求都返回 Err,is_timeout() 为真 |
服务端在 Server::new 里把 keep_alive_while_idle 写死:
let ping_config = ping::Config {
bdp_initial_window: bdp,
keep_alive_interval: config.keep_alive_interval,
keep_alive_timeout: config.keep_alive_timeout,
// If keep-alive is enabled for servers, always enabled while
// idle, so it can more aggressively close dead connections.
keep_alive_while_idle: true,
};
超时后服务端的 poll_ping 调 h2 的 abrupt_shutdown(h2::Reason::NO_ERROR);之后 poll_accept 返回 None,poll_server 里的 ensure_not_timed_out 把它变成 KeepAliveTimedOut 错误。客户端的 Conn::poll 遇到 KeepAliveTimedOut 直接 return Poll::Ready(Ok(())),ConnTask 结束,h2 连接被 drop;ClientTask 和在途请求的 ResponseFutMap 在各自的错误路径上调 ensure_not_timed_out,把 h2 的连接错误换成超时错误。KeepAliveTimedOut 的 source() 是 hyper 的 TimedOut,所以 is_timeout() 为真;Display 只有 http2 error。
实验:PING 没人回
服务端 keep-alive 间隔 200ms、超时 300ms。客户端是手写的:发连接前言和一个空 SETTINGS,回 ACK 给服务端的 SETTINGS,然后只读不回,不理 PING。
hyper = { version = "=1.12.0", features = ["server", "client", "http2"] }
hyper-util = { version = "=0.1.21", features = ["tokio"] }
http-body-util = "0.1"
tokio = { version = "=1.54.1", features = ["full"] }
use std::{convert::Infallible, time::{Duration, Instant}};
use http_body_util::Empty;
use hyper::{body::{Bytes, Incoming}, server, service::service_fn, Request, Response};
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use tokio::{io::{AsyncReadExt, AsyncWriteExt}, net::{TcpListener, TcpStream}};
const NAMES: [&str; 10] = [
"DATA", "HEADERS", "PRIORITY", "RST_STREAM", "SETTINGS",
"PUSH_PROMISE", "PING", "GOAWAY", "WINDOW_UPDATE", "CONTINUATION",
];
#[tokio::main(flavor = "current_thread")]
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();
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let svc = service_fn(|_: Request<Incoming>| async {
Ok::<_, Infallible>(Response::new(Empty::<Bytes>::new()))
});
let r = server::conn::http2::Builder::new(TokioExecutor::new())
.timer(TokioTimer::new())
.keep_alive_interval(Duration::from_millis(200))
.keep_alive_timeout(Duration::from_millis(300))
.serve_connection(TokioIo::new(stream), svc)
.await;
match r {
Ok(()) => println!("{:>4}ms server conn: Ok(())", ms()),
Err(e) => println!("{:>4}ms server conn: Err({e}), is_timeout={}", ms(), e.is_timeout()),
}
});
// A raw client: preface + SETTINGS, ACKs the server's SETTINGS,
// never answers PING.
let mut c = TcpStream::connect(addr).await.unwrap();
c.write_all(b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n").await.unwrap();
c.write_all(&[0, 0, 0, 4, 0, 0, 0, 0, 0]).await.unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
loop {
let n = c.read(&mut chunk).await.unwrap();
if n == 0 {
println!("{:>4}ms client: EOF", ms());
break;
}
buf.extend_from_slice(&chunk[..n]);
while buf.len() >= 9 {
let len = u32::from_be_bytes([0, buf[0], buf[1], buf[2]]) as usize;
if buf.len() < 9 + len {
break;
}
let (kind, flags) = (NAMES[buf[3] as usize], buf[4]);
let ack = if flags & 1 == 1 && (kind == "SETTINGS" || kind == "PING") { " ACK" } else { "" };
let code = if kind == "GOAWAY" {
format!(" error_code={}", u32::from_be_bytes(buf[13..17].try_into().unwrap()))
} else {
String::new()
};
println!("{:>4}ms client: got {kind}{ack}{code}", ms());
if kind == "SETTINGS" && ack.is_empty() {
c.write_all(&[0, 0, 0, 4, 1, 0, 0, 0, 0]).await.unwrap();
}
buf.drain(..9 + len);
}
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
M2 Max、rustc 1.99.0、--release,连跑 3 次,除毫秒数差几毫秒外相同:
0ms client: got SETTINGS
0ms client: got SETTINGS ACK
0ms client: got WINDOW_UPDATE
202ms client: got PING
506ms server conn: Err(http2 error), is_timeout=true
506ms client: got GOAWAY error_code=0
506ms client: EOF
连接上一个流都没有,服务端照样在 200ms 发 PING(while_idle 写死为真)。PING 发出后 300ms 没有 PONG,服务端发 GOAWAY(NO_ERROR) 关连接,Connection 以超时错误结束。开头的 WINDOW_UPDATE 是服务端把连接级窗口从规范默认的 65535 放大到 1 MiB:用 sniff.rs 看,它在流 0 上的增量是 983041,正好是 1048576 − 65535(SETTINGS 只能改流级窗口的初始值,连接级窗口只能靠 WINDOW_UPDATE)。
反过来,hyper 客户端(间隔 100ms、超时 200ms)对着一个只读不回的手写服务端发一个请求:
use std::time::{Duration, Instant};
use http_body_util::Empty;
use hyper::{body::Bytes, client, Request};
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use tokio::{io::{AsyncReadExt, AsyncWriteExt}, net::{TcpListener, TcpStream}};
#[tokio::main(flavor = "current_thread")]
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();
// A raw server: sends SETTINGS, ACKs every SETTINGS it gets, reads and
// ignores everything else (never answers PING, never responds).
tokio::spawn(async move {
let (mut s, _) = listener.accept().await.unwrap();
s.write_all(&[0, 0, 0, 4, 0, 0, 0, 0, 0]).await.unwrap();
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
let mut skip = 24;
loop {
let n = s.read(&mut chunk).await.unwrap();
if n == 0 {
println!("{:>4}ms server: EOF", ms());
return;
}
buf.extend_from_slice(&chunk[..n]);
let k = skip.min(buf.len());
buf.drain(..k);
skip -= k;
while buf.len() >= 9 {
let len = u32::from_be_bytes([0, buf[0], buf[1], buf[2]]) as usize;
if buf.len() < 9 + len {
break;
}
if buf[3] == 4 && buf[4] & 1 == 0 {
s.write_all(&[0, 0, 0, 4, 1, 0, 0, 0, 0]).await.unwrap();
}
match buf[3] {
1 => println!("{:>4}ms server: got HEADERS", ms()),
6 => println!("{:>4}ms server: got PING{}", ms(), if buf[4] & 1 == 1 { " ACK" } else { "" }),
7 => println!("{:>4}ms server: got GOAWAY", ms()),
_ => {}
}
buf.drain(..9 + len);
}
}
});
let stream = TcpStream::connect(addr).await.unwrap();
let (mut sender, conn) = client::conn::http2::Builder::new(TokioExecutor::new())
.timer(TokioTimer::new())
.keep_alive_interval(Duration::from_millis(100))
.keep_alive_timeout(Duration::from_millis(200))
.handshake::<_, Empty<Bytes>>(TokioIo::new(stream))
.await
.unwrap();
let conn = tokio::spawn(async move {
let r = conn.await;
println!("{:>4}ms client conn: {r:?}", ms());
});
let req = Request::get("http://x/").body(Empty::new()).unwrap();
match sender.send_request(req).await {
Ok(res) => println!("{:>4}ms client: {}", ms(), res.status()),
Err(e) => println!("{:>4}ms client: Err({e}), is_timeout={}", ms(), e.is_timeout()),
}
conn.await.unwrap();
tokio::time::sleep(Duration::from_millis(20)).await;
}
连跑 3 次,除毫秒数差 1ms 以内外相同:
0ms server: got HEADERS
104ms server: got PING
306ms client conn: Err(hyper::Error(Http2, KeepAliveTimedOut))
306ms client: Err(http2 error), is_timeout=true
306ms server: EOF
客户端默认 while_idle = false,但这里有一个请求在等响应(Send 任务持着一个 Recorder,连接不空闲),所以 100ms 发了 PING。300ms 时超时:对端看到的是直接 EOF,没有 GOAWAY;Connection 和在途请求都得到超时错误。
问: 客户端默认空闲时不发 PING,服务端却写死空闲时也发,为什么两边不一样?
答: 两边要防的东西不同。服务端开 keep-alive 是为了清理死连接:客户端掉线、NAT 表项过期之后,连接上不会再有任何帧,它恰恰是「空闲」的,空闲时不发 PING 就永远发现不了,注释说的就是「so it can more aggressively close dead connections」。客户端开 keep-alive 主要是为了别让在途请求挂在一条死连接上;空闲连接死了,下次用时自然会失败,而且很多服务端把客户端在没有流时频繁发 PING 当作滥用(上面冷知识里的 gRPC 就是),所以默认不发。连接池里的空闲连接需要保活时,再显式打开 keep_alive_while_idle。
BDP:用 PING 量出窗口该多大
HTTP/2 的接收窗口决定了对端在等 WINDOW_UPDATE 之前最多能发多少字节。窗口小于带宽 × 往返时间(BDP),发送方就会时不时停下来等,吞吐上不去。adaptive_window(true) 让 hyper 自己量:模块注释里的算法是,收到 DATA 时若没有 BDP PING 在途,记下时间、发一个 PING;之后累加收到的字节;PONG 回来时,用往返时间和这段时间收到的字节估算 BDP,大于当前窗口的 2/3 就把窗口调大。
record_data 负责采样:
let mut locked = shared.lock().panic_if_poisoned();
locked.update_last_read_at();
// are we ready to send another bdp ping?
// if not, we don't need to record bytes either
if let Some(next_bdp_at) = &locked.next_bdp_at {
if locked.timer.now() < *next_bdp_at {
return;
} else {
locked.next_bdp_at = None;
}
}
if let Some(bytes) = &mut locked.bytes {
*bytes += len;
} else {
// no need to send bdp ping if bdp is disabled
return;
}
if !locked.is_ping_sent() {
locked.send_ping();
}
BDP 的 PING 由读数据的一方顺手发出(Incoming 被 poll 时),不需要 timer 的 sleep,只用 timer.now()。这就是为什么只开 adaptive_window 不给 timer 不会 panic。keep-alive 和 BDP 共用 ping_sent_at:同一时刻最多一个 PING 在途,它的 PONG 两边都用。
PONG 回来后,Bdp::calculate:
// average the rtt
let rtt = seconds(rtt);
if self.rtt == 0.0 {
// First sample means rtt is first rtt.
self.rtt = rtt;
} else {
// Weigh this rtt as 1/8 for a moving average.
self.rtt += (rtt - self.rtt) * 0.125;
}
// calculate the current bandwidth
let bw = (bytes as f64) / (self.rtt * 1.5);
trace!("current bandwidth = {:.1}B/s", bw);
if bw < self.max_bandwidth {
// not a faster bandwidth, so don't update
self.stabilize_delay();
return None;
} else {
self.max_bandwidth = bw;
}
// if the current `bytes` sample is at least 2/3 the previous
// bdp, increase to double the current sample.
// ...
if bytes >= self.bdp as usize * 2 / 3 {
self.bdp =
WindowSize::try_from((bytes * 2).min(BDP_LIMIT as usize)).unwrap_or(BDP_LIMIT);
trace!("BDP increased to {}", self.bdp);
self.stable_count = 0;
self.ping_delay /= 2;
Some(self.bdp)
} else {
self.stabilize_delay();
None
}
规则归纳一下:
| 量 | 规则 |
|---|---|
| RTT | 第一个样本直接用,之后按 1/8 的权重做指数移动平均 |
| 带宽 | bytes / (rtt × 1.5),只认创新高的样本 |
| 调窗口 | 这个样本的字节数 ≥ 当前窗口的 2/3,新窗口 = 字节数 × 2,上限 BDP_LIMIT 16 MiB(注释:再大就撞上 TCP 自己的流控了) |
| 下次采样 | ping_delay 从 100ms 起;调了窗口就减半,否则(stabilize_delay)在延迟还不到 10 秒时,连续两次没调窗口就乘 4 |
ping_delay 的伸缩是 v0.14.8 的 #2550 加的,提交说明说目的是窗口在变时快速提速、稳定后少发 PING,「hopefully preventing hyper from triggering ENHANCE_YOUR_CALM errors」。
calculate 返回 Some(wnd) 时 Ponger::poll 给出 Ponged::SizeUpdate(wnd),两端都对 h2 连接调 set_target_window_size(wnd) 和 set_initial_window_size(wnd):前者放大连接级窗口,后者通过一个新的 SETTINGS 帧改所有流的初始窗口。
实验:窗口怎么长
客户端开 adaptive_window,服务端回一个 64 MiB 的 body,中间的 sniff.rs(HTTP/2 服务端 那页的代码)只打印 SETTINGS 帧。SETTINGS 参数按 id=值 打印:2 是 ENABLE_PUSH,3 是 MAX_CONCURRENT_STREAMS,4 是 INITIAL_WINDOW_SIZE,5 是 MAX_FRAME_SIZE,6 是 MAX_HEADER_LIST_SIZE。
use std::{convert::Infallible, time::Instant};
use http_body_util::{BodyExt, Empty, Full};
use hyper::{body::{Bytes, Incoming}, client, server, service::service_fn, Request, Response};
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::net::{TcpListener, TcpStream};
#[path = "../sniff.rs"]
mod sniff;
#[tokio::main]
async fn main() {
let start = Instant::now();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = sniff::proxy(listener.local_addr().unwrap(), start, &["SETTINGS"]).await;
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let svc = service_fn(|_: Request<Incoming>| async {
Ok::<_, Infallible>(Response::new(Full::new(Bytes::from(vec![0u8; 64 << 20]))))
});
server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(stream), svc)
.await
.unwrap();
});
let stream = TcpStream::connect(proxy).await.unwrap();
let (mut sender, conn) = client::conn::http2::Builder::new(TokioExecutor::new())
.adaptive_window(true)
.handshake::<_, Empty<Bytes>>(TokioIo::new(stream))
.await
.unwrap();
tokio::spawn(conn);
let res = sender.send_request(Request::get("http://x/").body(Empty::new()).unwrap()).await.unwrap();
let n = res.into_body().collect().await.unwrap().to_bytes().len();
println!("{:>5}ms client: read {n} bytes", start.elapsed().as_millis());
}
多线程运行时,其中一次的输出:
0ms s->c SETTINGS stream=0 3=200 4=1048576 5=16384 6=16384
0ms c->s SETTINGS stream=0 2=0 4=65535 5=16384 6=16384
0ms c->s SETTINGS stream=0 ACK
0ms s->c SETTINGS stream=0 ACK
1ms c->s SETTINGS stream=0 4=131070
1ms s->c SETTINGS stream=0 ACK
51ms c->s SETTINGS stream=0 4=425980
51ms s->c SETTINGS stream=0 ACK
77ms c->s SETTINGS stream=0 4=1605624
78ms s->c SETTINGS stream=0 ACK
92ms c->s SETTINGS stream=0 4=2719728
92ms s->c SETTINGS stream=0 ACK
100ms client: read 67108864 bytes
- 头两行是两边的初始 SETTINGS:服务端用 hyper 的默认值(并发 200、窗口 1 MiB),客户端开了自适应窗口,从规范默认的 65535 起步,并且总是声明
ENABLE_PUSH=0。 - 之后每个
4=都是一次SizeUpdate。第一次在 1ms:第一个样本收满了一个 65535 的窗口,65535 ≥ 2/3 × 65535,新窗口 = 65535 × 2 = 131070。 - 更新的时间间隔约 50ms、26ms、15ms:
ping_delay从 100ms 每次减半,下一次采样要等next_bdp_at,所以更新越来越密。每次的新值都是样本字节数的两倍(都是偶数)。
一共跑了 8 次。更新的时间点稳定(约 1ms、51ms、77ms,其中 2 次在 90ms 左右还有第四次,这 2 次里又有 1 次在 100ms 有第五次),窗口的具体值每次不同(第一次更新是 131070 或 229374,第二次在 425980 到 851964 之间,第三次在 819200 到 3178496 之间),因为一个 PING 往返里收到多少字节取决于调度。64 MiB 在本机回环上约 100ms 就读完了,窗口还远没到 16 MiB 的上限。
客户端 keep-alive 间隔 100ms、带 TokioTimer,服务端不开 keep-alive,处理函数睡 550ms。三种情况各观察 550ms,用 sniff.rs 的 CLIENT_PINGS 数客户端发出的 PING(不算 ACK):A 不发请求,keep_alive_while_idle(false);B 不发请求,keep_alive_while_idle(true);C while_idle 为 false,发一个请求等它的响应。依赖同上。
use std::{convert::Infallible, sync::atomic::Ordering, time::{Duration, Instant}};
use http_body_util::Empty;
use hyper::{body::{Bytes, Incoming}, client, server, service::service_fn, Request, Response};
use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer};
use tokio::net::{TcpListener, TcpStream};
#[path = "../sniff.rs"]
mod sniff;
async fn run_case(name: &str, while_idle: bool, busy: bool) {
let start = Instant::now();
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let proxy = sniff::proxy(listener.local_addr().unwrap(), start, &[]).await;
tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let svc = service_fn(|_: Request<Incoming>| async {
tokio::time::sleep(Duration::from_millis(550)).await;
Ok::<_, Infallible>(Response::new(Empty::<Bytes>::new()))
});
let _ = server::conn::http2::Builder::new(TokioExecutor::new())
.serve_connection(TokioIo::new(stream), svc)
.await;
});
sniff::CLIENT_PINGS.store(0, Ordering::SeqCst);
let stream = TcpStream::connect(proxy).await.unwrap();
let (mut sender, conn) = client::conn::http2::Builder::new(TokioExecutor::new())
.timer(TokioTimer::new())
.keep_alive_interval(Duration::from_millis(100))
.keep_alive_while_idle(while_idle)
.handshake::<_, Empty<Bytes>>(TokioIo::new(stream))
.await
.unwrap();
tokio::spawn(conn);
if busy {
// one request whose response takes 550ms
let req = Request::get("http://x/").body(Empty::new()).unwrap();
sender.send_request(req).await.unwrap();
} else {
tokio::time::sleep(Duration::from_millis(550)).await;
}
println!("{name}: client sent {} PINGs", sniff::CLIENT_PINGS.load(Ordering::SeqCst));
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
run_case("A idle, while_idle=false", false, false).await;
run_case("B idle, while_idle=true ", true, false).await;
run_case("C busy, while_idle=false", false, true).await;
}
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
A idle, while_idle=false: client sent 0 PINGs
B idle, while_idle=true : client sent 5 PINGs
C busy, while_idle=false: client sent 5 PINGs
- A:连接上只有
Ponger和ClientTask的Recorder,is_idle为真,maybe_schedule在Init就返回,从不排期。 - B:允许空闲时发。每次 PONG 回来都更新
last_read_at并按last_read_at + 100ms重新排期,550ms 内在约 100、200、300、400、500ms 发了 5 个。服务端没开 keep-alive,但 h2 会自动回 PING,所以每个都有 PONG,连接不会超时。 - C:请求在等响应,Send 任务持着一个
Recorder,引用数是 3,连接不空闲,和 B 一样发 5 个。响应头在 550ms 才来,这期间没有别的帧更新last_read_at,排期只靠 PONG 推进。
小结
Ponger只有一个,由驱动连接的 future 每次 poll;Recorder到处都是,读到头、数据、trailers 时记一笔。两者共享一个Mutex里的状态,同一时刻最多一个 PING 在途,keep-alive 和 BDP 共用它的 PONG。- keep-alive 按
last_read_at + interval排期,期间读到过帧就不发;发出后等timeout,没有 PONG 就超时。需要Timer。服务端while_idle写死为真,超时后发 GOAWAY 再关;客户端默认空闲不发,超时直接断开。两边的错误is_timeout()都为真。 is_idle就是Arc::strong_count(&shared) <= 2:还有 body、Send 或 Pipe 任务活着,连接就不空闲。- BDP 由收数据的一方顺手发 PING,只用
timer.now();样本字节数达到当前窗口的 2/3 就把窗口设为样本的两倍(上限 16 MiB),采样间隔从 100ms 起,增长时减半、稳定时乘 4。