Hyper 与 Tower:工业级 HTTP 栈

第6章 Buffer / LoadShed / ConcurrencyLimit:容量工程

作者 杨艺韬 · 11,121 字 · 发布于 · 更新于

6.1 当服务饱和时,三种活法

上一章我们把三个"按规则节制"的中间件读完了——Timeout、Retry、RateLimit。这一章要讨论的是另一组问题:

当服务真的满了,你选择怎么办?

这是分布式系统里最古老也最哲学的问题之一。直觉上大多数人会说"限流呗",但真正坐下来审视细节,会发现"服务满了"可以用截然不同的方式应对:

  • 排队等(Buffer)——"我先把请求记下来,等服务有空时依次处理。"
  • 直接拒(LoadShed)——"现在忙不过来,请你 503 回去吧,等会再试。"
  • 挂起等(ConcurrencyLimit)——"别急,你的请求先挂着,轮到你的时候我再接。"

三种策略对应三种真实场景。不同场景下选错策略的代价可能非常昂贵:排队可能导致雪崩、拒绝可能丢掉容易处理的请求、挂起可能让上游大量 TCP 连接堆积。

Tower 把这三种策略都实现成了中间件:Buffer、LoadShed、ConcurrencyLimit。这一章我们把前两个读透,并和第 4 章已经读过的 ConcurrencyLimit(第 4 章读过它的 permit 模型;上一章 §5.4.5 只在概念上把它和 RateLimit 做过比较)合在一起对比。三个加起来约 1100 行代码(buffer/ 651 + load_shed/ 213 + limit/concurrency/ 227),但它们是整个 Rust 生产环境容量工程的基石。

源码引用来自 tower 0.5.3 (tower-0.5.3/tower/src/buffer/、tower-0.5.3/tower/src/load_shed/)。

6.2 Buffer:把 mpsc 和 oneshot 缝起来

Buffer 的核心观察是:tokio 的 mpsc::channel(bound) 本身就是一个背压感知的队列。消费端处理不过来时,生产端 send() 会返回 Pending——调用方可以在 task 层面自然挂起。Tower 的 Buffer 就是"把 mpsc 通道嫁接到 Service 接口上"。

6.2.1 两半结构

// tower-0.5.3/tower/src/buffer/service.rs:18-22
pub struct Buffer<Req, F> {
    tx: PollSender<Message<Req, F>>,
    handle: Handle,
}

Buffer 本身不持有被包裹的 Service——它只持有一个 channel 的发送端和一个 error handle。真正的 Service 活在另一个地方:Worker<T, Request>,一个独立的 tokio task。

这是 Buffer 最核心的架构选择:把 Service 消费和请求分发拆成两个独立的 async 实体。

          ┌──────────────┐            ┌──────────┐
          │ Buffer       │            │ Worker   │
caller ──▶│ (Clone)      │──mpsc────▶ │          │──call──▶ inner Service
          │              │            │          │
          └──────────────┘            └──────────┘
  • 调用方(左侧)拿一个 Buffer 实例,调用 poll_ready + call。
  • poll_ready 会对 mpsc 的 sender 做 poll_reserve——预留一个槽位。
  • call 构造一个 Message { request, span, tx }(tx 是 oneshot 的发送端),通过 mpsc 送给 worker。返回值是一个等 rx_oneshot 的 future。
  • Worker(右侧)在自己的 task 里 poll_recv 收请求,对 inner service 做 poll_ready + call,把 inner 的 response future 通过 oneshot 送回。

这是一个经典的 actor 模式——worker 是 actor,Buffer 是"client handle"。调用方只通过 handle 送消息,内部 actor 串行处理。

6.2.2 为什么 Buffer 能让任何 Service 变 Clone

// tower-0.5.3/tower/src/buffer/service.rs:133-144
impl<Req, F> Clone for Buffer<Req, F>
where Req: Send + 'static, F: Send + 'static,
{
    fn clone(&self) -> Self {
        Self {
            handle: self.handle.clone(),
            tx: self.tx.clone(),
        }
    }
}

Buffer<Req, F> 可以 Clone——不管底下的 Service 能不能 Clone。这在 Retry(需要 S: Clone)等场景里至关重要;Balance 本身没有实现 Clone,要让多个调用方共享同一个 Balance,通常也是在外面套一层 Buffer。

工作原理:clone 的只是 PollSender 和 Handle——mpsc 的 sender 和错误句柄都是廉价可 clone 的引用。多个 Buffer clone 共享同一个底层 worker,请求通过 channel 汇聚到 worker 串行处理。

在第 5 章我们读 Retry 源码时已经提到过:如果 inner service 自身不 Clone(比如 AuthClient,内部有大型配置),可以套一层 Buffer:

let svc = ServiceBuilder::new()
    .layer(RetryLayer::new(policy))
    .buffer(100)              // ← 在这里 Clone 藏进 Arc+mpsc
    .service(heavy_auth_client);

Retry 只看到"一个可 Clone 的 Buffer"——实际上底下 heavy_auth_client 只有一份,所有 Retry 的 clone 都通过 channel 排队进同一个 worker。

6.2.3 poll_ready:向 channel 要槽位

// tower-0.5.3/tower/src/buffer/service.rs:97-108
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    // First, check if the worker is still alive.
    if self.tx.is_closed() {
        return Poll::Ready(Err(self.get_worker_error()));
    }

    // Poll the sender to acquire a permit.
    self.tx
        .poll_reserve(cx)
        .map_err(|_| self.get_worker_error())
}

PollSender::poll_reserve(cx) 是 tokio-util 提供的一个 API,它把 tokio 的 mpsc::Sender::reserve_owned() 返回的 future 装进可复用的 ReusableBoxFuture,对外暴露成 poll 形式的接口(tokio-1.46.1/tokio-util/src/sync/mpsc.rs:60 调 reserve_owned,:137 定义 poll_reserve)。语义是"异步地为这一 Buffer 客户端预留一个 mpsc 槽位"。

Buffer 的 poll_ready 做两件事:

  1. 检查 worker 是否还活着(channel 是否关闭)。如果 worker 已经挂掉(可能因为底层 Service 报错),直接返回"我坏了"的错误。
  2. poll_reserve 一个 channel 槽位。如果 mpsc 已经满(达到 bound),返回 Pending——调用方被挂起,等有槽位再被 waker 唤醒。

这一步完美接上了 Buffer 的背压语义:当 worker 来不及处理、channel 里积累到 bound 个未处理消息时,新的 poll_ready 会 Pending——上游的 Tower 栈可以根据这个信号决定是继续挂起还是做别的决定。

6.2.4 call:送消息 + 等响应

// tower-0.5.3/tower/src/buffer/service.rs:110-131
fn call(&mut self, request: Req) -> Self::Future {
    let span = tracing::Span::current();
    let (tx, rx) = oneshot::channel();

    match self.tx.send_item(Message { request, span, tx }) {
        Ok(_) => ResponseFuture::new(rx),
        Err(_) => {
            tracing::trace!("buffer channel closed");
            ResponseFuture::failed(self.get_worker_error())
        }
    }
}

