Rust in Everything
基于 tower 0.5.3

buffer's worker closed unexpectedly 与 buffered service failed

主线位置:就绪主线。两个错误都出在 Buffer 和它的后台 worker 之间:调用方在 poll_ready 或者等响应时发现 worker 已经不在了,或者 worker 已经宣布内层坏了。

flowchart TB
  w["Buffer 的 worker"] --> gone["运行时关闭,worker 被 drop"]
  w --> bad["内层 poll_ready 返回 Err"]
  gone --> closed["Closed 错误"]
  bad --> se["ServiceError 错误"]
  closed --> forever["之后每个请求都是这个错误"]
  se --> forever

这两个错误的产生过程(failed 的三步、get_error_on_closed 怎么选错误类型)在 Buffer 里已经逐行讲过。这一页只讲它们在实际项目里怎么出现、怎么修。先记住一点:两种都是永久的,Buffer 没有恢复的办法。

Display类型什么时候
buffer's worker closed unexpectedlyClosedworker 没经过 failed 就没了
buffered service failed: {内层错误}ServiceErrorworker 里内层 poll_ready 返回过 Err

调用方拿到的都是 BoxError。用 e.is::<tower::buffer::error::Closed>()、e.is::<tower::buffer::error::ServiceError>() 区分;Closed 的 Debug 输出就是 Closed,unwrap() 时看到的是这个词,不是上面那句话。

Closed:测试之间共用一个 Buffer

Buffer::new 用 tokio::spawn(worker) 把 worker 放到当前运行时上。#[tokio::test] 给每个测试建一个自己的运行时,测试结束就关掉。把 Buffer 放进 static,让多个测试共用:

tower = { version = "=0.5.3", features = ["buffer", "util"] }
tokio = { version = "=1.54.1", features = ["full"] }
// tests/shared_buffer.rs
use std::future::Ready;
use std::sync::OnceLock;
use tower::buffer::Buffer;
use tower::{BoxError, Service, ServiceExt, service_fn};

type Client = Buffer<u32, Ready<Result<u32, BoxError>>>;

fn echo(x: u32) -> Ready<Result<u32, BoxError>> {
  std::future::ready(Ok(x))
}

// One client for the whole test binary, created by whichever test gets here first.
fn client() -> Client {
  static CLIENT: OnceLock<Client> = OnceLock::new();
  CLIENT.get_or_init(|| Buffer::new(service_fn(echo), 16)).clone()
}

async fn roundtrip(mut c: Client, x: u32) {
  match c.ready().await {
    Ok(svc) => assert_eq!(svc.call(x).await.unwrap(), x),
    Err(e) => panic!("ready failed: {e} ({e:?})"),
  }
}

#[tokio::test]
async fn first() {
  roundtrip(client(), 1).await;
}

#[tokio::test]
async fn second() {
  roundtrip(client(), 2).await;
}

cargo test --release --test shared_buffer -- --test-threads=1,M2 Max、rustc 1.99.0,连跑 3 次结果相同(线程编号除外):

running 2 tests
. 1/2
second --- FAILED

failures:

---- second stdout ----

thread 'second' (142463) panicked at tests/shared_buffer.rs:21:15:
ready failed: buffer's worker closed unexpectedly (Closed)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
    second

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

first 先跑,Buffer 和 worker 建在 first 的运行时上。first 结束,运行时关闭,上面的任务全部被 drop,worker 连同它的 mpsc::Receiver 一起没了,通道关闭。second 拿到的句柄在 poll_ready 第一行就发现 self.tx.is_closed(),Handle 里没有存过错误,于是是 Closed。

不加 --test-threads=1 时两个测试并行,结果变成看运气。直接跑测试二进制 3 轮、每轮 200 次,失败 182、157、159 次,没有一次卡住。另跑 100 次统计,first 失败 27 次、second 失败 48 次,位置都是 svc.call(x).await.unwrap() 那一行,值都是 Closed:一个测试已经把请求发进了通道,worker 还没回复,就随另一个测试的运行时一起被 drop,oneshot 发送端没发就没了,ResponseFuture 的 Rx 分支返回 Closed。本地并行跑偶尔全绿、CI 上换了线程数就红,是这个坑的常见样子。

修法有两种。最简单的是每个测试自己建 Buffer。确实要共用(比如它背后是一个昂贵的连接),就让 worker 跑在一个和测试二进制同寿的运行时上:用 Buffer::pair 拿到 worker,自己 spawn:

// tests/shared_buffer_fixed.rs:只有 client() 不同
use tokio::runtime::Runtime;

// The worker runs on a runtime that lives as long as the test binary.
fn client() -> Client {
  static RT: OnceLock<Runtime> = OnceLock::new();
  static CLIENT: OnceLock<Client> = OnceLock::new();
  CLIENT
    .get_or_init(|| {
      let rt = RT.get_or_init(|| {
        tokio::runtime::Builder::new_multi_thread()
          .worker_threads(1)
          .build()
          .unwrap()
      });
      let (client, worker) = Buffer::pair(service_fn(echo), 16);
      rt.spawn(worker);
      client
    })
    .clone()
}

--test-threads=1 连跑 3 次都是 test result: ok. 2 passed;不加时直接跑二进制 200 次,200 次通过。调用方在各自测试的运行时里,worker 在 RT 上,Buffer 本来就不要求两者在同一个运行时(Buffer 里「worker 所在的运行时没了」的实验也是跨运行时的)。

📝NOTE
冷知识:worker 意外退出,最早是 panic

