Rust in Everything
基于 tower 0.5.3

tower 解决什么问题

主线位置:就绪主线的起点。本页回答为什么 tower 要在 call 之前多一步 poll_ready。

flowchart TB
  subgraph push[没有就绪约定]
    a1[请求到达] --> a2[先接下]
    a2 --> a3[服务内部排队]
  end
  subgraph pull[tower]
    b1[调用方 poll_ready] -->|Pending| b2[调用方等着]
    b2 -->|wake| b1
    b1 -->|Ready| b3[call 发出请求]
  end

问题:中间件写一次,到处用

超时、限流、重试、负载均衡,这些逻辑和协议无关:HTTP 客户端要,gRPC 服务端要,数据库连接池也要。如果每个库各写一遍,就会有十份超时、十份限流,行为各不相同。

tower 的做法写在 tower/src/lib.rs 第 13 行,整个 crate 文档的第一行:

`async fn(Request) -> Result<Response, Error>`

把「收一个请求、异步地回一个响应或错误」抽象成一个 trait,也就是 Service:

pub trait Service<Request> {
    type Response;
    type Error;
    type Future: Future<Output = Result<Self::Response, Self::Error>>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;

    fn call(&mut self, req: Request) -> Self::Future;
}

客户端和服务端都是 Service:服务端实现它,收到的请求交给它处理;客户端也实现它,call 就是发一个请求出去。中间件是包着另一个 Service 的 Service,在调用内层之前或之后加一点行为。第 25 行起的文档说,这样写出来的超时、限流、负载均衡与协议无关,可以随意组合。Layer 负责「拿一个服务、返回包好的服务」,ServiceBuilder 把多个 Layer 叠起来,这些放在 02 核心契约里讲。

这个形状本身并不新。新的是 Service 里那个 poll_ready。

别人怎么做

Go:func(http.Handler) http.Handler

Go 标准库的 http.Handler 只有一个方法 ServeHTTP(ResponseWriter, *Request)。中间件的惯用写法是一个函数,收一个 Handler、返回一个 Handler,标准库自己的 TimeoutHandler(h Handler, dt time.Duration, msg string) Handler、StripPrefix、MaxBytesHandler 都是这个形状(Go 1.27 的 net/http/server.go)。

接口里没有「你现在能接吗」这个问题。Server 为每个连接起一个 goroutine(go c.serve(connCtx)),请求到达 ServeHTTP 时已经被接下了。要限并发,只能在 handler 里面做:要么让这个 goroutine 阻塞排队,要么直接回一个 503。

Finagle:Req => Future[Rep]

Twitter 的 Finagle(Scala)在论文《Your Server as a Function》里把服务定义成 type Service[Req, Rep] = Req => Future[Rep]:给一个请求,立刻返回一个将来的响应。tower 的 call 就是这个形状,Service 文档的「Functional」一节说的也是这件事:call 立即返回一个 Future,处理可以在之后任何时候、任何线程上进行。

tower 早期受 Finagle 影响很深。2017 年讨论中间件 trait 的 #2 里,作者说这个 trait「is indeed exactly like finagle's Filter」;引入 ServiceBuilder 的 #175 讨论命名时,引的也是这篇论文里一串 andThen 的例子。仓库里还有两处直接标了出处:P2C 负载均衡的文档引了 Finagle 的指南,PeakEwma 注明「derived from Finagle」。

actix:Transform

actix-web 的中间件实现 actix-service 的 Transform:new_transform(&self, service: S) 返回一个 future,完成后才得到包好的服务。tower 的 Layer::layer 是同步的,直接返回。

actix-service 2.0.3 的 Service trait 其实也有 poll_ready,但它的文档写的是另一套约定:

This is a best effort implementation. False positives are permitted. It is permitted for the service to return Ready from a poll_ready call and the next invocation of call results in an error.

Invoking call without first invoking poll_ready is permitted. Implementations must be resilient to this fact.

