fast 请求等了 2000ms:Steer 的队头阻塞
主线位置:就绪主线。
Steer在poll_ready时还不知道下一个请求发往哪个后端,只能把所有后端都问到就绪;任何一个后端的Pending都会挡住整个Steer。
flowchart TB
pr["Steer::poll_ready"] --> e{not_ready 空?}
e -->|是| ok["Ready(Ok)"]
e -->|否| head["问队首那个后端"]
head -->|Pending| pend["整个 Steer Pending"]
head -->|Ready| pop[弹出队首]
pop --> e
c["call(req)"] --> pick["picker 选下标 idx"]
pick --> push["idx 回到队尾"]
Steer 按请求内容把请求分给一组后端中的一个,比如按 key 分片。它本身的结构见 Steer 与 MakeService,这一页只讲它最常让人意外的地方:后端之间互相拖累。
复现
两个后端:slow 一次只处理一个请求、每个耗时 1 秒;fast 立即返回。三个任务各拿一份 Steer 的 clone,同时发出 slow-1、slow-2、fast-1:
tower = { version = "=0.5.3", features = ["steer", "buffer", "limit", "util"] }
tokio = { version = "=1.54.1", features = ["full", "test-util"] }
use std::time::Duration;
use tokio::time::{sleep, Instant};
use tower::buffer::Buffer;
use tower::steer::Steer;
use tower::util::BoxCloneSyncService;
use tower::{service_fn, BoxError, Service, ServiceBuilder, ServiceExt};
type Svc = BoxCloneSyncService<String, String, BoxError>;
// "slow" handles one request at a time and takes 1s; "fast" answers at once.
fn backends() -> Vec<Svc> {
let slow = ServiceBuilder::new()
.concurrency_limit(1)
.service_fn(|r: String| async move {
sleep(Duration::from_secs(1)).await;
Ok::<_, BoxError>(format!("slow handled {r}"))
});
let fast = service_fn(|r: String| async move { Ok::<_, BoxError>(format!("fast handled {r}")) });
vec![BoxCloneSyncService::new(slow), BoxCloneSyncService::new(fast)]
}
fn pick<S>(r: &String, _: &[S]) -> usize {
if r.starts_with("slow") { 0 } else { 1 }
}
async fn run<S>(name: &str, steer: S)
where
S: Service<String, Response = String, Error = BoxError> + Clone + Send + 'static,
S::Future: Send,
{
println!("{name}");
let start = Instant::now();
let mut tasks = Vec::new();
for r in ["slow-1", "slow-2", "fast-1"] {
let mut steer = steer.clone();
tasks.push(tokio::spawn(async move {
let res = steer.ready().await?.call(r.to_string()).await?;
Ok::<_, BoxError>(format!(" {res} at {}ms", start.elapsed().as_millis()))
}));
}
for t in tasks {
println!("{}", t.await.unwrap().unwrap());
}
}
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
run("Steer over bare backends", Steer::new(backends(), pick)).await;
let buffered: Vec<_> = backends().into_iter().map(|s| Buffer::new(s, 64)).collect();
run("Steer over Buffer(64) backends", Steer::new(buffered, pick)).await;
}
M2 Max、rustc 1.99.0、--release,时钟暂停,连跑 3 次输出相同:
Steer over bare backends
slow handled slow-1 at 1000ms
slow handled slow-2 at 2000ms
fast handled fast-1 at 2000ms
Steer over Buffer(64) backends
slow handled slow-1 at 1000ms
slow handled slow-2 at 2000ms
fast handled fast-1 at 0ms
fast-1 发往一个立即返回的后端,却等了 2000ms,和排在它前面的两个 slow 请求一起完成。第二组每个后端套了 Buffer(64),fast-1 回到 0ms。
源码:所有后端都就绪才算就绪
poll_ready 和 call:
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
loop {
// must wait for *all* services to be ready.
// this will cause head-of-line blocking unless the underlying services are always ready.
if self.not_ready.is_empty() {
return Poll::Ready(Ok(()));
} else {
if self.services[self.not_ready[0]]
.poll_ready(cx)?
.is_pending()
{
return Poll::Pending;
}
self.not_ready.pop_front();
}
}
}
fn call(&mut self, req: Req) -> Self::Future {
assert!(
self.not_ready.is_empty(),
"Steer must wait for all services to be ready. Did you forget to call poll_ready()?"
);
let idx = self.router.pick(&req, &self.services[..]);
let cl = &mut self.services[idx];
self.not_ready.push_back(idx);
cl.call(req)
}
not_ready是一个下标队列。Steer::new把所有下标都放进去。poll_ready从队首开始问,就绪一个弹一个,遇到第一个Pending就返回,后面的后端这一轮连问都不问。call只把用掉的那个放回队尾。 其他后端保持就绪,它们为就绪占下的资源(许可、通道位置)继续占着,直到被选中或者Steer被 drop。- picker 在
call里才运行,poll_ready时请求还没来。这正是只能「全部就绪」的原因,文档 说得很直白:「Steermust wait for all services to be ready since it can't know ahead of time whichServicethe next message will arrive for, and is unwilling to buffer items indefinitely. This will cause head-of-line blocking...」
回到实验。三份 clone 的 not_ready 都从 [0, 1] 开始,先问 slow。slow 外面是 ConcurrencyLimit(1),它的 poll_ready 要先拿到信号量许可(见 ConcurrencyLimit):
slow-1那份拿到唯一的许可,就绪、发出,1000ms 完成。slow-2和fast-1那两份都卡在slow的许可上,按到达顺序排在信号量的等待队列里(见 batch_semaphore)。- 1000ms,许可交给排在前面的
slow-2。fast-1继续等。 - 2000ms,
slow-2完成,许可交给fast-1那份 clone,它的slow终于就绪,接着问fast、就绪,call把请求发给fast,立即完成。
fast-1 那份 clone 为了一个用不上的许可等了 2 秒,拿到之后还一直攥着,直到任务结束。
Steer 来自 #426(tower 0.4.0),提交时叫 tower-router。PR 描述说它「can be thought of as an unbounded (to avoid head-of-line blocking) tower-buffer with extra details to allow routing requests to specific services」。评审里提到,当时的代码里有一整套「oneshot-arc-mutex-spawn」的写法。
评审意见要求反过来:「I think ServiceRouter::poll_ready should poll all the services. If the user wants unlimited buffering, they should wrap the Service in an unbounded tower-buffer ... but we should not force this on the user given that it is not a requirement of router that it buffers.」并解释了两难:要么先无条件收下请求,再去问对应的后端,就得准备缓冲无限多个请求;要么每个请求都要求所有后端就绪,就有队头阻塞。随后的一条评论说「I've renamed the implementation to tower-steer and deleted most of the code (which was there mostly for the oneshot-mutex stuff)」,留下的就是今天这个 not_ready 队列,缓冲交给调用方自己用 Buffer 加。
修法:每个后端套一个 Buffer
Buffer::poll_ready 只在通道里预留一个位置(self.tx.poll_reserve(cx)),通道有空位就 Ready;等内层就绪的事交给 worker 去做(见 Buffer)。所以 Buffer 外面的 Steer 看到的后端几乎总是就绪的,slow 的拥堵留在 slow 的 worker 里,不再挡住 fast。这就是实验第二组 fast-1 回到 0ms 的原因,也是 Steer 文档给的方案:「wrapping each component service with a Buffer with a high enough limit (the maximum number of concurrent requests) will prevent head-of-line blocking」。
关键在「high enough」。上面说过,就绪但没被选中的后端会一直占着为就绪拿到的资源,套了 Buffer 以后,这个资源就是通道位置:每一份已经就绪的 Steer clone,在每个后端的 Buffer 里都占着一个位置。 容量小于同时存在的 clone 数,阻塞就会回来,下面的题就是这种情况。如果后端本来就总是就绪(比如没有任何限流的 service_fn),不套 Buffer 也不会阻塞。
把 run 里的任务体改成先打印就绪时间:
tasks.push(tokio::spawn(async move {
steer.ready().await?;
println!(" {r} ready at {}ms", start.elapsed().as_millis());
let res = steer.call(r.to_string()).await?;
Ok::<_, BoxError>(format!(" {res} at {}ms", start.elapsed().as_millis()))
}));
main 改成 slow 用 Buffer(64)、fast 用 Buffer(1),其余和上面相同:
#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
let bounds = [64, 1]; // slow, fast
let buffered: Vec<_> = backends().into_iter().zip(bounds).map(|(s, b)| Buffer::new(s, b)).collect();
run("slow: Buffer(64), fast: Buffer(1)", Steer::new(buffered, pick)).await;
}
fast 后端什么都不做、立即返回,而且只有一个请求发给它。fast-1 在第几毫秒完成?
动脑筋答案
M2 Max、rustc 1.99.0、--release,时钟暂停,连跑 3 次输出相同:
slow: Buffer(64), fast: Buffer(1)
slow-1 ready at 0ms
slow-2 ready at 1000ms
slow handled slow-1 at 1000ms
fast-1 ready at 2000ms
slow handled slow-2 at 2000ms
fast handled fast-1 at 2000ms
slow-1 ready at 0ms:它那份 clone 在两个Buffer里各预留一个位置,就绪,把请求发给slow。fast那个位置没用上,但没有还:call只把slow放回not_ready,fast保持就绪。fast的通道只有 1 个位置,就这样被占住了。slow-2 ready at 1000ms:它在slow的Buffer(64)里马上有位置,卡在fast的那一个位置上。1000msslow-1完成,任务结束,那份 clone 被 drop,位置释放,交给先来排队的slow-2。fast-1 ready at 2000ms:slow-2那份 clone 又占住fast的位置,直到 2000ms 它的任务结束。fast handled fast-1 at 2000ms:和不套Buffer的结果一样。把bounds换成[1, 64]再跑,三个请求都在 0ms 就绪,fast-1在 0ms 完成:slow的 worker 一收到消息就腾出通道位置,等许可是 worker 的事,占不住调用方。
所以拖住 fast-1 的是发给 slow 的那些请求在 fast 的 Buffer 里占的位置。每个后端的 Buffer 容量都要按同时存在的 Steer clone 数来定,而不是按发给这个后端的请求数。
小结
Steer::poll_ready按not_ready队列逐个问后端,遇到第一个Pending就整体Pending;call才运行 picker,只把用掉的后端放回队列。- 结果是队头阻塞:发往空闲后端的请求,要等最忙的后端腾出就绪。实验里发给立即返回后端的
fast-1等了 2000ms。 - 就绪但没被选中的后端继续占着许可或通道位置,直到被选中或
Steer被 drop。 - 修法是每个后端套一个
Buffer,容量不小于同时存在的Steerclone 数;否则就绪的 clone 会占满某个后端的通道,阻塞又回来。