Rust in Everything
基于 tower 0.5.3

LoadShed

主线位置:就绪主线的终点。内层的 Pending 传到这里就停了,LoadShed 把它换成 Ready,再在请求主线上换成一个立即失败的 future。

flowchart TB
  pr["poll_ready"] --> ip{问内层}
  ip -->|"Ready(Ok)"| t["is_ready = true"]
  ip -->|Pending| f["is_ready = false"]
  ip -->|"Ready(Err)"| err[返回错误]
  t --> ok["返回 Ready(Ok)"]
  f --> ok
  call["call(req)"] --> chk{is_ready?}
  chk -->|是| inner["置回 false,调内层"]
  chk -->|否| ov[Overloaded future]

tower 的默认行为是背压:内层忙,poll_ready 就 Pending,调用方等。等待本身要占资源(连接、任务、内存),过载时排队越来越长,每个请求都慢。LoadShed 换了一种回答:忙就立刻拒绝,让调用方马上知道,而不是让它排着。

结构:一个布尔值

LoadShed:

#[derive(Debug)]
pub struct LoadShed<S> {
    inner: S,
    is_ready: bool,
}

is_ready 记录上一次 poll_ready 时内层是否就绪。LoadShedLayer 没有任何参数。

poll_ready:问一次,总说 Ready

poll_ready 和 call:

    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(()))
    }

    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()
        }
    }
  • 内层报错照样传出去。 只有 Pending 被改写,Ready(Err) 原样返回(装箱成 BoxError)。服务坏了和服务忙是两回事。
  • 就绪只算一次。 call 用掉 is_ready 后立刻置回 false。下一个请求必须重新 poll_ready,否则直接 Overloaded。这和 Service trait 的规则一致:一次 Ready 只换一次 call。
  • 没 poll_ready 就 call 不会 panic。 is_ready 初始是 false,结果是一个 Overloaded 错误。
  • 内层还是被问过了。 内层返回 Pending 时已经用 cx 登记了 waker,可能还占了排队位置。LoadShed 不再等它,但这些副作用留在内层身上,后面会看到它的后果。

ResponseFuture 是两个状态的枚举:

    enum ResponseState<F> {
        Called {
            #[pin]
            fut: F
        },
        Overloaded,
    }

Called 转发内层 future 并把错误装箱;Overloaded 第一次 poll 就返回 Err(Overloaded::new().into())。Overloaded 的 Display 是 service overloaded,调用方用 downcast_ref::<Overloaded>() 和别的错误区分,比如在 HTTP 服务里转成 503。

Clone 是手写的,副本的 is_ready 是 false。源码注释的理由是「a cloneable inner service likely tracks readiness per clone」:内层的就绪状态多半是跟着每个副本走的(比如 ConcurrencyLimit 的许可),副本不能继承一个它没有的就绪。

顺序:LoadShed 要在外面

LoadShed 只能看见它里面的 Pending。最常见的搭配是 ConcurrencyLimit:并发满了就拒绝,而不是排队。两种摆法,四个任务同时发请求,内层每个请求耗时 100ms,并发上限 2:

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

async fn work(id: u32) -> Result<u32, BoxError> {
  sleep(Duration::from_millis(100)).await;
  Ok(id)
}

async fn run<S>(name: &str, svc: S)
where
  S: Service<u32, Response = u32, Error = BoxError> + Clone + Send + 'static,
  S::Future: Send,
{
  let start = Instant::now();
  let mut tasks = Vec::new();
  for id in 1..=4 {
    let mut svc = svc.clone();
    tasks.push(tokio::spawn(async move {
      let r = svc.ready().await?.call(id).await;
      Ok::<_, BoxError>((id, r, start.elapsed().as_millis()))
    }));
  }
  println!("{name}");
  for t in tasks {
    let (id, r, ms) = t.await.unwrap().unwrap();
    match r {
      Ok(v) => println!("  req {id}: ok {v} at {ms}ms"),
      Err(e) => println!("  req {id}: err \"{e}\" at {ms}ms"),
    }
  }
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
  let a = ServiceBuilder::new()
    .load_shed()
    .concurrency_limit(2)
    .service_fn(work);
  run("A: LoadShed > ConcurrencyLimit(2)", a).await;

  let b = ServiceBuilder::new()
    .concurrency_limit(2)
    .load_shed()
    .service_fn(work);
  run("B: ConcurrencyLimit(2) > LoadShed", b).await;
}

M2 Max、rustc 1.99.0、--release,时钟暂停,连跑 3 次输出相同:

A: LoadShed > ConcurrencyLimit(2)
  req 1: ok 1 at 100ms
  req 2: ok 2 at 100ms
  req 3: err "service overloaded" at 0ms
  req 4: err "service overloaded" at 0ms
B: ConcurrencyLimit(2) > LoadShed
  req 1: ok 1 at 100ms
  req 2: ok 2 at 100ms
  req 3: ok 3 at 200ms
  req 4: ok 4 at 200ms
  • A:LoadShed 在外。请求 3、4 的 ConcurrencyLimit 取不到许可,返回 Pending,LoadShed 记下来、照样说 Ready,call 时立即失败,0ms 就拿到 service overloaded。
  • B:ConcurrencyLimit 在外。等许可发生在它自己的 poll_ready 里,调用方在 ready().await 上排队;轮到 LoadShed 被问的时候,里面的 service_fn 永远就绪,它没有东西可拒绝。请求 3、4 排到 100ms 才开始,200ms 完成。LoadShed 等于不存在。