几个细节:

  • tracing::Span::current():把当前 tracing span 捎带到 worker。不这么做的话,worker task 里的 tracing 事件不会关联到发起请求的 span。这是一个跨 task tracing 的惯用技巧——span 是 Clone 的,传入 message 里,worker 收到后 _guard = msg.span.enter() 进入这个 span。
  • (tx, rx) = oneshot::channel():每个请求对应一个一次性 channel。worker 把 inner service 返回的 response future 通过 tx 送回,调用方在 rx 上等。
  • send_item:这里已经通过 poll_reserve 拿到了槽位,所以 send 是同步成功的——不可能阻塞,不可能 Pending。协议保证 call 前必走 poll_ready,poll_ready 拿到槽位后一直到 call 都是"已预留"状态。

6.2.5 Worker:真正的循环

// tower-0.5.3/tower/src/buffer/worker.rs:147-206 精简
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
    if self.finish { return Poll::Ready(()); }
    loop {
        match ready!(self.poll_next_msg(cx)) {
            Some((msg, _first)) => {
                let _guard = msg.span.enter();
                if let Some(ref failed) = self.failed {
                    let _ = msg.tx.send(Err(failed.clone()));
                    continue;
                }
                match self.service.poll_ready(cx) {
                    Poll::Ready(Ok(())) => {
                        let response = self.service.call(msg.request);
                        let _ = msg.tx.send(Ok(response));
                    }
                    Poll::Pending => {
                        // 把消息放回去,挂起 worker
                        drop(_guard);
                        self.current_message = Some(msg);
                        return Poll::Pending;
                    }
                    Poll::Ready(Err(e)) => {
                        let error = e.into();
                        drop(_guard);
                        self.failed(error);
                        let _ = msg.tx.send(Err(self.failed.as_ref().unwrap().clone()));
                    }
                }
            }
            None => {
                self.finish = true;
                return Poll::Ready(());
            }
        }
    }
}

Worker 的 Future::poll 本身是一个 loop——每次 poll 尽可能消费所有待处理消息,直到要么所有消息处理完、要么 service 不 ready。

核心三段式:

  1. 拿下一个消息(poll_next_msg)。如果 channel 关了(所有 Buffer 实例都 drop 了),返回 None。
  2. 检查 service 状态:如果已经 failed,直接把错误送回 oneshot。
  3. poll_ready + call:
    • Ready(Ok):调用 inner.call(req),把 response future 通过 oneshot 送回。注意!这里送回的是 future,不是 response——真正的 await 发生在调用方的 ResponseFuture。
    • Pending:把消息放回 self.current_message,return Pending。下次 worker 被 wake 时再尝试。
    • Ready(Err):service 永久坏了。走 failed() 流程——关闭 channel、把错误广播给所有还在等的 oneshot。

current_message 字段非常巧妙——它解决了"已经从 channel 取出但 service 还没就绪"的尴尬。不能把消息再送回 channel(顺序就乱了),必须缓存在 worker 里等 service 就绪再 call。

6.2.6 cancel 的三种路径

Buffer 支持三种请求取消:

  1. 调用方 drop 了 ResponseFuture(还没送到 worker 就不想要了)。worker 下次 poll_next_msg 时会检查 msg.tx.is_closed()——如果调用方已经 drop 了 rx,oneshot 的 tx 会变成 closed,worker 把这条消息直接跳过:
// tower-0.5.3/tower/src/buffer/worker.rs:85-90(current_message 分支;从 channel 新取的消息在 :94-101 做同样的检查)
if !msg.tx.is_closed() {
    return Poll::Ready(Some((msg, false)));
}
tracing::trace!("dropping cancelled buffered request");
  1. Worker 报错:调用 self.failed(error),后续所有消息收到错误。
  2. 所有 Buffer clone drop:channel 关闭,worker 收到 None,正常结束。

这三种路径不需要额外的"cancel 信号"通道——Rust 的 drop 语义和 oneshot/mpsc 的 closed 检测完整覆盖。

6.2.7 Buffer 的 bound 该设多少

/// A note on choosing a `bound`
///
/// When [`Buffer`]'s implementation of [`poll_ready`] returns [`Poll::Ready`], it reserves a
/// slot in the channel for the forthcoming [`call`]. However, if this call doesn't arrive,
/// this reserved slot may be held up for a long time.

这段文档里的警告值得强调:poll_ready 返回 Ready 后,那个槽位就被"预留"了。如果调用方 poll_ready 后没有立刻 call(比如被 cancel 了),那个槽位还占着——直到 PollSender 的 drop 释放。

实战推论(也是这段文档接下来的原话建议):bound 至少要等于 Buffer 会面对的最大并发请求数。如果你有 200 个并发 task 都在 buffer_svc.ready().await,bound 设成 100,会有 100 个 task 被堵在 poll_ready 的 Pending 上——正确,但不是你想要的"先全部 ready,然后慢慢处理"的效果。

6.3 LoadShed:饱和时立即拒绝

Buffer 的哲学是"排队",LoadShed 的哲学刚好相反——"满了就拒"。整段源码加起来 213 行(mod.rs 76 + future.rs 70 + error.rs 34 + layer.rs 33)。

6.3.1 整体结构

// tower-0.5.3/tower/src/load_shed/mod.rs:17-20
#[derive(Debug)]
pub struct LoadShed<S> {
    inner: S,
    is_ready: bool,
}

两个字段:inner + 一个 bool。就这么多。

is_ready 是一个缓存——它记录"上一次 inner.poll_ready 返回 Ready 了吗"。这个细节让 LoadShed 的整个哲学可行。

6.3.2 颠覆性的 poll_ready

// tower-0.5.3/tower/src/load_shed/mod.rs:43-54
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    // We check for readiness here, so that we can know in `call` if
    // the inner service is overloaded or not.
    self.is_ready = match self.inner.poll_ready(cx) {
        Poll::Ready(Err(e)) => return Poll::Ready(Err(e.into())),
        r => r.is_ready(),
    };

    // But we always report Ready, so that layers above don't wait until
    // the inner service is ready (the entire point of this layer!)
    Poll::Ready(Ok(()))
}

看这一行代码:Poll::Ready(Ok(())) —— LoadShed 的 poll_ready 永远返回 Ready。

这是 LoadShed 的灵魂:它偏离了大多数调用方对 poll_ready 的默认理解(Ready 通常被当作"真的能接";tower-service 的文档其实并没有承诺 Ready 之后 call 一定成功),而且这种偏离是有意的——LoadShed 的存在就是为了绕过背压,让上层调用方永远不挂起。

具体流程:

  1. 内部先 poll 一次 inner,把结果存在 self.is_ready 里。
  2. 无论 inner 是 Ready 还是 Pending,都向调用方报告 Ready。
  3. 如果 inner 报错,这种"不可恢复"的信号还是要传上去——否则调用方会以为还能用。

6.3.3 call:真相的时刻

// tower-0.5.3/tower/src/load_shed/mod.rs:56-64
fn call(&mut self, req: Req) -> Self::Future {
    if self.is_ready {
        // readiness only counts once, you need to check again!
        self.is_ready = false;
        ResponseFuture::called(self.inner.call(req))
    } else {
        ResponseFuture::overloaded()
    }
}

