Rust in Everything
基于 tower 0.5.3

请求卡住不动: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.」

❗IMPORTANT
动脑筋:bound 为 3,四个任务,卡不卡?

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 次相同。)

📝NOTE
冷知识:「不想 call 了」至今没有正式的说法

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 无关。

💬 关联讨论

发起讨论
加载中...