所以记住一句话:LoadShed 把它里面的等待变成拒绝,管不到它外面的等待。 在 ServiceBuilder 里,先写的在外层,.load_shed() 要写在 .concurrency_limit() 前面(见 ServiceBuilder)。

反过来也要注意:LoadShed 不知道 Pending 是谁给的。它里面任何一层返回 Pending(比如 RateLimit 在等窗口、Buffer 的通道满了),请求都会被拒绝。

📝NOTE
冷知识:LoadShed 一开始就是为了配并发限制写的

LoadShed 在 2019 年 3 月以独立的 tower-load-shed crate 加入(PR #204,随 tower-load-shed 0.1.0 发布,tower 0.1.0 重新导出了它)。PR 描述只有一段:「Provides a middleware that immediately fails any request if the underlying service isn't ready yet. This is useful when used in conjunction with InFlightLimit or others, so that requests are rejected immediately, instead of keeping track of extra pending requests.」InFlightLimit 就是后来改名的 ConcurrencyLimit。第一版用的还是 futures 0.1 的 Poll<(), Error>,但 is_ready 字段、call 里「用掉就置回 false」、Clone 时 is_ready: false,这几处和本页的 0.5.3 代码是同一个写法。

被拒绝之后,内层还在排队

A 里被拒绝的任务随后就结束了,它手里那份服务被 drop,看不出问题。但如果同一个服务实例活得比请求长(比如一个连接上复用一份服务),被拒绝的那次 poll_ready 留下的东西就要算一算了。

ConcurrencyLimit 取不到许可时,PollSemaphore 里那个取许可的 future 已经排进了信号量的等待队列;LoadShed 不等它,但也没有把它撤下来。

tower = { version = "=0.5.3", features = ["load-shed", "limit", "util"] }
tokio = { version = "=1.54.1", features = ["rt", "macros", "sync"] }
use std::{convert::Infallible, future::poll_fn, sync::Arc, task::Poll};
use tokio::sync::Semaphore;
use tower::{
  limit::GlobalConcurrencyLimitLayer, load_shed::LoadShedLayer, service_fn, Service,
  ServiceBuilder,
};

async fn ready_now<S: Service<u32>>(svc: &mut S) -> bool {
  poll_fn(|cx| Poll::Ready(svc.poll_ready(cx).is_ready())).await
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let sem = Arc::new(Semaphore::new(1));
  let svc = ServiceBuilder::new()
    .layer(LoadShedLayer::new())
    .layer(GlobalConcurrencyLimitLayer::with_semaphore(sem.clone()))
    .service(service_fn(|x: u32| async move { Ok::<_, Infallible>(x) }));

  let (mut a, mut b, mut c) = (svc.clone(), svc.clone(), svc.clone());

  ready_now(&mut a).await;
  let fut_a = a.call(1); // a holds the only permit
  ready_now(&mut b).await; // LoadShed says Ready, inner was Pending
  println!("b.call -> {:?}", b.call(2).await.map_err(|e| e.to_string()));

  drop(fut_a); // permit released
  println!("after fut_a dropped: available = {}", sem.available_permits());
  ready_now(&mut c).await;
  println!("c.call -> {:?}", c.call(3).await.map_err(|e| e.to_string()));
  ready_now(&mut b).await;
  println!("b.call -> {:?}", b.call(4).await.map_err(|e| e.to_string()));
}
❗IMPORTANT
动脑筋:fut_a 被 drop 以后,c 能拿到许可吗?

并发上限 1。a 占着唯一的许可,b 被拒绝了一次,但 b 这个实例还活着。然后 fut_a 被 drop,许可归还,此时没有任何请求在处理。第二行打印的 available 是几?c.call 成功还是 service overloaded?最后 b.call 呢?答案在文末。

动脑筋答案

M2 Max、rustc 1.99.0、--release,连跑 3 次,输出相同:

b.call -> Err("service overloaded")
after fut_a dropped: available = 0
c.call -> Err("service overloaded")
b.call -> Ok(4)
  • 第一行:b 的 ConcurrencyLimit 取不到许可,LoadShed 记下 is_ready = false,call 返回 Overloaded。但 b 取许可的请求留在了信号量的等待队列里。
  • available = 0:fut_a 归还许可时,信号量发现队列里有 b,直接把许可交给了 b 的等待节点(见 batch_semaphore),计数没有加回来。这时没有任何请求在跑,许可却被一个已经被拒绝过的实例占着。
  • c.call 被拒绝:c 取不到许可,于是 c 也被 shed 了,而系统其实是空闲的。
  • b.call 成功:b 再次 poll_ready,PollSemaphore 发现许可早已到手,b 就绪,请求通过。

被拒绝的实例只要不被 drop 也不再 poll_ready,就会一直占着那个许可。每个请求用一份新 clone、用完就丢,或者被拒绝后照常继续 poll_ready,都能避开这种情况。

小结

  • LoadShed 只有一个 is_ready:poll_ready 问内层并记下结果,自己总是返回 Ready(Ok)(内层报错除外)。
  • call 时内层就绪就调用并把 is_ready 置回 false,否则返回一个立即失败的 Overloaded(service overloaded)。不 poll_ready 就 call 不 panic,而是得到 Overloaded。
  • 它只能把里面的 Pending 变成拒绝:要配合 ConcurrencyLimit,LoadShed 必须在外层;里面任何一层的 Pending 都会导致拒绝。
  • 内层被问过的副作用还在:ConcurrencyLimit 的取许可请求留在信号量队列里,被拒绝但仍存活的实例会截走归还的许可。

💬 关联讨论

发起讨论
加载中...