到了 call,LoadShed 才查看上一次 poll_ready 缓存的结果:

  • is_ready == true:inner 那时是 ready 的——转交请求。注意 self.is_ready = false——一次许可只能用一次,下次 call 必须再 poll_ready。
  • is_ready == false:inner 那时是 pending 的——直接构造一个"已失败"的 future,里面装着 Overloaded 错误。调用方 await 这个 future,立即拿到 Err(Overloaded)。

"overloaded future"的实现:

// tower-0.5.3/tower/src/load_shed/future.rs:22-31
pin_project! {
    #[project = ResponseStateProj]
    enum ResponseState<F> {
        Called { #[pin] fut: F },
        Overloaded,
    }
}
// poll 实现(同文件 :55-58):
match self.project().state.project() {
    ResponseStateProj::Called { fut } => fut.poll(cx).map_err(Into::into),
    ResponseStateProj::Overloaded => Poll::Ready(Err(Overloaded::new().into())),
}

两个变体的 future——Called 是真实工作的 future,Overloaded 是一个立即 Ready 的常量 future。整个"拒绝"路径是零分配的(只构造一个 unit variant),性能代价小到可以忽略。

6.3.4 LoadShed 的用场

读过 poll_ready 永远 Ready 这个设计之后,你可能会担心 LoadShed 是不是"把 Tower 协议破坏了"。确实——但只在 LoadShed 自己这一层。外层看到的是一个"总是就绪"的服务,内层的真实就绪信号被 LoadShed 吞掉并转化成 Overloaded 错误。

合适的使用场景:HTTP 服务端。HTTP 请求到达之后,你很少希望连接层 pending——更合理的是立即响应 503(前提是在外层把 Overloaded 错误映射成 503 响应,见下文)。用户自然会在客户端 retry 或者显示"服务繁忙"。

实战组合:

let svc = ServiceBuilder::new()
    .load_shed()                     // 上层始终 Ready → 请求永不等待
    .concurrency_limit(100)          // 内层有真实容量限制
    .service(expensive_handler);

concurrency_limit 会在 100 个 in-flight 时报告 Pending;这个 Pending 被 load_shed 吞掉——转换成 Err(Overloaded) 返回给调用方。调用方立刻拿到错误。注意 hyper 自己不会把 service 错误翻译成 503:HTTP/1 下它会以 user service 错误结束这条连接(hyper-1.9.0/src/proto/h1/dispatch.rs:337),HTTP/2 下会 reset 这个 stream(hyper-1.9.0/src/proto/h2/server.rs:459-462);要回 503,得在 load_shed 外面再加一层把 Overloaded 转成响应(例如 axum 的 HandleErrorLayer)。整个链条:"服务满 → 立即 503",不挂起、不排队。

不合适的使用场景:客户端代码。客户端调用远端服务,绝大多数情况下你希望"等一下,等远端有空"——这恰好是 ConcurrencyLimit 或 Buffer 的作用。LoadShed 会让客户端直接放弃,可能导致大量无意义的 retry。

6.4 三者对比:一个表讲清

策略 中间件 poll_ready 的行为 call 的行为 典型用场
挂起 ConcurrencyLimit 有许可时 Ready;无许可时 Pending 消费许可 客户端,公平等待
排队 Buffer 有 channel 槽时 Ready;无槽时 Pending 送消息,等 oneshot 让 !Clone 变 Clone,或延迟处理
拒绝 LoadShed 总是 Ready 按缓存决定转发或返回 Overloaded HTTP 服务端,流量管控

这三种策略对应三种完全不同的系统观:

挂起是"平滑"的——上游 task 挂起在 waker 上,不占用线程(挂起的请求本身仍占内存),但可能让上游看起来"卡住了",如果上游是一个 HTTP 连接,连接可能被客户端判断为超时。

排队是"平滑但可能累积"的——请求不会丢,但队列会积累。适合批处理、异步工作流。但 bound 设得太大时,内存可能吃爆;bound 设得太小时,又退化成"背压传到上游"。

拒绝是"决断"的——系统任何时候都快速响应,不积累、不挂起。但失败率会上升,对客户端友好度取决于客户端有没有正确的 retry 逻辑。

实战里往往组合使用。一种常见的三层组合(生产 HTTP 服务要不要加中间这层 Buffer,见 §6.5.4):

// 顶层:对上游永远 Ready,防止 TCP 连接累积
.load_shed()
// 中层:设置一个排队缓冲,吸收 burst
.buffer(500)
// 内层:限制真实并发,保护 CPU 和下游
.concurrency_limit(50)
.service(router)

请求流量的命运:

  1. 前 50 个 → 立刻进入 handler。
  2. 第 51-551 个 → 进队列(buffer 500 个槽,外加 worker 因 inner 未就绪而攥在 current_message 里的那一条)。
  3. 第 552 个 → 触发 LoadShed 拒绝(buffer.poll_ready 的 poll_reserve 返回 Pending,被 load_shed 转成 Overloaded 错误)。

三层结合得到一个"平滑吸收 burst、硬拒绝雪崩"的栈。

6.5 深入:Worker 的 failed() 协议

Buffer 里有一段值得独立讨论的代码——Worker::failed()(tower-0.5.3/tower/src/buffer/worker.rs:106-137,下面是去掉原注释、加了中文注释的精简版)。它处理的是"inner service 永久报错之后,怎么让所有使用这个 Buffer 的调用方都知道"。

fn failed(&mut self, error: crate::BoxError) {
    let error = ServiceError::new(error);
    let mut inner = self.handle.inner.lock().unwrap();

    if inner.is_some() { return; }   // 已经报错过,避免重复处理

    *inner = Some(error.clone());
    drop(inner);

    self.rx.close();   // 关闭 channel——后续 send 会失败

    self.failed = Some(error);
}

注释里有一段工程思考非常精彩(worker.rs:112-118,手动翻译):

必须处理 "handle 并发地尝试 send 请求" 的场景。我们希望 要么 请求 send 失败、要么 它能从自己的 oneshot 上收到错误。要避免的 race 是:我们先给所有在等的请求广播错误,然后 调用方才发来它的 send——这时它拿不到错误,永远挂起。

解决方法:先 暴露错误、然后 关闭 channel(所以后续 send 会 fail)、然后 给已经在等的 oneshot 发错误。

这段话读三遍值得——它在讲一个分布式系统里"状态广播 + 并发写入"的经典 race condition,在 Tower 这种纯 in-process actor 模式里依然存在。解决方式很细致:

  1. 先写 handle.inner = Some(error):这时 channel 还没关,调用方仍可能 send 成功,但从此刻起任何读 handle 的人都能拿到这个错误。
  2. 再关 channel (self.rx.close()):此后 tx.send_item() 都会立刻失败——新 call 走 Err(_) => ResponseFuture::failed(...) 分支,self.get_worker_error() 从 handle 读到 error。
  3. 设 self.failed:worker 后续 pop 出的消息会被这个 self.failed 拦住,直接回错误。

这个顺序之所以正确,是因为"更新顺序和读顺序一致"——调用方的流程是 tx.send → 检查结果 → 读 handle 错误。worker 的广播顺序是 handle 写 → channel 关闭。调用方 send 失败之后再读 handle,一定能读到错误。

写 Rust 并发代码时这类"先写可见字段,再触发关闭信号"的模式在 crossbeam、tokio 源码里反复出现。对照《Tokio 源码深度解析》专栏第 13 章 channel 源码 讨论 broadcast/oneshot 关闭顺序——模式是相通的。

6.5.1 tower 0.5.3 里 buffer/ 和 load_shed/ 的真实行数对比

读到这里你可能好奇——两个中间件功能相反、代码量差多少?实测 tower 仓库 251296d 的 tower-0.5.3/tower/src/(即本专栏声明的 0.5.3)——

模块 文件 行 小计
buffer/ mod / service / worker / layer / future / error / message 47 + 144 + 226 + 72 + 78 + 68 + 16 651
load_shed/ mod / layer / future / error 76 + 33 + 70 + 34 213

Buffer 是 LoadShed 的 3.06 倍——差距几乎全部集中在三个 buffer 独有文件——

  • worker.rs 226 行——LoadShed 没有 worker,它只有一个 is_ready 布尔、零后台任务。Buffer 要维护一个后台 tokio::spawn 的 worker loop、处理 close/failed/cancel 三种结束路径、负责把 handle 的错误广播给所有 send 端
  • service.rs 144 行——Buffer 的 Service impl 要处理 poll_ready 向 channel 要槽位、call 送消息到 worker 并拿 oneshot 的两步协议;LoadShed 的整个 mod.rs(结构体、Service impl 和 Clone)只有 76 行、因为它不涉及跨任务状态同步
  • message.rs 16 行 + future.rs 78 行——Buffer 要定义 Message<Req, Fut>(带 oneshot sender + tracing span)作为 worker 通信载体;LoadShed 的 future.rs 70 行里拒绝路径只是一个 ResponseState::Overloaded unit variant 加 poll 里的一行 Poll::Ready(Err(Overloaded::new().into()))——纯同步路径

一条值得记住的规律——"是否需要跨任务的消息传递"是中间件复杂度分水岭——同步包装(Timeout 169、RateLimit 193、LoadShed 213、ConcurrencyLimit 227)都在 200 行上下;带后台 worker 或 pending future 集合的 Buffer(651)、Balance(679,另加 ready_cache/ 537)都在 600 行以上(SpawnReady 是例外——它只是把 poll_ready 甩给 tokio::spawn,122 行)。选中间件时这个差距也会映射到运行时成本——worker 意味着多一次跨 task 的调度切换(同一 runtime 内的 task 切换,不是 OS 线程切换)、一次 allocation、一次 oneshot。

6.5.2 Buffer 通道的源码账本:7 个文件 + 关键类型定位

上一小节给出 651 行的总量,这一小节把它摊开到"每个文件具体承担什么、关键类型在哪一行"——读源码的时候你可以直接当成地图用。实测 tower-0.5.3/tower/src/buffer/——

文件 行 核心职责 关键类型 / 函数 / 行号
mod.rs 47 公开导出 + 模块文档 pub use service::Buffer; pub use layer::BufferLayer
message.rs 16 跨任务通信载体 struct Message { request, tx: oneshot::Sender, span: tracing::Span }(line 5-10)
error.rs 68 两个错误类型(都是 struct) ServiceError(worker 报错)+ Closed(worker 已退出)
future.rs 78 调用方等 oneshot 的 future ResponseFuture 内置 ResponseState::Failed/Rx/Poll 三态机
layer.rs 72 BufferLayer::new(bound) + 长注释 tokio::spawn(worker) 发生在 layer.rs 之外的 Buffer::new(tower-0.5.3/tower/src/buffer/service.rs:57)
service.rs 144 Buffer<Req, F> 客户端句柄 pub fn new (line 49)、pub fn pair (line 66)、Service::poll_ready (line 97-108)、Service::call (line 110-131)、Clone (line 133-144)
worker.rs 226 唯一后台 task Worker<T, Req>(line 23-33,6 字段)、Handle(line 37-40)、Worker::new (line 47)、poll_next_msg (line 71)、failed (line 106)、Future::poll (line 147)、Handle::get_error_on_closed (line 210)、Handle::clone (line 221)
合计 651

几条值得记的"冷知识"——

  • message.rs 只有 16 行——它是 Buffer 最小但最核心的类型。整个 Buffer 的价值就凝结在这个 struct:把"请求 + oneshot 返回端 + tracing span"打包送进 mpsc,让 actor 模式能跨 task 保留 trace 上下文。
  • Handle 是一个单字段结构体(worker.rs:37-40)——内部只有一个 Arc<Mutex<Option<ServiceError>>>。整个"worker 挂掉后所有调用方都能读到错误"的协议,就靠这 1 个 Arc<Mutex<Option<E>>> 支撑。§6.5 讲的 failed() race-free 顺序正是围绕这个 Handle 展开。
  • Worker 有 6 个字段(worker.rs:23-33)——current_message(回填已取出但 inner 未 ready 的消息)、rx(接收端)、service(被包裹的 inner)、finish(结束标志)、failed(永久错误缓存)、handle(与 Handle 共享 Arc)。这 6 个字段每一个都对应一种运行态的可能性,读的时候可以按字段对着 poll() 函数的 match 分支一一对应。
  • tokio::spawn(worker) 在 tower-0.5.3/tower/src/buffer/service.rs:57 的 Buffer::new——不是在 BufferLayer::layer() 里!这意味着 每次 Layer::layer() 都会立刻 spawn 一个新 worker。如果你把 BufferLayer 套在一个会被反复 layer() 的地方(比如每个连接、每次 MakeService 调用都重建一次 service 栈),会悄悄 spawn 大量 worker。规避方法:要么用 Buffer::pair 自己管理 worker 生命周期、要么在按连接构造 service 的逻辑之外只构造一次 Buffer,再把它 clone 给各个连接。

Buffer 的"7 个文件 651 行"和后面将读到的 Balance(第 7 章,balance/ 679 行 + ready_cache/ 537 行)、SpawnReady(122 行)并置——你会看到一个规律——

graph TD
    A["同步中间件<br/>(Timeout / LoadShed / Concurrency / Rate)"] --> A1["70-130 行 / 文件"]
    A --> A2["无 tokio::spawn"]
    A --> A3["无跨 task 错误广播"]
    B["异步中间件<br/>(Buffer / SpawnReady / Balance)"] --> B1["600+ 行 / 模块(SpawnReady 例外)"]
    B --> B2["有后台 task 或 FuturesUnordered 集合"]
    B --> B3["有跨 task / 跨 future 的失败传播(Handle + oneshot 或 PendingError)"]

"是否跨 task"就是 Tower 中间件宇宙的元维度——一旦跨越,Handle + Message + Worker + oneshot + Closed + ServiceError 这一整套模式就必然出现。

6.5.3 LoadShed vs ConcurrencyLimit:同叫"限制",源码机制截然相反

这一小节回答一个经常被问到的问题:.load_shed().concurrency_limit(100) 和 .concurrency_limit(100) 差在哪里? 不是表面上的"一个拒绝一个等待"——两者背后的源码机制完全不同。实测行号摆在一起对照看——

维度 ConcurrencyLimit
(tower-0.5.3/tower/src/limit/concurrency/service.rs 117 行)
LoadShed
(tower-0.5.3/tower/src/load_shed/mod.rs 76 行)
容量来源 外部 Arc<Semaphore>(line 2, 16) 无显式容量——依赖 inner 的 poll_ready
状态字段 permit: Option<OwnedSemaphorePermit>(line 22) is_ready: bool(mod.rs:17-20)
poll_ready 行为 先 semaphore.poll_acquire (line 68),许可拿到才调 inner.poll_ready (line 78) 总是返回 Poll::Ready(Ok(())) (line 53),但内部先调 inner.poll_ready 并缓存 (line 46-49)
达不到容量时 poll_ready Pending——调用方挂起等信号量 poll_ready Ready,但 call 里看到 is_ready==false,构造 ResponseFuture::overloaded() (line 62)
call 行为 permit.take()(line 83-86)+ 转发 inner.call;permit 随 response future 生命周期持有 if is_ready { is_ready=false; inner.call } else ResponseFuture::overloaded()
许可释放时机 ResponseFuture drop 时 permit 自动 drop —— response 完成后才释放 无许可概念;拒绝路径零分配,只有一个 unit variant
是否使用 tokio sync 原语 tokio::sync::Semaphore + tokio_util::sync::PollSemaphore(line 2-3) 零——纯同步布尔
外部能看到的错误 永远不报"overloaded",只报 inner 的错误 Overloaded 单独错误类型(tower-0.5.3/tower/src/load_shed/error.rs 34 行)
能否和自身叠加 .concurrency_limit(10).concurrency_limit(100) 语义合理(实际上限取两者中较小的 10) .load_shed().load_shed() 几乎无意义——内层总是报 Ready,外层那一层永远不会触发
背压语义 真背压——Pending 会冒泡到上游 切断背压——把 Pending 转成错误,不向上游 Pending

一条关键洞察——ConcurrencyLimit 的 poll_ready 是 "先拿票、再探病" 两段式(line 65-79)——

// 先尝试拿信号量许可
if self.permit.is_none() {
    self.permit = ready!(self.semaphore.poll_acquire(cx));
}
// 有许可后才问 inner 是否 ready
self.inner.poll_ready(cx)

这个顺序意味着 ConcurrencyLimit 会把 inner 的 Pending 透传——如果 inner(比如下游数据库连接池)本身还没就绪、就算 ConcurrencyLimit 有空槽也会 Pending。这是正确的"双重背压"。

对比 LoadShed 的 poll_ready (mod.rs:43-54)——

self.is_ready = match self.inner.poll_ready(cx) {
    Poll::Ready(Err(e)) => return Poll::Ready(Err(e.into())),
    r => r.is_ready(),           // ← 把 Pending 塞进布尔缓存
};
Poll::Ready(Ok(()))              // ← 对上层撒谎说"我 ready 了"

LoadShed 故意让 poll_ready = Ready 不再意味着"call 一定会进真正的工作路径"——tower-service 的文档并没有这样承诺,但多数调用方默认这样理解。这不是 bug,是LoadShed 存在的全部理由——它要把"慢背压"强行转成"快失败"。

组合叠加的语义小心事项——

叠加方式 含义 何时用
.load_shed().concurrency_limit(100) 外层撕背压、内层真限流→ 超过 100 并发立刻 Err(Overloaded) HTTP server 推荐——满了就 503
.concurrency_limit(100).load_shed() 外层真限流、内层 LoadShed 只在 handler 自己 poll_ready 返回 Pending 时才拒 几乎永远错——满 100 个在途时外层 ConcurrencyLimit 照样 Pending,请求挂起而不是被拒;对总是 Ready 的 handler,内层 LoadShed 等于没装
.concurrency_limit(100).timeout(5s) 硬并发 + 单请求超时 必须这么叠(§6.6.2)——防慢请求占槽
.buffer(500).concurrency_limit(100) 排队 500 + 并发 100 适合"短 burst 可容忍额外 5× 延迟"的批处理
.load_shed().buffer(500).concurrency_limit(100) 三层——最外撕背压、中层吸 burst、内层真限 需要吸收 burst 时用(§6.4 结尾那段 stack)——"平滑 burst,硬拒雪崩",代价是排队延迟(见 §6.5.4)

Tower 中间件选错位置、语义马上反掉。

6.5.4 与 ch23 §23.8.2 调优旋钮源码账本的串联

第 23 章 §23.8.2 给出了一份"调优旋钮的源码账本":调一个生产 hyper 服务时可能要翻的源码范围,合计 15229 行、跨 hyper/hyper-util/tower 三个 crate,其中近八成来自 tower 全 crate——它是"可能要翻的范围",不是旋钮本身的体量。ch23 模板真正用到的 tower 代码是 timeout、load_shed、limit/concurrency 三个 Service 实现,合计 263 行(后两个是本章主角)——也就是说这套"HTTP 服务负载控制底盘"的核心 Service 实现不到 hyper-util auto Builder(1385 行)的 1/5。

把本章读到的 651 行 Buffer + 213 行 LoadShed + 117 行 ConcurrencyLimit 横向摆回 ch23 账本——

中间件 本章测得行数 ch23 §23.8.2 口径 差异说明
tower-0.5.3/tower/src/timeout/ (mod + future + layer) 147 (70+53+24) 70(只算 mod.rs) ch23 只统计了"Service 定义本体",本章把 future + layer 也列进去了
tower-0.5.3/tower/src/load_shed/ 全子树 213 76(只算 mod.rs) 同上——ch23 估的是"最小决策代码"
tower-0.5.3/tower/src/limit/concurrency/ 全子树 227 (10+60+40+117) 117(只算 service.rs) 同上
tower-0.5.3/tower/src/buffer/ 全子树 651 未列入(ch23 §23.8 没用 Buffer) ch23 的生产 stack 选择了无 Buffer 的 load_shed + concurrency_limit 直连——本章 §6.4 的三层叠加只适用于"容忍短 burst 的批处理",ch23 §23.8 的生产模板没有用 Buffer

两章的差异背后有一个隐藏的工程观——

生产 HTTP 服务默认不开 Buffer。Buffer 会把"瞬时压力"翻译成"队列延迟",但 HTTP 服务的核心诉求是"尾延迟可预测"——满了直接 503 比偷偷排队更诚实。ch23 §23.8 选择 load_shed + concurrency_limit 而不套 buffer,本质就是这个判断。

那什么场景真需要 Buffer? 三类——

  1. 服务内部是 !Clone(比如 AuthClient 持有大型连接池)——第 5 章的 Retry 要求 S: Clone,不套 Buffer 没法接 Retry。
  2. 批处理 / 后台消费(比如消息队列 worker)——吞吐优先、延迟可容忍到秒级。
  3. 多消费者共享一个串行 resource(比如一个 FFI 调用只能单线程跑)——Buffer 是最小成本的 "actor adapter"。

横向串联本专栏已有账本——

flowchart LR
    subgraph 本专栏["本专栏 Hyper&Tower"]
        CH02["ch02 tower-service<br/>401 行 / 83% 文档"]
        CH05["ch05 Timeout/Retry/RateLimit"]
        CH06["ch06 Buffer/LoadShed/ConcurrencyLimit<br/>651+213+227=1091 行"]
        CH19["ch19 hyper-util 全 crate<br/>12726 行"]
        CH21["ch21 client/legacy 子树<br/>6643 行"]
        CH22["ch22 axum 全家桶<br/>35223 行"]
        CH23["ch23 调优旋钮源码账本<br/>15229 行(近八成是 tower 全 crate)"]
    end
    CH02 --> CH05 --> CH06
    CH06 -.->|"load_shed + concurrency 193 行"| CH23
    CH19 -.->|hyper-util 1385 行| CH23
    CH21 -.->|客户端侧| CH23
    CH22 -.->|服务端侧| CH23

把这张图记在脑子里——"Tower 中间件薄、生态承载重"这条规律不是抒情,是可测量的:真正写在 tower crate 里的这三个生产级中间件合起来约 1100 行,但它和 hyper-util(12726)、axum(35223)组合起来就是 Rust HTTP 宇宙的主干。

对下一章的伏笔——ch07 将读 Balance / Discover / ready_cache。把本章 §6.5.2 的"跨 task 中间件复杂度规律"带过去,你会发现 Balance 又是一个 600+ 行的中间件——但它和 Buffer 不是同一种模式:它不 spawn worker,而是在自己的 poll_ready 里轮询 Discover 和 ReadyCache(内部是一个 FuturesUnordered)。读第 7 章时可以先猜:没有 Worker 的 Balance 靠什么把"后端何时就绪"这个异步事件收进来? 猜完再对源码。

6.5.5 三种策略的"默认值与关键 API 对照表"

读完上面两张表,再加一张API 维度的对照——把本章三个中间件在 ServiceBuilder 上的调用签名、默认行为、覆盖方式全部摊开——

维度 Buffer LoadShed ConcurrencyLimit
Builder API .buffer(bound: usize) .load_shed()(无参) .concurrency_limit(max: usize)
无默认值(必须显式传) bound —— max
有可选配置 Buffer::pair(svc, bound) 返回 (Buffer, Worker) 让你自己 spawn LoadShedLayer::new() —— 层化构造(layer.rs 33 行全部) ConcurrencyLimit::with_semaphore(svc, Arc<Semaphore>) 允许多 Service 共享一个信号量
底层依赖 tokio::spawn + tokio::sync::mpsc + tokio_util::sync::PollSender 零外部依赖 tokio::sync::Semaphore + tokio_util::sync::PollSemaphore
实现 Clone 总是可以(tower-0.5.3/tower/src/buffer/service.rs:133-144——只克隆 tx+handle) 仅当 S: Clone 时可以(tower-0.5.3/tower/src/load_shed/mod.rs:67-75)——is_ready 会重置为 false 仅当 S: Clone 时可以(tower-0.5.3/tower/src/limit/concurrency/service.rs:95-106)——新 clone 的 permit: None
drop 语义 最后一个 Buffer drop → mpsc 关闭 → worker 收 None → worker task 退出 不持有资源 ResponseFuture drop → permit drop → 信号量自动归还
对 poll_ready 反向不变量 遵守 Tower 协议(槽位预留机制),但槽可能被占很久(文档警告) 故意不传递 inner 的 Pending——永远 Ready 遵守 Tower 协议(无许可 Pending),且透传 inner 的 Pending
错误类型 BoxError(由 ServiceError/Closed 混合) BoxError——拒绝时里面装的是 Overloaded(tower-0.5.3/tower/src/load_shed/error.rs:11 独立类型),可用 is::<Overloaded>() 区分 继承 S::Error 不变
与 Service<Req> trait 关系 请求类型 Req 必须 Send + 'static(跨 task 传) 无额外约束 无额外约束

其中最容易被忽视的那一行是 ConcurrencyLimit::with_semaphore(svc, sem)——它允许你手动共享一个 Arc<Semaphore> 到多个 Service。典型用场:多个路由共享同一个"数据库连接池"容量——

let db_sem = Arc::new(Semaphore::new(50));   // 总池上限 50

let route_a = ServiceBuilder::new()
    .layer_fn(|s| ConcurrencyLimit::with_semaphore(s, db_sem.clone()))
    .service(handler_a);
let route_b = ServiceBuilder::new()
    .layer_fn(|s| ConcurrencyLimit::with_semaphore(s, db_sem.clone()))
    .service(handler_b);

这等价于跨路由的全局并发池——route_a 和 route_b 从同一个信号量里取 permit,两者在途请求合计不超过 50。而如果你朴素地写 .concurrency_limit(50) 两次,就变成两个独立的 50 并发池——总容量 100,和预期的 50 差了一倍。读源码(service.rs:27-36)你会发现 ::new() 只是 ::with_semaphore(inner, Arc::new(Semaphore::new(max))) 的糖衣。

6.5.6 三种 drop 路径的代价账本:多少 ns 丢进去?

本章 §6.2.6 提过 Buffer 的三种 cancel 路径,但没量化。这一节把三种中间件"被取消时发生了什么"以及"取消一次要花多少功夫"放一起——不是为了 benchmark 数字,而是为了让你在 code review 时一眼看出哪里会慢。

取消场景 Buffer LoadShed ConcurrencyLimit
已 poll_ready 但未 call mpsc 槽位被 PollSender 占着,直到 sender drop(可能很久)—— §6.2.7 文档警告的精髓 无状态,零代价 permit 被持有直到 service drop 或再次 call——可能长时间占槽
已 call 但调用方 drop 了返回的 future 若消息还没被 worker 处理:worker 下次 poll_next_msg 查 msg.tx.is_closed() → 跳过(tower-0.5.3/tower/src/buffer/worker.rs:81-101)——一次原子读 ResponseFuture::Overloaded drop 即释放——零代价 ResponseFuture drop → permit 被 drop → Semaphore 内部 atomic 归还
inner service 永久报错 Worker::failed() 写 Handle + 关 channel + 广播 → O(N) 错误 clone(tower-0.5.3/tower/src/buffer/worker.rs:106-137) 错误直接回传,LoadShed 本身不做额外工作 错误直接回传,permit 仍归还
所有 handle drop(服务关停) mpsc 关闭 → worker 收 None → task 退出(tower-0.5.3/tower/src/buffer/worker.rs:199-203) 无状态 Semaphore 随 Arc 归零 drop

一条实战提醒——ConcurrencyLimit 的 permit 跟着 ResponseFuture 走(tower-0.5.3/tower/src/limit/concurrency/future.rs:17-22),inner 的 response future 一完成、这个 future 被 drop,permit 就归还。对 HTTP 来说,response future 完成的时刻通常是响应头就绪,不包括之后流式发送 body 的时间。所以一个能流 30 分钟的 SSE stream,只在建立响应那一瞬间占用 permit——ConcurrencyLimit(50) 管不住长连接的数量。要限制这类长连接,得另外按连接或按 body 生命周期计数。

再引一次已有账本的观察——《Tokio 源码深度解析》专栏第 13 章 channel 源码 里讲过 bounded mpsc 的 reserve() 本质是从信号量拿一个 permit——释放只是一次原子计数归还,本身很便宜。所以 Buffer 的"槽位预留"本身不慢,慢的是槽位被用户代码逻辑占久了(忘记及时 call 或 drop)。

6.5.7 Buffer ResponseFuture 状态机:78 行读出三态

tower-0.5.3/tower/src/buffer/future.rs 只有 78 行,但它定义了一个三态机——是调用方那端"等 oneshot 返回 future、再等那个 future 出 response"的完整编舞。读这 78 行的回报比你想的大:它是 Tower 里少见的"双层 await"实现范本。

三个状态——

stateDiagram-v2
    [*] --> Failed: Buffer 一 call 就炸(channel 关了)
    [*] --> Rx: call 成功 → 拿到 oneshot Receiver
    Rx --> [*]: worker 在真 call 前挂了(收到 ServiceError)或 oneshot 被丢弃(Closed),直接返回错误
    Rx --> Poll: 收到 inner 的 response future
    Poll --> [*]: 最终 response 就绪
    Failed --> [*]: 立刻返回错误

对应到源码——ResponseState::Failed 在 call 同步检测到 channel 关闭时直接构造(tower-0.5.3/tower/src/buffer/service.rs:127-128)、ResponseState::Rx 在 call 成功送出消息后包住 oneshot receiver、ResponseState::Poll 是 oneshot 收到 inner 返回的 "工作 future" 后转入的态——最后那层 fut.poll(cx) 才是 inner service 真正的响应 future。

这个三态机等价于一个跨 task 的 Promise chain——oneshot 把 "inner service future 的所有权"从 worker 搬回了调用方的 task。搬回来之后,真正的计算(比如 TCP 往返、磁盘 IO)发生在调用方所在的 runtime上——而不是 worker 里。这是 Buffer 的非对称设计——

阶段 执行者
请求排队 → inner.poll_ready worker task
inner.call(构造 future) worker task
future.poll(真正跑 IO) 调用方 task

换句话说——worker 只是一个"派工台",真正干活的线程是调用方自己的。这个设计的好处是 worker 不会被任何一个慢请求阻塞——它只负责"从 queue 取、派单、立刻回到 loop"。如果 inner 是 hyper-util 的 legacy Client,那实际的 HTTP 往返跑在发起请求的那个 tokio task 里,worker 只参与了一个 poll_ready + call 的瞬间。

这也解释了为什么 Buffer 的 worker 行号里看不到"等待 inner 完成"的代码——它根本不需要等。service.call(msg.request) 返回 future 后,worker 立刻 msg.tx.send(Ok(response)) 把 future 塞进 oneshot(tower-0.5.3/tower/src/buffer/worker.rs:177),然后 continue loop。

6.5.8 tracing 的 span 穿越:一个容易忽略的性能点

§6.2.4 提过 Buffer 会把 tracing::Span::current() 塞进 Message 传给 worker(tower-0.5.3/tower/src/buffer/service.rs:116)。这个设计的性能含义值得单独说——

  • tracing::Span 是 Clone 的——但不是零成本。Span 内部持有 Dispatch(Arc<dyn Subscriber>)和一个 ID。Span::current() 要走一次 thread-local 读取。
  • 每个 buffer 请求要 clone 一次 Span——如果你启用了 tracing,每次 Buffer::call 都比不用 Buffer 多一次 Arc::clone + thread-local 访问。开销很小,但在高 QPS 场景下会出现在火焰图上。
  • worker 进入 span 用 let _guard = msg.span.enter()(tower-0.5.3/tower/src/buffer/worker.rs:155)——每次处理消息 enter+drop 一次。这是让 inner service 内部的 tracing::info! 能挂回调用方 span的唯一手段。没有这一步,worker 的 task 和调用方的 task 是两个独立的 span 上下文,日志就断了。

一条经验——如果你用 Buffer 包裹一个会记录大量 trace 的 service,先量一下 tracing 订阅端对应 target 的开销,再去优化 inner service 的 IO。这不是 Buffer 的锅,是 tracing 的固有成本;但 Buffer 把 "跨 task 的 span 透传"这一成本显式暴露出来——读这一段代码能训练你对"哪些 Arc::clone 在热路径上"的敏感度。

对比一下——LoadShed 和 ConcurrencyLimit 都不跨 task——它们的 call 就在调用方自己的 task 里,tracing span 天然延续,不需要任何额外操作。这又是一个"跨 task 中间件 vs 同步中间件"的隐性成本差。

6.5.9 一条可以直接落地的"阅读顺序"清单

读完本章前面所有表格和账本,给一个具体的"按这个顺序读核心文件"建议——

  1. 先读 message.rs 16 行——理解整条 actor 链的最小承载单元
  2. 再读 tower-0.5.3/tower/src/load_shed/mod.rs 76 行——感受"同步中间件"的纯净样子(没有任何跨 task 协议)
  3. 然后读 tower-0.5.3/tower/src/limit/concurrency/service.rs 117 行——同步中间件但引入外部资源(Semaphore),理解"资源约束是如何通过 Future 生命周期自动归还"
  4. 接着读 tower-0.5.3/tower/src/buffer/service.rs 144 行——Buffer 的客户端半侧,看"poll_reserve + send_item + oneshot::channel"三件套怎么拼
  5. 然后读 tower-0.5.3/tower/src/buffer/worker.rs 226 行——异步中间件的主体,重点读 Worker::failed (line 106) 和 Future::poll (line 147)
  6. 最后读 tower-0.5.3/tower/src/buffer/future.rs 78 行——把调用方侧的三态机和 worker 的 oneshot.send 对齐起来

按这个顺序读下来,不到 700 行代码,2-3 小时完成——但收获的是"同步 vs 异步中间件的普适心智模型"。这个模型会在你读第 7 章的 Balance、第 20 章的连接池、《Tokio 源码深度解析》专栏的 sync 原语时反复复用。

6.5.10 修两条容易误导的说法:Buffer 的"spawn 次数"与"无 bound 默认值"

最后一小节,修正两条和 0.5.3 源码不一致、容易被想当然的说法——

说法一:"Buffer 会在每个 call 里 spawn 一个新 task" —— 错。实测 tower-0.5.3/tower/src/buffer/service.rs:49-60——tokio::spawn(worker) 只在 Buffer::new 里被调用一次,对应"每把 Buffer 包上一个 Service 就 spawn 一个 worker"。call 路径只经过 send_item + oneshot::channel——零 spawn。

但有一个隐式 spawn——每次调用 BufferLayer::layer(svc) 也会走 Buffer::new(tower-0.5.3/tower/src/buffer/layer.rs:53-55)。这意味着如果你在每个新连接的 service 构造逻辑里套 BufferLayer::new(100),每个新连接都会 spawn 一个 worker task——这就是容易被忽视的"per-connection worker"。正确做法:把 Buffer 放在按连接构造 service 的逻辑之外(整个 server 一个 worker)、或者根本不要用 Layer 形式,直接在外层 Buffer::new 一次。

说法二:".buffer() 默认 bound 是 0" —— 错,根本没有默认值。ServiceBuilder::buffer(bound: usize) 必须显式传。但 tokio 的 mpsc::channel(bound) 传 0 会 panic(tokio-1.46.1/tokio/src/sync/mpsc/bounded.rs 里有 assert!(buffer > 0, "mpsc bounded channel requires buffer > 0")),所以即使你真的传 0 也会立刻挂。正确的 bound 选择见 §6.2.7——但"永远不传 0"这条硬约束,tower 的 Buffer::new 文档没写,只写在 tokio mpsc::channel 的文档里(tokio-1.46.1/tokio/src/sync/mpsc/bounded.rs:130,"Panics if the buffer capacity is 0")。

对比 LoadShed 无参、ConcurrencyLimit 必填 max——三个中间件的"强制配置 surface"如下——

中间件 零参数版本 必填参数 是否有"沉默变坏"的边界值
Buffer 无(必填 bound) bound: usize bound=0 会立刻 panic;bound 过大会吃内存
LoadShed 有——.load_shed() —— 无
ConcurrencyLimit 无(必填 max) max: usize max=0 等价于"永远 Pending"——不会 panic,但服务永远不响应

max=0 的沉默陷阱值得特别注意——tokio::sync::Semaphore::new(0) 完全合法、只是 poll_acquire 永远返回 Pending。ConcurrencyLimit 套一个 max=0 的信号量,对上层的表现是"永久 poll_ready Pending"——服务没报错,只是所有请求都挂着。如果你的配置文件从环境变量读 max、环境变量没设默认成 0——直接中奖。

6.5.11 本章账本归并回本专栏主表

把 §6.5.1 的 "651 + 213 = 864 行" 加上 §6.5.3 讨论的 ConcurrencyLimit 全子树 227 行,本章完整覆盖的 tower 源码是——

ch06 覆盖行数=651⏟buffer/+213⏟load_shed/+227⏟limit/concurrency/=1091 行\text{ch06 覆盖行数} = \underbrace{651}_{\text{buffer/}} + \underbrace{213}_{\text{load\_shed/}} + \underbrace{227}_{\text{limit/concurrency/}} = \mathbf{1091 \text{ 行}}

这 1091 行对应的功能密度——三种完全不同的容量策略、三种 drop 语义、两种(同步 / 异步)中间件范式、两种 tokio sync 原语的用法(Semaphore + mpsc+oneshot)——平均每 363 行一个独立概念。和 ch22 的 axum(35223 行,大部分是 HTTP 协议适配与胶水)对比,tower 这 1000 来行里几乎每一段都在讲一个抽象——这也是先读 tower 再读 axum 更顺的原因——前者 1000 行每一行都在讲抽象,后者 30000 行大部分是 HTTP 协议适配器的胶水。

把本章 1091 行归入本专栏账本(累计)——

章节 覆盖 crate 子树 实测行数 累计
ch02 tower-service/ 401 401
ch04 tower-0.5.3/tower/src/util/ 重点 约 500 ~890
ch05 tower-0.5.3/tower/src/{timeout,retry,limit/rate}/ 1325 ~2230
ch06 tower-0.5.3/tower/src/{buffer,load_shed,limit/concurrency}/ 1091 ~3320
ch19 hyper-util/ 全 crate 12726 ~16000
ch21 hyper-util 仓库 src/client/legacy/ 子树 6643 (ch19 子集)
ch22 axum/ 全家桶 35223 ~51300
ch23 调优时可能要翻的源码范围(近八成是 tower 全 crate) 15229 (横跨多 crate,与上面各行有重叠)

到 ch06 末尾,你已经跟读了约 3300 行 tower 源码——超过整个 tower 0.5.3(11935 行)的 1/4;第 7 章的 balance/ + discover/ + ready_cache/ + load/ 合起来还有 2271 行。

6.6 一些容量工程的"反常识"

读完三种策略和它们的源码,给你三条容易被忽视的经验。

6.6.1 Buffer 不是"无限容量"的替身

新手最容易的误解是把 Buffer 当"加大吞吐"的魔法——"我加一个 buffer(10000),吞吐就能上来"。实际上:

  • Buffer 不能加速 inner service——worker 是串行 poll inner 的。
  • Buffer 只能吸收 burst,不能提升稳态吞吐。稳态 throughput = min(producer_rate, consumer_rate)——buffer 大了只让"满起来慢一点"。
  • Buffer 大 → 请求在队列里等的时间长 → 尾延迟炸掉。p99 latency 受 buffer 深度直接影响。

如果你真的需要"更高的稳态吞吐",要么加并行度(水平扩展)、要么优化 inner service。Buffer 只是"容量缓冲",不是"容量放大器"。

6.6.2 ConcurrencyLimit 对慢请求敏感

假设你设 .concurrency_limit(100) 期望"最多 100 个并发请求"。但 "100 个并发" 只是一个字面意思——它不限制每个请求的耗时。如果下游变慢(从平均 50ms 涨到 500ms),100 个槽会被"满上"——总吞吐从 2000 QPS 变成 200 QPS。

典型场景:数据库慢查询。平时 100 个 DB 并发完全够用,某天执行计划突然变差,单查从 10ms 变 1s——concurrency_limit(100) 的上限没变,但吞吐崩掉了。服务本身不报错,只是 100 个槽很快被慢请求占满、poll_ready 越来越多地 Pending,客户端看到的每个请求都慢了约 100 倍。

防守方法:ConcurrencyLimit 一般要配 Timeout 使用。单个请求超时了就释放许可,防止慢请求独占槽位。ServiceBuilder 的 rustdoc 示例里 .concurrency_limit(64).timeout(Duration::from_secs(10)) 反复成对出现就是这个原因(tower-0.5.3/tower/src/builder/mod.rs)。

6.6.3 LoadShed 在客户端通常错了

第三个"反常识":LoadShed 很少应该出现在客户端代码里。

客户端调用远端服务,偶尔的容量限制(连接池满、inflight 达上限)是正常的——等一下就好。LoadShed 把所有"暂时满"都翻译成错误——客户端代码需要在每一次调用处判断"如果是 Overloaded 要不要 retry 要不要冒泡",非常不友好。

LoadShed 的正确位置几乎总是最外层 HTTP 服务端——在这个位置拒绝比挂起 TCP 连接更健康。在客户端用 ConcurrencyLimit 或者 Buffer,让调用方安静地等待,直到真的满太久才通过 Timeout 放弃。

6.7 落到你键盘上

本章读完之后:

  • 读 tower-0.5.3/tower/src/buffer/future.rs——很短,是 Buffer 调用方等 oneshot 的 future 实现。里面有一个细节:如果 worker drop 了而 oneshot 还没发消息,调用方怎么识别?答案在 Rx 分支的 Err(_) => Poll::Ready(Err(Closed::new().into()))——oneshot receiver 收到 RecvError 就说明 worker 连同 sender 一起 drop 了。
  • 写一段压测:用 .concurrency_limit(10) 和 .load_shed().concurrency_limit(10) 各 wrap 一个延迟 100ms 的 handler,分别发 1000 并发请求。第一种所有请求都会慢慢处理完(10 个一批,约 10 秒);第二种绝大多数直接拿到 Overloaded(1000 个同时发出时实测 990 个被拒,只有 10 个成功)。两者延迟分布、成功率曲线完全不同——这就是策略的力量。
  • 在你生产项目里盘一遍:哪些地方用 concurrency_limit 但没配 timeout?哪些地方 buffer 开得过大?哪些地方该用 load_shed 却没用?对着本章三种策略的适用场景过一遍能发现一批隐患。

下一章我们继续往 Tower 的深水区走——Balance、Discover、ready_cache,这一组中间件把"多个后端 endpoints"织成一个"单一 Service",是微服务里 client-side load balancing 的基础。