read_timeout 把调用方的处理时间也算了进去
主线位置:响应主线,读 body 的那一段。
read_timeout在 body 上是一层ReadTimeoutBody,每次交出一帧就把计时器往后推。三种超时各管哪一段、0.12 和 0.13.5 的差别,见 超时。本页讲这个差别在流式消费时的后果。
sequenceDiagram participant S as 服务器 participant B as ReadTimeoutBody participant A as 调用方 S->>B: 4 块一次发完 A->>B: chunk B-->>A: 第 1 块 并重置计时 Note over A: 处理 300ms A->>B: chunk B-->>A: 计时已过 报超时
实验 crate:exp-reqwest/pitb-lab(src/bin/readgap.rs)。M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,release 构建,客户端 .no_proxy(),连跑 3 次,结果相同(毫秒数差 1 到 3)。服务器是 testsrv:?chunks=4&size=16 一次发完 4 块,每块 16 字节;加上 body_ms=1000 则每两块之间停 1 秒。
现象
下载一个流,边读边处理:写一个慢盘、往一个有界通道里塞、再转发给另一个慢的下游。客户端设了 read_timeout(200ms),用意是「服务器 200 毫秒没有新数据就放弃」:
let c = reqwest::Client::builder().no_proxy().read_timeout(RT).build().unwrap();
let mut r = c.get(srv.url(path)).send().await.unwrap();
// ...
let out = loop {
match r.chunk().await {
Ok(Some(_)) => {
n += 1;
tokio::time::sleep(Duration::from_millis(per_chunk_ms)).await;
}
// ...
服务器一口气把 4 块都发完了,从来没有沉默过:
[read_timeout, consumer 100ms/chunk] all 4 chunks (413ms)
[read_timeout, consumer 300ms/chunk] chunk #2 failed: is_timeout=true error decoding response body <- request or response body error <- operation timed out (304ms)
[read_timeout, 300ms before 1st chunk only] all 4 chunks (308ms)
- 每块处理 100 毫秒,没事。每块处理 300 毫秒,第 2 次
chunk()就报超时,这时第 2 块其实已经在缓冲区里了。 - 错误是
is_timeout()加Decode种类,Display是error decoding response body,看上去像是服务器卡住或者数据坏了。 - 拿到响应头之后、读第一块之前停 300 毫秒,不报错。只有「交出一块」到「再要下一块」之间的时间被算进去。
原因
let deadline = tokio::time::Instant::now() + *this.timeout;
let sleep_pinned = if let Some(some) = this.sleep.as_mut().as_pin_mut() {
some
} else {
this.sleep.set(Some(tokio::time::sleep_until(deadline)));
this.sleep.as_mut().as_pin_mut().unwrap()
};
// Error if the timeout has expired.
if let Poll::Ready(()) = sleep_pinned.poll(cx) {
return Poll::Ready(Some(Err(crate::error::body(crate::error::TimedOut))));
}
let item = ready!(this.inner.poll_frame(cx))
.map(|opt_chunk| opt_chunk.map_err(crate::error::body));
// a ready frame means timeout is reset for the next chunk
if let Some(some) = this.sleep.as_mut().as_pin_mut() {
some.reset(tokio::time::Instant::now() + *this.timeout);
}
Poll::Ready(item)
两处合起来造成这个现象:
- 交出一帧的同时
reset(now + timeout):下一次的期限从「交出时刻」算起,调用方拿着这一块做事的时间全在里面。 - 每次 poll 先看计时器,再去读内层 body:期限一过,就算下一块已经到了,也先报超时。
第一块之前不受影响,是因为 sleep 一开始是 None,第一次 poll 才创建,从那一刻算。
这是 #3085(「perf(body): reuse tokio::time::Sleep timer via reset() in ReadTimeoutBody」,ffda263)带来的,首次发布在 v0.13.5,CHANGELOG 没有提到。改之前,交出一帧后是 this.sleep.set(None),下一次 poll 才新建计时器,处理时间不算。0.12.28 上同一个实验 4 块都读得到,对照输出在 超时 的动脑筋答案里。tag 之后 master 上 src/async_impl/body.rs 没有新提交。
修法
不设 read_timeout,自己给「等下一块」计时。 tokio::time::timeout 只包住 chunk() 这一个 future,处理数据的时间在它外面:
let c = reqwest::Client::builder().no_proxy().build().unwrap();
let mut r = c.get(srv.url(path)).send().await.unwrap();
// ...
let out = loop {
match tokio::time::timeout(RT, r.chunk()).await {
Ok(Ok(Some(_))) => {
n += 1;
tokio::time::sleep(Duration::from_millis(per_chunk_ms)).await;
}
Ok(Ok(None)) => break format!("all {n} chunks"),
Ok(Err(e)) => break format!("chunk #{} failed: {}", n + 1, pitb_lab::chain(&e)),
Err(elapsed) => break format!("chunk #{}: no data for {RT:?} ({elapsed})", n + 1),
}
};
同一个慢消费者,以及一个真的每两块之间停 1 秒的服务器:
[timeout(chunk()), consumer 300ms/chunk] all 4 chunks (1209ms)
[timeout(chunk()), server stalls 1s] chunk #2: no data for 200ms (deadline has elapsed) (203ms)
慢消费者读完了 4 块;服务器真的沉默时,200 毫秒后放弃。这正是当初想要的语义。超时被丢掉的只是 chunk() 这个 future,Response 还在,可以决定重试或者放弃。
响应头之前那一段,read_timeout 本来就是一个从 send() 起算的固定期限(见 超时),去掉它以后用 connect_timeout 和 RequestBuilder::timeout / 外面再包一层 tokio::time::timeout 补上。
用 bytes() / text() 一次读完的代码不受影响:它们在读完之前不交还控制权,两次 poll 之间没有调用方的时间。
怎么检查
- 错误满足
e.is_timeout(),Display是error decoding response body,出现在读 body 的循环里,而且失败的位置和调用方每块的处理时间有关:处理越慢,越早失败。 - 把处理逻辑临时换成空操作再跑一次,问题消失,就是这个坑。
- 查
Cargo.lock里 reqwest 的版本:v0.13.5 有这个行为,0.12.28 没有。
小结
- 0.13.5 的
read_timeout在交出一块时重置计时器,下一块的期限包括调用方处理这一块的时间;期限一过,数据已经到了也报超时。 - 症状是读流时
error decoding response body、is_timeout()为真,失败点随处理速度变化。 - 来源是 #3085(
ffda263,v0.13.5),一个标题为性能优化的提交。 - 修法:不设
read_timeout,用tokio::time::timeout只包住每次chunk();一次读完的bytes()/text()不受影响。