在途请求数只增不减
主线位置:结局主线的「什么算放弃」。
InFlightRequests的计数守卫在响应 future 完成时移进 body,只有 body 被 drop 才减一。机制和oneshot下的实测在 InFlightRequests;这一页换成真实的 TCP 连接,回答一个运维上的问题:数字下不来,是泄漏还是真的有那么多响应开着。
flowchart TB
callNode["call:加一"] --> fut["响应 future 完成:守卫移进 body"]
fut --> body["hyper 往连接里写 body"]
body --> q{"body 什么时候被 drop"}
q -->|"写完"| done["减一"]
q -->|"客户端断开,hyper 发现"| done
q -->|"流不结束、客户端也不断"| stay["一直算在途"]
复现
axum 服务两个流式端点:/ticking 每 200 ms 发一个 SSE 事件,/silent 发一个事件后永远挂起(模拟很久才有下一个事件的流)。客户端用裸 TCP 开三条连接,各读到第一个事件后就不再读,过一秒再逐个关掉 socket。
tower-http = { version = "=0.7.1", features = ["metrics"] }
axum = { version = "=0.8.9", default-features = false, features = ["tokio", "http1"] }
futures = "0.3"
tokio = { version = "=1.54.1", features = ["macros", "rt", "net", "time", "io-util"] }
use axum::body::{Body, Bytes};
use axum::routing::get;
use axum::Router;
use futures::stream::{self, StreamExt};
use std::convert::Infallible;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::{sleep, Instant};
use tower_http::metrics::InFlightRequestsLayer;
// 每 200 ms 一个事件,永不结束
fn ticking() -> Body {
let s = stream::unfold(0u32, |n| async move {
if n > 0 {
sleep(Duration::from_millis(200)).await;
}
Some((Ok::<_, Infallible>(Bytes::from(format!("data: tick {n}\n\n"))), n + 1))
});
Body::from_stream(s)
}
// 一个事件之后再也不发
fn silent() -> Body {
let first = stream::once(async { Ok::<_, Infallible>(Bytes::from("data: hello\n\n")) });
Body::from_stream(first.chain(stream::pending()))
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let (layer, counter) = InFlightRequestsLayer::pair();
let app = Router::new()
.route("/ticking", get(|| async { ticking() }))
.route("/silent", get(|| async { silent() }))
.layer(layer);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let start = Instant::now();
let t = move || start.elapsed().as_millis() / 50 * 50;
let open = |path: &'static str| async move {
let mut sock = TcpStream::connect(addr).await.unwrap();
sock.write_all(format!("GET {path} HTTP/1.1\r\nhost: x\r\n\r\n").as_bytes()).await.unwrap();
let mut buf = [0u8; 1024];
let _ = sock.read(&mut buf).await.unwrap();
sock
};
// 三个标签页各开一条事件流;客户端读到第一个事件后就不再读
let mut socks = vec![open("/ticking").await, open("/silent").await, open("/silent").await];
sleep(Duration::from_millis(50)).await;
println!("{:>5}ms three streams open, in flight = {}", t(), counter.get());
sleep(Duration::from_millis(1000)).await;
println!("{:>5}ms still open, in flight = {}", t(), counter.get());
while let Some(sock) = socks.pop() {
drop(sock);
sleep(Duration::from_millis(50)).await;
println!("{:>5}ms one client closed its socket, in flight = {}", t(), counter.get());
}
}
M2 Max、rustc 1.99.0,跑 3 次输出相同(时间按 50 ms 取整):
50ms three streams open, in flight = 3
1050ms still open, in flight = 3
1100ms one client closed its socket, in flight = 2
1150ms one client closed its socket, in flight = 1
1200ms one client closed its socket, in flight = 0
- 开着就算在途:三条流开着的一秒里,计数一直是 3。从
InFlightRequests看,它们和一个卡住的请求没有区别。 - 断开立刻减:
socks.pop()先关的是两条/silent,body 一直Pending、hyper 没有东西要写,计数照样在 50 ms 内减一。从结果看,hyper 在 body 等待期间也会发现对端关闭(这里没有去读 hyper 的源码确认是哪一处),随即结束连接、丢掉 body,守卫随之 drop(in_flight_requests.rs第 201 行 的Drop)。/ticking也一样。
所以在这个实验的条件下(HTTP/1.1、对端正常关闭连接),计数没有泄漏:每个数都对应一个还开着的响应。
什么时候真的下不来
- 长连接本来就多。SSE、长轮询、大文件下载在客户端开着的时候一直算在途;一个开着 SSE 的标签页就是一个永久的在途数。这个数更接近「打开着的响应数」,模块文档说请求「is considered complete when the response body is consumed, dropped, or an error happens」,流不结束就不算完成。
- 对端消失但没有关连接。手机切网络、中间设备静默丢包时,服务端收不到 FIN 或 RST,hyper 也就不知道连接断了,直到下一次写失败或者 TCP 层超时。
/silent这样很久不写的流会挂得更久。这一条没有在实验里复现(需要模拟丢包),未核实具体多久回落。 - 自己的代码攥着 body。比如在中间件里把响应 body 存起来没交出去,或者 body 读完了变量还在作用域里,见 InFlightRequests 的动脑筋。
怎么办
- 要看「正在处理的请求」,把长连接的路由排除在外:
InFlightRequestsLayer只套在普通路由上,SSE、下载单独挂。 - 要看「响应头出来之前的处理中请求」,这个层给不了,它的守卫在 body 里;可以自己写一个只包 future 的计数层,或者用 tower 的
PendingRequests思路(对照见 InFlightRequests)。 - 对端失联的连接,给流加心跳(SSE 的注释行),让写操作及时失败;axum 的
Sse::keep_alive就是做这个的。
小结
- 客户端关掉连接时,hyper 立刻丢掉 body,计数马上回落,即使 body 正在等待。
- 计数下不来,通常是 SSE、长轮询、下载还开着:它量的是打开着的响应。
- 对端静默消失时要等写失败或 TCP 超时(未实测);长连接路由单独挂,或者加心跳。
💬 关联讨论
发起讨论加载中...