也就是说,poll_ready 在 actix 里是一个提示,不是约定;服务不能指望调用方先问过再发。另外它的方法都取 &self,文档说是为了在常见情况下省开销,可变状态要自己放进 Cell、RefCell 或 Mutex。

中间件形状发请求前问能不能接问过之后
Go net/httpfunc(Handler) Handler没有这一步—
Finagle(论文)Req => Future[Rep]没有这一步—
actix-service 2.0.3Transform,异步构造poll_ready,可以不调返回 Ready 也可能失败
tower 0.5.3Layer,同步构造poll_ready,必须先调返回 Ready 后,call 一定被接下

poll_ready:先问,再发

tower 的约定写在 Service 文档的「Backpressure」一节和 poll_ready 的注释里:服务满了时 poll_ready 返回 Pending,等它又能接了再叫醒调用方的任务;调用方负责在 call 之前确认服务已经就绪;call 的实现允许在没就绪时 panic。poll_ready 还可以预留资源,留给紧接着的那次 call 用。这几条规则的细节放在 Service trait。

💡TIP
打个比方:餐厅门口的领位员

到了一家满座的餐厅。Go 的做法是谁进门都先安排坐下,厨房按顺序慢慢做;tower 的做法是门口站一个领位员(poll_ready),先看有没有空桌(信号量里的许可)。有空桌,领位员把桌子留给你(ConcurrencyLimit 把许可存进 self.permit),你再点菜(call);没空桌,你在门口等,领位员记下你的手机号(Context 里的 waker),有桌子空出来就打给你(wake)。客人吃完离开(响应 future 被 drop),桌子才空出来。

比方到这里为止:真实的餐厅里,被领到桌前的客人总会点菜;tower 里 call 可能永远不来,调用方拿到 Ready 之后直接把服务丢了。所以预留的资源必须能在 drop 时自己释放,这是 poll_ready 规则里的一条。

为什么要这么严格?issue #3(「Figure out backpressure」)给了一个具体的场景。如果服务总是先把请求接下、在内部排队,调用方(当时是 tokio-proto)只能按连接限制在途请求数,比如每个 HTTP/2 连接 100 个。这只在各连接互相独立时管用:要是所有连接共用一个容量 1000 的全局队列,而开着 10 万个连接,10 个连接就能把队列塞满,其余连接却还各自能再积压 100 个请求。issue 里写的理想情况是:全局队列满了,就别再接新连接。

这要求「满了」这个信号能从最里面一路传到最外面的调用方。poll_ready 就是这条通道:每一层的 poll_ready 都先问内层,内层 Pending,外层也 Pending,最外面的调用方就停在 ready().await 上,不再产生新请求。约定严格,中间件才敢在 poll_ready 里把资源真正占下来,call 的时候不用再检查一遍、也不会出现「刚才问还有,现在发已经没了」。

📝NOTE
冷知识:poll_ready 加过、删过、又加回来,最后才变成必须调用

2016 年 9 月,tower 的前身 tokio-service 的 Service 里就有 fn poll_ready(&self) -> Async<()>。一个月后准备发 0.1 时被删掉了,提交说明是「Drop poll_ready for now, given that we're uncertain about the overall backpressure story, and it can be added easily as a defaulted method later on. Backpressure should be a major goal in an upcoming release.」

2017 年 9 月的 #6(「Add backpresure capabilities to Service」,修的就是上面的 issue #3)把它加了回来,但当时定位是提示:「poll_ready is expected to be a hint and will be implemented in a best effort fashion. It is permitted for a Service to return Ready from poll_ready and the next invocation of Service::call fails.」这和今天 actix-service 文档里的说法几乎一字不差。

2019 年 2 月的 #161(「Require poll_ready to be called before call」)改成了现在的约定:「This allows Service::call to panic in the event the user of the service omits poll_ready or does not wait until Ready is observed.」两个 PR 都最早出现在 tower 0.1.0 里。ConcurrencyLimit::call 里那句 .expect("max requests in-flight; poll_ready must be called first") 敢直接 panic,靠的就是 #161。