Closed 这个错误类型是 2019 年 3 月的 PR #190 加的,标题就是「buffer: fix panics if worker executor drops unexpectedly」。在那之前,响应 future 等 worker 回复时如果发送端被 drop,走的是:

                    Err(_) => unreachable!(
                        "Worker exited without sending error to all outstanding requests."
                    ),

unreachable! 表明代码假定 worker 总会先把错误发给每个在等的请求再退出;但执行器直接把 worker 丢掉时,worker 根本没有机会发,这一行就成了 panic。这次提交把这一行换成 Err(_) => return Err(Closed::new().into()),也就是今天 ResponseFuture::poll 的 Rx 分支;同时把 ServiceError 的 Display 从 Service::{method} failed: ... 改成了现在的 buffered service failed: ...。第一个包含它的发布是独立的 tower-buffer 0.1.0,tower 0.1.0 以 tower::buffer 重新导出了它。

buffered service failed:一次错误,永久失败

Service trait 的规则 3:poll_ready 返回 Err 表示这个实例不能再用。Buffer 的 worker 照做:内层 poll_ready 一报错,worker 就把错误存进共享的 Handle、关通道(failed),从此所有句柄都失败。问题在于很多内层服务用 poll_ready 的错误表示「连接断了」,而连接断了本来是可以重连的。

下面的 Conn 是一条连接,1 号连接服务完一个请求后断开,poll_ready 报错:

tower = { version = "=0.5.3", features = ["buffer", "reconnect", "util"] }
tokio = { version = "=1.54.1", features = ["full"] }
use std::future::{Ready, ready};
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::task::{Context, Poll};
use tower::buffer::Buffer;
use tower::reconnect::Reconnect;
use tower::{BoxError, Service, ServiceExt, service_fn};

// A connection. Connection #1 breaks after serving one request:
// its poll_ready reports the broken connection as an error.
struct Conn {
  id: u32,
  served: u32,
}

impl Service<u32> for Conn {
  type Response = String;
  type Error = BoxError;
  type Future = Ready<Result<String, BoxError>>;

  fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), BoxError>> {
    if self.id == 1 && self.served >= 1 {
      return Poll::Ready(Err("connection reset".into()));
    }
    Poll::Ready(Ok(()))
  }

  fn call(&mut self, req: u32) -> Self::Future {
    self.served += 1;
    ready(Ok(format!("req {req} served by conn #{}", self.id)))
  }
}

async fn send<S>(svc: &mut S, req: u32)
where
  S: Service<u32, Response = String, Error = BoxError>,
{
  match svc.ready().await {
    Ok(s) => match s.call(req).await {
      Ok(rsp) => println!("  {rsp}"),
      Err(e) => println!("  req {req}: call failed: {e}"),
    },
    Err(e) => println!("  req {req}: ready failed: {e}"),
  }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  println!("Buffer over a connection:");
  let mut svc = Buffer::new(Conn { id: 1, served: 0 }, 8);
  for req in 1..=3 {
    send(&mut svc, req).await;
  }

  println!("Buffer over Reconnect:");
  let next_id = Arc::new(AtomicU32::new(1));
  let mk = service_fn(move |_: ()| {
    let id = next_id.fetch_add(1, Ordering::SeqCst);
    ready(Ok::<_, BoxError>(Conn { id, served: 0 }))
  });
  let mut svc = Buffer::new(Reconnect::new(mk, ()), 8);
  for req in 1..=3 {
    send(&mut svc, req).await;
  }
}

连跑 3 次输出相同:

Buffer over a connection:
  req 1 served by conn #1
  req 2: call failed: buffered service failed: connection reset
  req 3: ready failed: buffered service failed: connection reset
Buffer over Reconnect:
  req 1 served by conn #1
  req 2 served by conn #2
  req 3 served by conn #2

前半段:

  • 请求 2 的 ready 成功了,call 的结果才失败。 Buffer::poll_ready 只在通道里预留位置,不问内层;worker 取出请求 2,问内层 poll_ready,这时才看到错误,走 Poll::Ready(Err(e)) 分支,把错误回给请求 2。所以只在 ready() 上处理错误是不够的。
  • 请求 3 在 ready 上就失败。 通道已经关了,poll_ready 第一行返回存着的错误。之后不管内层是不是已经好了,都不会再有人问它。

后半段在 Buffer 和连接之间套了 Reconnect。Reconnect 在已连接状态下遇到内层 poll_ready 报错,不往外传,而是把状态改回 Idle(第 130–133 行),循环回去重新连接。worker 看到的 poll_ready 一直是 Ok,请求 2 由 2 号连接服务。代价是那次断开的错误被丢掉了,连一行日志都没有;Reconnect 只把建连接的错误交给下一次 call(见 Reconnect)。

还有一种写法是在 Buffer 外面检测到 ServiceError 后重建整个 Buffer。这要求所有持有旧句柄的地方都能换成新的,通常比在里面套 Reconnect 麻烦。

小结

  • Closed(buffer's worker closed unexpectedly):worker 没报错就没了,最常见的原因是 worker 所在的运行时被关掉。测试之间共用一个 static Buffer 时,串行必失败,并行看运气。修法:每个测试自己建,或用 Buffer::pair 把 worker 放到一个长寿的运行时上。
  • ServiceError(buffered service failed: …):内层 poll_ready 报过一次错,Buffer 从此永久失败。第一个撞上错误的请求是在响应 future 上失败,之后的在 poll_ready 上失败。
  • 内层用 poll_ready 的错误表示「连接断了」时,在 Buffer 里面套 Reconnect,让它在内层重连,不把错误传给 worker。

💬 关联讨论

发起讨论
加载中...