请求卡住不动:Buffer 的位置被占光
主线位置:就绪主线。
Buffer::poll_ready不问内层,只在有界通道里预留一个位置。位置被占满时,所有调用方的poll_ready都Pending;占着位置的人如果又在等别人,就成了死锁。
flowchart TB t1["任务 1、2:ready 成功"] --> hold["各占一个位置,bound = 2"] hold --> wait1["去等屏障,还没 call"] t3["任务 3、4:ready"] --> full["通道满:Pending"] wait1 -->|"屏障要等 4 个人"| full full -->|"永远到不了屏障"| wait1
这一页的现象没有错误信息:没有 panic,没有 Err,请求就是不返回,内层服务的日志里也没有它。
复现
四个任务共用一个 Buffer。每个任务 ready() 之后,要先和其他任务在一个 Barrier 前会合,才去 call。Barrier 在这里代表任何「ready 之后、call 之前还要等的东西」:一把锁、另一个服务的就绪、另一个请求的结果。
tower = { version = "=0.5.3", features = ["buffer", "util"] }
tokio = { version = "=1.54.1", features = ["full", "test-util"] }
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Barrier;
use tower::buffer::Buffer;
use tower::{BoxError, Service, ServiceExt, service_fn};
// `tasks` tasks share one Buffer. Each one has to meet the others at a
// barrier (think: wait for a lock, or for another request) before calling.
async fn run(bound: usize, tasks: usize, ready_first: bool) {
let svc = Buffer::new(service_fn(|x: usize| async move { Ok::<_, BoxError>(x) }), bound);
let barrier = Arc::new(Barrier::new(tasks));
let mut handles = Vec::new();
for i in 0..tasks {
let mut svc = svc.clone();
let barrier = barrier.clone();
handles.push(tokio::spawn(async move {
if ready_first {
svc.ready().await?; // reserve a slot ...
barrier.wait().await; // ... then wait for something else
} else {
barrier.wait().await; // wait first ...
svc.ready().await?; // ... then reserve and call right away
}
svc.call(i).await
}));
}
let all = async {
for h in handles {
h.await.unwrap().unwrap();
}
};
let order = if ready_first { "ready, wait, call" } else { "wait, ready, call" };
match tokio::time::timeout(Duration::from_secs(10), all).await {
Ok(()) => println!("bound {bound}, {tasks} tasks, {order}: all done"),
Err(_) => println!("bound {bound}, {tasks} tasks, {order}: stuck after 10s"),
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
run(2, 4, true).await;
run(4, 4, true).await;
run(2, 4, false).await;
}
时钟是暂停的,「10 秒」不用真等。M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
bound 2, 4 tasks, ready, wait, call: stuck after 10s
bound 4, 4 tasks, ready, wait, call: all done
bound 2, 4 tasks, wait, ready, call: all done
内层是一个永远就绪的 service_fn,第一行照样卡死。
为什么
Buffer::poll_ready 只做一件事:self.tx.poll_reserve(cx),在 mpsc 通道里给这个句柄预留一个位置。位置记在句柄的 PollSender 里,有两种方式还回去:
call:send_item用掉这个位置,把消息放进通道;worker 取走消息时位置才空出来。- drop 句柄:预留随
PollSender一起释放(Buffer 的「bound 怎么选」一节有实验)。
「ready 了、还没 call、句柄也还活着」的时候,位置一直被占着,哪怕内层完全空闲,worker 也帮不上忙:它只能处理通道里已有的消息,而这几个任务一条消息都还没发。第一行里,任务 1、2 占满了 bound = 2,在屏障前等另外两个;任务 3、4 在 poll_ready 上等位置,永远到不了屏障。
这和 Service trait 的规则 2、规则 4 是一回事:就绪是一种「已占到」的状态,call 可能永远不来。Buffer 保证 drop 时归还,但「不来、也不走」的句柄它管不了。Buffer::new 的文档(第 36–44 行)把这种情况写得很直白:「all the slots in the buffer may be held up by futures that have just called [poll_ready] but will not issue a [call], which prevents other senders from issuing new requests.」
Buffer 的动脑筋里算过:内层卡住时,Buffer 能收下 bound + 1 个请求,多出来的一个在 worker 手里,不占通道。那么把上面的 main 换成只跑一行:
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
run(3, 4, true).await;
}
bound + 1 = 4,正好等于任务数。打印出来是 all done 还是 stuck after 10s?答案在本页末尾。
修法
两种,取决于你能改哪一边:
| 修法 | 做法 | 对应上面哪一行 |
|---|---|---|
加大 bound | 不小于「ready 之后可能去等别的东西」的任务数 | 第二行:bound 4 |
| 改调用方 | 把要等的东西放到 ready() 前面,ready().await 之后立刻 call | 第三行:bound 2 也跑完 |
第二种更可靠:bound 要按句柄数算,一个任务先 ready 了一个句柄,又经另一个句柄发请求,它一个人就占两个位置,很难估准。svc.ready().await?.call(req) 写成一个表达式、或者直接用 oneshot,中间没有 .await 的机会,位置从预留到用掉不会被别的等待隔开。
如果 ready 之后确实可能改主意(比如在 select! 里,另一个分支先完成了),就把这个句柄 drop 掉,或者换成它的一个新 clone:新 clone 不带预留,旧的一 drop,位置就还回去了。(另跑了一个 bound = 1 的小程序:h ready 之后,另一个句柄 5 秒内 ready 不了;执行 h = h.clone(); 之后立刻 ready。连跑 3 次相同。)
Buffer::new 上那段关于 bound 的说明是 2020 年 3 月的 PR #425「Add Buffer::new note on how to set bound」加的,第一个包含它的发布是 tower 0.4.0。PR 说明写的是:「Related to https://github.com/tower-rs/tower/issues/408. While the above issue is resolved, tower-buffer should document its behavior and offer guidance on setting the channel size.」
它引用的 issue #408「Disarming Service after successful poll_ready」到今天还开着。issue 的原话:「Currently if poll_ready returns Ready it effectively reserves something (for instance a semaphore token). This means you must be following up with call next. The only other option is to Drop the service which however is not always possible.」提议给 Service 加一个 disarm_ready,撤销 poll_ready 占下的东西。讨论里有人给出了 Buffer 句柄的变通办法:「we can work around this by cloning the Handle, dropping the old one, and replacing it with the clone to give up the slot」,也就是上一节最后一段的做法;同时指出泛型代码(只知道 S: Service)用不了这一招。维护者担心的是改 trait 要整个生态再升级一次。所以 0.5.3 里,释放预留的办法仍然只有 call 和 drop。
另一种卡法:响应 future 没人 poll
Buffer 里面套着 ConcurrencyLimit 时,许可跟着内层 future 走(见 ConcurrencyLimit),而内层 future 是送回调用方、在调用方的任务里 poll 的(见 Buffer)。调用方拿到响应 future 却不 poll,许可就不还,worker 卡在 ConcurrencyLimit::poll_ready 上,后面的请求全部排着:
tower = { version = "=0.5.3", features = ["buffer", "limit", "util"] }
tokio = { version = "=1.54.1", features = ["full", "test-util"] }
use std::time::Duration;
use tower::{BoxError, Service, ServiceBuilder, ServiceExt, service_fn};
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let mut svc = ServiceBuilder::new()
.buffer(8)
.concurrency_limit(1)
.service(service_fn(|x: u32| async move { Ok::<_, BoxError>(x) }));
let a = svc.ready().await.unwrap().call(1);
let b = svc.ready().await.unwrap().call(2);
// Wait for b first; a is not polled until b is done.
match tokio::time::timeout(Duration::from_secs(10), b).await {
Ok(r) => println!("b = {}", r.unwrap()),
Err(_) => println!("b: stuck after 10s"),
}
println!("a = {}", a.await.unwrap());
}
连跑 3 次输出相同:
b: stuck after 10s
a = 1
请求 a 拿到了唯一的许可,它的内层 future(连同许可)已经送到 a 的响应 future 里,但没人 poll。请求 b 停在 worker 手里,等 ConcurrencyLimit 的许可。先等 b 再等 a,就是互相等。超时以后去 poll a,a 立刻完成。bound 再大也没用,这里占住的不是通道位置,是内层的许可。要并发等多个响应,用 join!、FuturesUnordered 一起 poll,不要按固定顺序一个个 .await。
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同:
bound 3, 4 tasks, ready, wait, call: stuck after 10s
卡住。bound + 1 里多出来的那一个,是 worker 已经从通道里取走、因为内层没就绪而拿在手里的消息。要被取走,消息得先发进通道,也就是先 call。这里前三个任务 ready 之后都在屏障前等,一条消息都没发,worker 手里什么也没有,第四个任务只能等通道里的位置,而三个位置都被预留着。bound + 1 说的是「内层卡住时能收下几个已经 call 的请求」,不是「能有几个句柄同时处于就绪状态」;后者就是 bound。
小结
Buffer::poll_ready预留一个通道位置,只有call或 drop 句柄才归还;内层是否空闲无关。- ready 之后又去等别的东西的句柄多于
bound,所有调用方都会卡在poll_ready上,没有任何错误。 - 修法:
bound不小于同时可能处于「已就绪未 call」的句柄数;更可靠的是ready().await后立刻call,要等的东西放到 ready 前面;改主意时 drop 句柄。 Buffer里套ConcurrencyLimit时,响应 future 不 poll 就不还许可,worker 会卡在内层的poll_ready上;这种卡法和bound无关。