一个慢请求卡住同一连接上后面的请求
主线位置:两条主线都有。服务端主线上,
Dispatcher要等一个响应连同 body 写完,才读下一个请求头;客户端主线上,SendRequest要等连接任务读完上一个响应、再次说「要」,才放下一个请求进来,连接池也要等到这时才把连接放回去。
flowchart TB
slow["/slow 在连接 1 上"] --> q{"/fast 走哪条路"}
q -->|"同一个 SendRequest"| wait["等 /slow 的 body 读完"]
q -->|"legacy Client,HTTP/1"| newconn["新建连接 2,不等"]
q -->|"legacy Client,HTTP/2"| stream["同一连接的新流,不等"]
stream -->|"流数到上限"| h2wait["在 h2 里排队"]
newconn -->|"1.12.0 之前偶尔"| stale["被交给忙着的连接 1"]
stale --> wait
服务端这一侧的串行在 Dispatcher 一章已经用 pipelining 实验看过:三个请求一次写进去,服务按顺序一个一个处理,后一个要等前一个的响应连同 body 写完。可 pipelining 要客户端在一条连接上不等响应就接着发,实际的客户端很少这样做,hyper 自己的客户端也不会(下面 A 部分就能看到)。所以「慢请求卡住后面的请求」在现实里是不是问题,要从客户端那一侧看:后面的请求会不会被放到同一条连接上。
复现:从客户端看
服务端用 auto::Builder,给每条连接编号、写进响应头 x-conn。/slow 立刻返回响应头和第一块 body,300 ms 后才发最后一块;/fast 立刻返回。/slow 发出 50 ms 后发 /fast:
- A:一条 HTTP/1 连接,
client::conn::http1,两个请求用同一个SendRequest; - B:legacy
Client,HTTP/1; - C:legacy
Client,http2_only(true)(明文 HTTP/2); - D:同时发 20 个
/slow,HTTP/1 和 HTTP/2 各一次,看建了几条连接。
hyper = { version = "=1.12.0", features = ["client", "server", "http1", "http2"] }
hyper-util = { version = "=0.1.21", features = ["client-legacy", "server-auto", "http1", "http2", "tokio"] }
http-body-util = "=0.1.5"
bytes = "1"
futures = "0.3"
tokio = { version = "=1.54.1", features = ["full"] }
use std::convert::Infallible;
use std::net::SocketAddr;
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::sync::Arc;
use std::time::{Duration, Instant};
use bytes::Bytes;
use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full, StreamBody};
use hyper::body::{Frame, Incoming};
use hyper::client::conn::http1 as client_h1;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::client::legacy::{connect::HttpConnector, Client};
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto;
use tokio::net::{TcpListener, TcpStream};
type B = BoxBody<Bytes, Infallible>;
// /slow: response head at once, body ends 300 ms later. /fast: "ok".
async fn handler(req: Request<Incoming>, conn: usize) -> Result<Response<B>, Infallible> {
let body = if req.uri().path() == "/slow" {
let s = futures::stream::unfold(0, |i| async move {
match i {
0 => Some((Ok::<_, Infallible>(Frame::data(Bytes::from("start;"))), 1)),
1 => {
tokio::time::sleep(Duration::from_millis(300)).await;
Some((Ok(Frame::data(Bytes::from("end"))), 2))
}
_ => None,
}
});
StreamBody::new(s).boxed()
} else {
Full::new(Bytes::from("ok")).boxed()
};
let mut res = Response::new(body);
res.headers_mut().insert("x-conn", conn.into());
Ok(res)
}
// auto server; numbers connections, returns the accept counter
async fn server(max_streams: Option<u32>) -> (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, SeqCst) + 1;
tokio::spawn(async move {
let mut b = auto::Builder::new(TokioExecutor::new());
b.http2().max_concurrent_streams(max_streams);
let _ = b.serve_connection(TokioIo::new(stream), service_fn(move |req| handler(req, n))).await;
});
}
});
(addr, accepts)
}
fn clock() -> impl Fn() -> u128 + Copy {
let start = Instant::now();
move || start.elapsed().as_millis()
}
async fn part_a() {
println!("--- A. one HTTP/1 connection (client::conn::http1)");
let ms = clock();
let (addr, _) = server(None).await;
let io = TokioIo::new(TcpStream::connect(addr).await.unwrap());
let (mut tx, conn) = client_h1::handshake::<_, Empty<Bytes>>(io).await.unwrap();
tokio::spawn(conn);
let slow = tx.send_request(Request::get("/slow").body(Empty::new()).unwrap());
let slow = tokio::spawn(async move {
let res = slow.await.unwrap();
println!("{:>4}ms /slow head", ms());
res.into_body().collect().await.unwrap();
println!("{:>4}ms /slow body done", ms());
});
tokio::time::sleep(Duration::from_millis(50)).await;
let r = tx.send_request(Request::get("/fast").body(Empty::new()).unwrap()).await;
println!("{:>4}ms /fast sent without ready(): {:?}", ms(), r.map(|r| r.status()));
tx.ready().await.unwrap();
println!("{:>4}ms ready() returned", ms());
let res = tx.send_request(Request::get("/fast").body(Empty::new()).unwrap()).await.unwrap();
println!("{:>4}ms /fast done: {}", ms(), res.status());
slow.await.unwrap();
}
async fn slow_then_fast(label: &str, client: Client<HttpConnector, Empty<Bytes>>, addr: SocketAddr, accepts: Arc<AtomicUsize>) {
println!("--- {label}");
let ms = clock();
let c = client.clone();
let slow = tokio::spawn(async move {
let res = c.get(format!("http://{addr}/slow").parse().unwrap()).await.unwrap();
let conn = res.headers()["x-conn"].to_str().unwrap().to_string();
res.into_body().collect().await.unwrap();
println!("{:>4}ms /slow done on conn #{conn}", ms());
});
tokio::time::sleep(Duration::from_millis(50)).await;
let res = client.get(format!("http://{addr}/fast").parse().unwrap()).await.unwrap();
println!("{:>4}ms /fast done on conn #{}", ms(), res.headers()["x-conn"].to_str().unwrap());
slow.await.unwrap();
println!("accepts: {}", accepts.load(SeqCst));
}
async fn many_slow(label: &str, client: Client<HttpConnector, Empty<Bytes>>, addr: SocketAddr, accepts: Arc<AtomicUsize>, n: usize) {
println!("--- {label}");
let ms = clock();
let mut tasks = Vec::new();
for _ in 0..n {
let c = client.clone();
tasks.push(tokio::spawn(async move {
let res = c.get(format!("http://{addr}/slow").parse().unwrap()).await.unwrap();
res.into_body().collect().await.unwrap();
}));
}
for t in tasks {
t.await.unwrap();
}
println!("{:>4}ms {n} x /slow done, accepts: {}", ms(), accepts.load(SeqCst));
}
fn h1_client() -> Client<HttpConnector, Empty<Bytes>> {
Client::builder(TokioExecutor::new()).build_http()
}
fn h2_client() -> Client<HttpConnector, Empty<Bytes>> {
Client::builder(TokioExecutor::new()).http2_only(true).build_http()
}
#[tokio::main]
async fn main() {
part_a().await;
let (addr, accepts) = server(None).await;
slow_then_fast("B. legacy Client, HTTP/1", h1_client(), addr, accepts).await;
let (addr, accepts) = server(None).await;
slow_then_fast("C. legacy Client, HTTP/2", h2_client(), addr, accepts).await;
let (addr, accepts) = server(None).await;
many_slow("D. 20 at once, HTTP/1", h1_client(), addr, accepts, 20).await;
let (addr, accepts) = server(None).await;
many_slow("D. 20 at once, HTTP/2", h2_client(), addr, accepts, 20).await;
}
M2 Max、rustc 1.99.0、--release,其中一次的输出:
--- A. one HTTP/1 connection (client::conn::http1)
0ms /slow head
53ms /fast sent without ready(): Err(hyper::Error(Canceled, "connection was not ready"))
303ms /slow body done
303ms ready() returned
304ms /fast done: 200 OK
--- B. legacy Client, HTTP/1
53ms /fast done on conn #2
303ms /slow done on conn #1
accepts: 2
--- C. legacy Client, HTTP/2
53ms /fast done on conn #1
304ms /slow done on conn #1
accepts: 1
--- D. 20 at once, HTTP/1
304ms 20 x /slow done, accepts: 20
--- D. 20 at once, HTTP/2
305ms 20 x /slow done, accepts: 1
连跑 3 次,结论和连接编号每次相同,毫秒数差 2 ms 以内;A 里 /slow body done 和 ready() returned 落在同一毫秒,先后会互换。
- A:
/slow的响应头 0 ms 就到了,可连接直到 body 读完(303 ms)才重新可用。50 ms 时不等就发/fast,hyper 不是把它排在后面,而是直接拒绝:Canceled, "connection was not ready"(buffered_once那一次特权在第一个请求就用掉了,见 conn::http1 客户端)。ready().await则一直等到 303 ms。这就是「卡住」:用同一个 HTTP/1SendRequest,后一个请求必须等前一个的整个响应。 - B:legacy
Client下,/fast50 ms 后在新连接 #2 上立刻完成,没有被/slow拖住。代价是多了一条连接。 - C:HTTP/2 下,
/fast在同一条连接 #1 上作为一个新流立刻完成。 - D:20 个并发的慢请求,HTTP/1 建了 20 条连接,HTTP/2 只用 1 条。耗时一样,都是 300 ms 多一点。
池子为什么不把忙着的连接借出去
legacy Client 拿连接时只从空闲列表里找;一条 HTTP/1 连接在请求在途时根本不在空闲列表里。它什么时候回去,由 try_send_request 在收到响应头时决定(第 358 行):
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);
}
drop(pooled) 就是放回池里(Pooled 的 Drop)。HTTP/1 连接这时还在读 body,is_ready() 为假,于是另起一个小任务等 poll_ready,也就是等连接任务读完 body、回到空闲、再从请求通道取请求时发出 want 信号。在那之前,这条连接对池子是不可见的,后来的请求只能去新建连接(或者等别的连接回池,见连接池的「和新建连接赛跑」)。B 里 /fast 落在 #2 上就是这个原因。
整个判断压在 is_ready() 上:它为真,连接就被认为空闲。这个前提在 hyper 1.12.0 之前并不总成立。
1.12.0 之前:池子偶尔会借出忙着的连接
HTTP/1 的 is_ready() 报告的是 want 标志。连接任务发现请求通道空了,就把标志置成 Want;send_request 放请求进去之前先 give(),把它拨回 Idle。issue #4207 找到了两种让标志「过期」的走法:连接任务发现通道空、还没来得及 want(),另一个线程上的 send_request 已经 give() 并放进了请求,want() 落在 give() 之后;或者连接任务的 tokio 协作预算用完,poll_recv 明明有请求也返回 Pending,任务照样发出 want。两种情况下,连接任务取走请求时标志仍是 Want,请求在途期间它不再去看通道,标志就一直不变,is_ready() 一直为真。上面那段代码于是在收到响应头时就把这条还在传 body 的连接放回池里,下一个请求拿到它,排在整个响应后面。
#4208(首次发布于 v1.12.0)的修法是在 Receiver::poll_recv 取到请求时撤回残留的 want:
Poll::Ready(item) => {
// A want signaled after finding the queue empty can land after
// the Sender's `give()` for the message taken here, as can one
// signaled on a coop-budget Pending with a message queued.
// Withdraw it, or the Sender reports this connection as ready
// while it is still serving this message.
self.taker.unwant();
Poll::Ready(item.map(|mut env| env.0.take().expect("envelope not dropped")))
}
来龙去脉在 conn::http1 客户端的冷知识里。这里换个角度:它在 legacy Client 上能不能被观察到?下面的程序用一个 Client,8 个任务循环请求 /stream(5 块各 16 KiB,每 10 ms 一块,共约 50 ms),16 个任务循环请求立刻返回的 /probe,跑 10 秒,统计耗时超过 30 ms 的 /probe:
hyper = { version = "=1.12.0", features = ["server", "http1"] } # 另一次换成 "=1.11.1"
hyper-util = { version = "=0.1.21", features = ["client-legacy", "http1", "tokio"] }
http-body-util = "=0.1.5"
bytes = "1"
futures = "0.3"
tokio = { version = "=1.54.1", features = ["full"] }
use std::convert::Infallible;
use std::sync::atomic::{AtomicUsize, Ordering::SeqCst};
use std::sync::Arc;
use std::time::{Duration, Instant};
use bytes::Bytes;
use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full, StreamBody};
use hyper::body::{Frame, Incoming};
use hyper::server::conn::http1;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo};
use tokio::net::TcpListener;
type B = BoxBody<Bytes, Infallible>;
// /stream: 5 chunks of 16 KiB, one every 10 ms. Anything else: "ok".
async fn handler(req: Request<Incoming>) -> Result<Response<B>, Infallible> {
if req.uri().path() == "/stream" {
let s = futures::stream::unfold(0, |i| async move {
if i == 5 {
return None;
}
tokio::time::sleep(Duration::from_millis(10)).await;
Some((Ok::<_, Infallible>(Frame::data(Bytes::from(vec![b'x'; 16 * 1024]))), i + 1))
});
return Ok(Response::new(StreamBody::new(s).boxed()));
}
Ok(Response::new(Full::new(Bytes::from("ok")).boxed()))
}
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (stream, _) = listener.accept().await.unwrap();
tokio::spawn(http1::Builder::new().serve_connection(TokioIo::new(stream), service_fn(handler)));
}
});
let client: Client<_, Empty<Bytes>> = Client::builder(TokioExecutor::new()).build_http();
let deadline = Instant::now() + Duration::from_secs(10);
let probes = Arc::new(AtomicUsize::new(0));
let slow = Arc::new(AtomicUsize::new(0));
let max_us = Arc::new(AtomicUsize::new(0));
let mut tasks = Vec::new();
for _ in 0..8 {
let c = client.clone();
tasks.push(tokio::spawn(async move {
while Instant::now() < deadline {
let res = c.get(format!("http://{addr}/stream").parse().unwrap()).await.unwrap();
res.into_body().collect().await.unwrap();
}
}));
}
for _ in 0..16 {
let (c, probes, slow, max_us) = (client.clone(), probes.clone(), slow.clone(), max_us.clone());
tasks.push(tokio::spawn(async move {
while Instant::now() < deadline {
let t = Instant::now();
let res = c.get(format!("http://{addr}/probe").parse().unwrap()).await.unwrap();
res.into_body().collect().await.unwrap();
probes.fetch_add(1, SeqCst);
max_us.fetch_max(t.elapsed().as_micros() as usize, SeqCst);
if t.elapsed() > Duration::from_millis(30) {
slow.fetch_add(1, SeqCst);
}
tokio::time::sleep(Duration::from_millis(1)).await;
}
}));
}
for t in tasks {
t.await.unwrap();
}
println!(
"probes: {}, slower than 30 ms: {}, slowest: {} ms",
probes.load(SeqCst),
slow.load(SeqCst),
max_us.load(SeqCst) / 1000
);
}
hyper 1.12.0 和 1.11.1 各编译一份,交替各跑 3 次(行首的版本号是外面的 shell 循环加的;两边的 want 都解析到 0.3.2,差别只在 hyper):
1.12.0: probes: 59990, slower than 30 ms: 0, slowest: 2 ms
1.11.1: probes: 60115, slower than 30 ms: 7, slowest: 55 ms
1.12.0: probes: 60015, slower than 30 ms: 0, slowest: 3 ms
1.11.1: probes: 60333, slower than 30 ms: 6, slowest: 57 ms
1.12.0: probes: 59860, slower than 30 ms: 0, slowest: 1 ms
1.11.1: probes: 60920, slower than 30 ms: 5, slowest: 57 ms
1.11.1 每次有 5 到 7 个 /probe 花了 30 ms 以上,最慢 55 到 57 ms,正好是「排在一个约 50 ms 的 /stream 响应后面」的时长;1.12.0 一个都没有,最慢 3 ms。六万次里几次,量不大,但它出现在「短请求和长流式响应共用一个 Client」这种很普通的用法里,没有任何选项能关掉。issue 的报告者是在一个反向代理里撞上的,短的探测请求经常排在流式请求后面等几百毫秒到几秒。本站锁的是 hyper 1.10.1,出站请求走的 reqwest 底下就是 legacy Client(见本站的出站客户端),在这个修复之前。
那么,什么时候真的会卡
| 场景 | 会不会卡 | 说明 |
|---|---|---|
| 服务端,客户端不用 pipelining | 不会 | 一个慢处理函数只占住它自己那条连接;每条连接是独立的 future,别的连接照常处理 |
| 服务端,客户端用 pipelining | 会 | 后面的请求已经在读缓冲里,但要等前一个响应写完(Dispatcher) |
客户端共用一个 HTTP/1 SendRequest(比如包在 Mutex 里) | 会 | A:后一个请求要等前一个的整个响应,包括 body |
legacy Client(含 reqwest),HTTP/1,hyper 1.12.0 及以后 | 不会 | B:忙着的连接不在空闲列表里,后来的请求新建连接;代价是连接数,D 里 20 个慢请求就是 20 条连接 |
| 同上,hyper 1.12.0 之前 | 偶尔 | is_ready() 过期,池子把忙着的连接借出去(#4207) |
legacy Client,HTTP/2 | 不会,直到流数上限 | C、D:一条连接上并发;超过服务端 MAX_CONCURRENT_STREAMS 后,新请求在 h2 里排队(下面的动脑筋) |
HTTP/1 在现实里的主要代价不是「卡住」,而是 D 那一行:并发多少,连接就开多少,每条都要握手(TLS 更贵),服务端为每条连接维持一个任务和一个 socket。legacy Client 没有「每个主机最多几条连接」的上限(pool_max_idle_per_host 只限空闲的),所以它不会让请求排队等连接,连接数会一直涨上去;要限制,得在外面自己加,比如 tower 的 ConcurrencyLimit,而一旦限制了,慢请求就会占住名额,排队又回来了,只是排在名额上而不是某一条连接上。
问: 既然 HTTP/2 能在一条连接上并发,legacy Client 为什么默认还用 HTTP/1?
答: 因为它不知道对端会不会说 HTTP/2。明文连接上,HTTP/2 只能靠「先验知识」直接发前言,对端不支持就失败,所以要你自己声明 http2_only(true),C 和 D 就是这么做的。TLS 连接上可以靠 ALPN 协商:连接器在 Connected 里报告协商出了 h2,池子就把这条连接当 HTTP/2 用、在同一个键下共享(连接池的「HTTP/2:一条连接大家共用」)。所以对 HTTPS 服务,只要 TLS 连接器开了 h2 的 ALPN、服务端也支持,legacy Client 本来就会用上 HTTP/2。
HTTP/2 也不是没有上限:一条连接能同时开多少个流,由服务端在 SETTINGS 里的 max_concurrent_streams 决定。
把服务端的 max_concurrent_streams 设成 2,同一个 http2_only 的 Client 先发一个请求预热(让客户端收到服务端的 SETTINGS),然后同时发 3 个 /slow,50 ms 后发 /fast。handler、server、clock、h2_client 和上面的程序相同,只换 main:
#[tokio::main]
async fn main() {
let ms = clock();
let (addr, accepts) = server(Some(2)).await;
let client = h2_client();
// warm up: one request so the client has seen the server's SETTINGS
client.get(format!("http://{addr}/fast").parse().unwrap()).await.unwrap();
let mut tasks = Vec::new();
for i in 0..3 {
let c = client.clone();
tasks.push(tokio::spawn(async move {
let res = c.get(format!("http://{addr}/slow").parse().unwrap()).await.unwrap();
res.into_body().collect().await.unwrap();
println!("{:>4}ms /slow #{i} done", ms());
}));
}
tokio::time::sleep(Duration::from_millis(50)).await;
client.get(format!("http://{addr}/fast").parse().unwrap()).await.unwrap();
println!("{:>4}ms /fast done", ms());
for t in tasks {
t.await.unwrap();
}
println!("accepts: {}", accepts.load(SeqCst));
}
/fast 在第几毫秒完成?池子会不会为它另开一条连接(accepts 是几)?答案在本页末尾。
修法
| 情况 | 怎么办 |
|---|---|
自己用 client::conn::http1,多个调用方共用一个 SendRequest | 换成 legacy Client(或自己维护多条连接),或者改用 HTTP/2 的 SendRequest,它可以 clone、并发 |
| 短请求偶尔排在长流式响应后面,hyper 在 1.12.0 之前 | 升级到 hyper 1.12.0;不能升级时,长短请求分用两个 Client(各自一个池子) |
| HTTP/1 并发高,连接数太多 | 能用 HTTP/2 就用;不能用就接受连接数,或在外面加并发上限,并给慢请求单独的上限 |
| HTTP/2 下快请求被慢请求拖住 | 看服务端的 MAX_CONCURRENT_STREAMS(hyper 服务端默认 200)是否用满;长流式请求和短请求分开连接(分两个 Client),或者调大上限 |
| 服务端担心 pipelining 的串行 | 不用管:串行是 HTTP/1 的语义,响应必须按请求的顺序回去 |
动脑筋答案
其中一次的输出:
304ms /slow #1 done
304ms /slow #0 done
304ms /fast done
606ms /slow #2 done
accepts: 1
连跑 3 次:/fast 每次都在 303 到 306 ms 之间完成,accepts 每次都是 1;哪个 /slow 排到第二轮(606 到 608 ms 完成)每次不同,前三行的先后也会变。
/fast在约 304 ms 才完成,虽然它 50 ms 就发出了,本身不需要任何时间。服务端只允许两个并发流,/slow#0 和 #1 占满了,#2 和/fast都在客户端的 h2 里排队(「pending open」,见 HTTP/2 客户端的没有蠢问题),等前两个流在 300 ms 时结束才开出去。哪两个/slow先拿到流,每次运行不一样,但总有一个排到第二轮,606 ms 才完成。accepts: 1:池子没有为排队的请求再开一条连接。HTTP/2 连接在池里是共享的,一个键只放一条,检查也只看连接是否关闭,不看流数用了多少。所以 HTTP/2 不是没有队头阻塞,而是把它从「一条连接一个请求」挪到了「一条连接 N 个流」:并发流用满之后,一个慢请求照样能拖住后面的快请求。
小结
- hyper 的 HTTP/1 一条连接同一时间只跑一个请求,下一个要等上一个响应连同 body 结束,两端都是。服务端只有在客户端 pipelining 时才会让请求排队,而实际的客户端很少这样做。
- 客户端这一侧,
client::conn::http1的SendRequest在连接忙时直接拒绝(connection was not ready),ready()要等到上一个响应的 body 读完;共用一个SendRequest就会串行。 - legacy
Client只把is_ready()为真的连接放回池里,忙着的 HTTP/1 连接不会被借出去,后来的请求新建连接:不卡,但连接数随并发增长。 - hyper 1.12.0 之前
is_ready()会过期(#4207 / #4208),池子偶尔把忙着的连接借出去,短请求排到长响应后面;实测 1.11.1 十秒内 5 到 7 次,1.12.0 为 0。 - HTTP/2 在一条连接上并发,但受服务端
MAX_CONCURRENT_STREAMS限制;用满之后新请求在 h2 里排队,legacyClient不会为此多开连接。