实验:等待发生在 ready() 上

5 个任务共用一个最多 2 个并发的服务,每个请求处理 100ms。每个任务先 ready().await,再 call:

tower = { version = "=0.5.3", features = ["limit", "util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt", "time", "test-util"] }
use std::time::Duration;
use tokio::time::Instant;
use tower::{BoxError, Service, ServiceBuilder, ServiceExt};

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
  let start = Instant::now();
  // 每个请求处理 100ms;最多 2 个同时在途
  let svc = ServiceBuilder::new()
    .concurrency_limit(2)
    .service_fn(|id: u32| async move {
      tokio::time::sleep(Duration::from_millis(100)).await;
      Ok::<_, BoxError>(id)
    });

  let mut tasks = Vec::new();
  for id in 1..=5 {
    // clone 共享同一个信号量,但每个副本自己去取许可
    let mut svc = svc.clone();
    tasks.push(tokio::spawn(async move {
      svc.ready().await.unwrap();
      let ready_at = start.elapsed().as_millis();
      let resp = svc.call(id).await.unwrap();
      println!("req {resp}: ready at {ready_at:>3}ms, done at {:>3}ms", start.elapsed().as_millis());
    }));
  }
  for t in tasks {
    t.await.unwrap();
  }
}

start_paused = true 让 tokio 的时钟暂停,没有任务可跑时直接跳到下一个定时器(见 Sleep 的一生),所以打印的时间是精确的整数,不受机器快慢影响。

❗IMPORTANT
动脑筋:第 5 个请求在第几毫秒 ready,第几毫秒完成?平台 M2 Max、rustc 1.99.0。每个请求的处理时间都是 100ms,并发上限是 2。

这个实验要看的不是总耗时,而是等待发生在哪里:拿不到许可的任务停在 ready() 上,请求还没有构造出来交给服务,服务里也没有任何排队的请求。

📝NOTE
没有蠢问题

问: 让调用方在 ready() 上等,和让服务在内部排队,最后不都是等吗?

答: 等的人一样,能做的选择不一样。请求还没发出去,调用方就可以决定不等了:外面套一层 LoadShed,没就绪就立刻返回 Overloaded 错误(「service overloaded」);P2C 负载均衡问多个后端谁就绪,挑一个能接的发;或者干脆停止从连接上读新请求。服务内部排队时请求已经被接下,这些选择都没有了,只能等它排到或者超时。

动脑筋答案

req 1: ready at   0ms, done at 100ms
req 2: ready at   0ms, done at 100ms
req 3: ready at 100ms, done at 200ms
req 4: ready at 100ms, done at 200ms
req 5: ready at 200ms, done at 300ms

跑了 3 次,输出完全相同。第 5 个请求在 200ms ready,300ms 完成:

  • 请求 1、2 在 0ms 拿到两个许可,3、4、5 的 poll_ready 返回 Pending。
  • 100ms 时 1、2 的响应 future 完成并被 drop,许可归还,3、4 拿到。5 继续等。
  • 200ms 时 3、4 完成,5 才拿到许可,再处理 100ms。

每个请求从 ready 到 done 都正好是 100ms:服务真正处理的时间没有变,多出来的等待全在 ready() 上。

小结

  • tower 把「请求 → 响应」抽象成 Service,中间件是包着 Service 的 Service,与协议、客户端还是服务端无关。
  • call 的形状来自 Finagle 的 Req => Future[Rep];Go 的 Handler 和 Finagle 论文里的 Service 都没有「能不能接」这一步。
  • actix-service 也有 poll_ready,但只是提示,不调也行;tower 要求必须先调,Ready 之后 call 一定被接下。
  • 约定严格,中间件才能在 poll_ready 里预留资源,Pending 才能一路传回调用方:这就是背压。
  • 这条约定是逐步收紧的:#6(2017)加回 poll_ready 时还是提示,#161(2019)才要求必须先调。

💬 关联讨论

发起讨论
加载中...