Rust in Everything
基于 tower 0.5.3

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 时请求还没来。这正是只能「全部就绪」的原因,文档 说得很直白:「Steer must wait for all services to be ready since it can't know ahead of time which Service the 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 秒,拿到之后还一直攥着,直到任务结束。

📝NOTE
冷知识:Steer 的第一版自己缓冲请求

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 也不会阻塞。

❗IMPORTANT
动脑筋:只把 fast 的 Buffer 改成 1

把 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 的那一个位置上。1000ms slow-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,容量不小于同时存在的 Steer clone 数;否则就绪的 clone 会占满某个后端的通道,阻塞又回来。

💬 关联讨论

发起讨论
加载中...