Rust in Everything
基于 tower 0.5.3

Load 与三种负载度量

主线位置:两条主线都在。负载是在请求主线上量出来的:call 时往响应 future 里放一个 Handle,Handle 被 drop 时更新计数或延迟估计;又是在就绪主线上读的:P2C 在 poll_ready 里调 load() 比较两个候选。

flowchart TB
  callreq["call(req)"] --> h["新建 Handle"]
  h --> tcf[TrackCompletionFuture]
  tcf -->|内层返回 Ok| tc["track_completion"]
  tcf -->|内层返回 Err| keep[Handle 留在 future 里]
  tc -->|CompleteOnResponse| drop1[立即 drop Handle]
  tc -->|自定义| body[Handle 跟着响应走]
  keep --> drop2[future 被 drop]
  body --> drop3[响应被 drop]
  drop1 --> upd[计数减一或更新延迟]
  drop2 --> upd
  drop3 --> upd
  upd -.-> rd["P2C 调 load() 比较"]

P2C 要从两个候选里挑「比较闲」的那个,它不关心闲是怎么定义的,只要求能比较。Load 就是这个接口;load 模块另外提供了三种实现,以及一个把「请求什么时候算结束」抽出来的 TrackCompletion。

Load:一个能比较的数

Load:

pub trait Load {
    /// A comparable load metric.
    ///
    /// Lesser values indicate that the service is less loaded, and should be preferred for new
    /// requests over another service with a higher value.
    type Metric: PartialOrd;

    /// Estimate the service's current load.
    fn load(&self) -> Self::Metric;
}
  • Metric 只要 PartialOrd,所以 f64 也行。越小越闲。
  • load 取 &self。后面会看到 PeakEwma 在 load 里其实改了状态,靠的是内部的 Mutex。

Load 是一个独立于 Service 的 trait,中间件不会自动转发它。0.5.3 里转发 Load 的只有 ConcurrencyLimit 和 RateLimit(都在 load feature 下),其他中间件包上去之后,外面那层就不是 Load 了。

📝NOTE
没有蠢问题

问: 我想给每个后端加超时,写成 Timeout<PeakEwma<Conn>> 放进 Balance,为什么编译不过?

答: Timeout 没有实现 Load,Balance 要求 D::Service: Load。度量负载的那一层必须在每个后端服务栈的最外面(或者外面只有 ConcurrencyLimit、RateLimit)。反过来写 PeakEwma<Timeout<Conn>> 可以编译,含义也合理:超时的请求按超时那一刻计入延迟(文末实验输出的第 7 行就是这种情况)。超时也可以加在整个 Balance 外面,那就和负载度量无关了。

Constant:固定值

Constant 包一个服务,load() 永远返回构造时给的值,poll_ready 和 call 原样转发。文档说它「primarily useful for testing」。它还为 D: Discover + Unpin 实现了 Stream:把发现流里每个 Insert 的服务包成 Constant,Remove 原样传过去。另外两种度量也各有一个这样的包装(PendingRequestsDiscover、PeakEwmaDiscover),都是 Stream,所以按 Discover 一页的 blanket impl,包完还是 Discover。

TrackCompletion:请求什么时候算结束

两种真正的度量都要知道「一个请求什么时候结束」。最简单的答案是「响应 future 完成时」,但对流式响应(HTTP body 还在传)来说,这时请求其实还占着后端。TrackCompletion 把这个决定交给使用者:

pub trait TrackCompletion<H, V>: Clone {
    /// The instrumented value type.
    type Output;

    /// Attaches a `H`-typed handle to a `V`-typed value.
    fn track_completion(&self, handle: H, value: V) -> Self::Output;
}

H 是度量方给的 RAII 句柄(drop 时更新负载),V 是内层的响应。实现方可以把句柄塞进响应里(比如放进 HTTP 响应的 extensions,或者换一个 body 类型),让它活到真正结束。默认的 CompleteOnResponse 直接 drop:

impl<H, V> TrackCompletion<H, V> for CompleteOnResponse {
    type Output = V;

    fn track_completion(&self, handle: H, value: V) -> V {
        drop(handle);
        value
    }
}

CompleteOnResponse 标了 #[non_exhaustive],crate 外面不能直接写 CompleteOnResponse 当值,要用 CompleteOnResponse::default()。

句柄怎么进到响应里,看 TrackCompletionFuture::poll:

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.project();
        let rsp = ready!(this.future.poll(cx))?;
        let h = this.handle.take().expect("handle");
        Poll::Ready(Ok(this.completion.track_completion(h, rsp)))
    }

注意 ? 在 take() 前面:内层返回 Err 时直接返回,句柄留在 future 的 handle: Option<H> 字段里,要等这个 future 被 drop 才释放。用 .await 的话 future 马上就被 drop 了,看不出区别;但如果 future 被放在别的结构里手动 poll,失败的请求会一直算在负载里。下一节的实验第 5、6 行就是这种情况。

PendingRequests:数 Arc 的引用

PendingRequests:

#[derive(Debug)]
pub struct PendingRequests<S, C = CompleteOnResponse> {
    service: S,
    ref_count: RefCount,
    completion: C,
}

/// Shared between instances of [`PendingRequests`] and [`Handle`] to track active references.
#[derive(Clone, Debug, Default)]
struct RefCount(Arc<()>);

没有计数器,计数的是一个 Arc<()> 的强引用数。call 每次克隆一份放进 Handle,Handle 跟着 TrackCompletionFuture 走;load 减掉服务自己持有的那一份:

    fn load(&self) -> Count {
        // Count the number of references that aren't `self`.
        Count(self.ref_count.ref_count() - 1)
    }

ref_count() 就是 Arc::strong_count。这样「请求结束」不需要任何显式的回调:Handle 不管是怎么没的(响应完成、future 被取消、响应被 drop),Arc 的计数都会少一。PendingRequests 没有实现 Clone,如果有,副本会持有同一个 Arc,strong_count - 1 就不再等于在途请求数。

下面的程序覆盖了正常完成、取消、失败、自定义 TrackCompletion 四种情况。service_fn 永远就绪,这里省掉了 poll_ready。

tower = { version = "=0.5.3", features = ["load", "util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt"] }
use std::{future::{poll_fn, Future}, task::Poll};
use tower::{
  load::{
    completion::TrackCompletion,
    pending_requests::{Handle, PendingRequests},
    CompleteOnResponse, Load,
  },
  service_fn, Service,
};

// keeps the handle inside the "response", like a streaming body would
#[derive(Clone)]
struct KeepUntilBodyDropped;

impl<V> TrackCompletion<Handle, V> for KeepUntilBodyDropped {
  type Output = (V, Handle);
  fn track_completion(&self, handle: Handle, value: V) -> (V, Handle) {
    (value, handle)
  }
}

#[tokio::main(flavor = "current_thread")]
async fn main() {
  let svc = service_fn(|x: i32| async move {
    if x < 0 { Err("negative") } else { Ok(x) }
  });

  let mut p = PendingRequests::new(svc, CompleteOnResponse::default());
  println!("1 new:                    {:?}", p.load());
  let f1 = p.call(1);
  let f2 = p.call(2);
  println!("2 two futures alive:      {:?}", p.load());
  let _ = f1.await;
  println!("3 f1 awaited:             {:?}", p.load());
  drop(f2);
  println!("4 f2 dropped unpolled:    {:?}", p.load());

  let mut f3 = Box::pin(p.call(-1));
  let r = poll_fn(|cx| Poll::Ready(f3.as_mut().poll(cx))).await;
  println!("5 f3 polled -> {r:?}");
  println!("  f3 still alive:         {:?}", p.load());
  drop(f3);
  println!("6 f3 dropped:             {:?}", p.load());

  let mut q = PendingRequests::new(svc, KeepUntilBodyDropped);
  let (v, body) = q.call(4).await.unwrap();
  println!("7 response {v} received:  {:?}", q.load());
  drop(body);
  println!("8 body dropped:           {:?}", q.load());
}

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

1 new:                    Count(0)
2 two futures alive:      Count(2)
3 f1 awaited:             Count(1)
4 f2 dropped unpolled:    Count(0)
5 f3 polled -> Ready(Err("negative"))
  f3 still alive:         Count(1)
6 f3 dropped:             Count(0)
7 response 4 received:  Count(1)
8 body dropped:           Count(0)
  • 2:call 一返回就算在途,不用等 future 被 poll。
  • 4:从没 poll 过的 future 被 drop,同样减一。取消的请求不会泄漏计数。
  • 5、6:失败的 future 已经返回了 Ready(Err),但还活着,计数还是 1;drop 之后才归零。
  • 7、8:自定义的 TrackCompletion 把 Handle 放进了「响应」,拿到响应后负载仍是 1,直到「body」被 drop。

PeakEwma:峰值优先、慢慢衰减的延迟估计

在途请求数不区分快慢:一个 1 ms 的后端和一个 1 s 的后端,在途数相同时负载一样。PeakEwma 用「估计延迟 × 在途数」来估计积压的工作量。文档说算法来自 Finagle 的 PeakEwma。

#[derive(Debug)]
pub struct PeakEwma<S, C = CompleteOnResponse> {
    service: S,
    decay_ns: f64,
    rtt_estimate: Arc<Mutex<RttEstimate>>,
    completion: C,
}

RttEstimate 只有两个字段:rtt_ns(当前估计,纳秒,f64)和 update_at(上次更新的时刻)。Arc 有两个用途:在途的 Handle 各持一份,drop 时去更新估计;同时它的强引用数就是在途请求数,和 PendingRequests 一个思路。

load():估计 ×(在途 + 1)

load:

    fn load(&self) -> Self::Metric {
        let pending = Arc::strong_count(&self.rtt_estimate) as u32 - 1;

        // Update the RTT estimate to account for decay since the last update.
        // If an estimate has not been established, a default is provided
        let estimate = self.update_estimate();

        let cost = Cost(estimate * f64::from(pending + 1));
        // ...
        cost
    }

+ 1 让空闲的后端也有一个非零的代价:没有在途请求时,负载就是估计延迟本身,两个空闲后端之间按延迟比。返回的 Cost 是包着 f64 的不透明类型,单位是纳秒。

update_estimate 要锁 Mutex,再调 RttEstimate::decay:

    fn decay(&mut self, decay_ns: f64) -> f64 {
        // Updates with a 0 duration so that the estimate decays towards 0.
        let now = Instant::now();
        self.update(now, now, decay_ns)
    }

也就是说,读负载本身就是一次「观测到 0 延迟」的更新:估计值按距离上次更新的时间往 0 衰减,update_at 改成现在。load() 不是只读的。

update:变大立刻采用,变小按时间加权

响应结束时,Handle 的 Drop 用 sent_at 到现在的时间调 update。sent_at 是在 handle() 里取的,也就是 call 的时刻,不是 poll_ready 的时刻。

        let rtt = nanos(recv_at.saturating_duration_since(sent_at));

        let now = Instant::now();
        // ...
        self.rtt_ns = if self.rtt_ns < rtt {
            // For Peak-EWMA, always use the worst-case (peak) value as the estimate for
            // subsequent requests.
            // ...
            rtt
        } else {
            // When an RTT is observed that is less than the estimated RTT, we decay the
            // prior estimate according to how much time has elapsed since the last
            // update. The inverse of the decay is used to scale the estimate towards the
            // observed RTT value.
            let elapsed = nanos(now.saturating_duration_since(self.update_at));
            let decay = (-elapsed / decay_ns).exp();
            let recency = 1.0 - decay;
            let next_estimate = (self.rtt_ns * decay) + (rtt * recency);
            // ...
            next_estimate
        };
        self.update_at = now;

        self.rtt_ns

记 τ = decay_ns,Δt = 现在减 update_at,est 是当前估计,rtt 是这次观测:

情况新的 est
新建default_rtt
rtt > estrtt,直接取峰值,不看时间
rtt ≤ estest · e^(−Δt/τ) + rtt · (1 − e^(−Δt/τ))
调 load()上一行取 rtt = 0:est · e^(−Δt/τ)
代价 Costest ×(在途数 + 1)

几个由此推出、实验里能看到的结论:

  • 新节点有一个「罚分」:估计从 default_rtt 开始,不是 0。文档说这是为了在没有基线数据之前不把新节点压垮。但只要有人读它的 load(),这个罚分就会随时间往 0 衰减,空闲久了的新节点会变得很有吸引力。源码里的测试 default_decay 测的就是这个。
  • 衰减只按时间,不按次数:同样的 Δt,不管中间读了多少次 load(),纯衰减的结果相同(e^(−a)·e^(−b) = e^(−(a+b)))。但观测到较小的 rtt 时,它的权重 1 − e^(−Δt/τ) 取决于 Δt,而 Δt 从上一次更新算起,load() 也算一次更新。所以「读了多少次 load()」会改变一个较小的观测值能把估计拉下来多少。这就是下面的动脑筋。
  • 请求被取消也算一次观测:Handle 在 future 被 drop 时同样会更新,延迟就是从 call 到 drop 的时间。
  • 快速失败的节点显得便宜:TrackCompletionFuture 不区分成功和失败,1 ms 就返回错误的节点会把自己的估计拉低。Balance 只在 poll_ready 报错时移除节点,call 返回的错误不会让节点离开(见 ReadyCache)。

两个构造入口的单位不一样:PeakEwma::new 的衰减参数是 decay_ns: f64,PeakEwmaDiscover::new 的是 decay: Duration,内部用 nanos 转成纳秒。

📝NOTE
冷知识:saturating_duration_since 是为了躲一个标准库的 panic

上面 update 里两处时间相减,0.4.11 及以前写的是 recv_at - sent_at 和 now - self.update_at,也就是 Instant::sub,当时这种写法在结果为负时会 panic。PR #633(首次发布于 tower 0.4.12)的说明是:「We have reports of runtime panics (linkerd/linkerd2#7748) that sound a lot like rust-lang/rust#86470. We don't have any evidence that these panics originate in tower, but we have some potentialy flawed Instant arithmetic that could panic in this way.」PR 认为这「almost definitely a bug in Rust」,但还是先避开:把 Instant::elapsed 和 Instant::sub 都换成了 saturating_duration_since,后一个时刻比前一个早时结果是 0,而不是 panic。同一个 PR 也改了 hedge 和 retry 里的同类写法,并给 PeakEwma::new 加了 debug_assert!(decay_ns > 0.0, ...)。

实验

PeakEwma 用的是 tokio::time::Instant,所以可以用 tokio 的暂停时钟精确控制时间:start_paused = true 之后,advance 和 sleep 只推进虚拟时钟,结果每次都一样。默认延迟 100 ms,τ = 1 s。

tower = { version = "=0.5.3", features = ["load", "util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt", "time", "test-util"] }
use std::time::Duration;
use tokio::time::{advance, sleep};
use tower::{
  load::{CompleteOnResponse, Load, PeakEwma},
  service_fn, Service,
};

// Cost is opaque: read the f64 out of its Debug output, in ms
fn ms<L: Load>(svc: &L) -> String
where
  L::Metric: std::fmt::Debug,
{
  let s = format!("{:?}", svc.load());
  let ns: f64 = s.trim_start_matches("Cost(").trim_end_matches(')').parse().unwrap();
  format!("{:8.3} ms", ns / 1e6)
}

#[tokio::main(flavor = "current_thread", start_paused = true)]
async fn main() {
  // the request is "how long to take"; a negative value fails after that long
  let svc = service_fn(|ms: i64| async move {
    sleep(Duration::from_millis(ms.unsigned_abs())).await;
    if ms < 0 { Err("failed") } else { Ok(()) }
  });
  let new = || PeakEwma::new(svc, Duration::from_millis(100), 1e9, CompleteOnResponse::default());

  let mut a = new();
  println!("1 fresh, default_rtt 100ms:     {}", ms(&a));
  advance(Duration::from_millis(500)).await;
  println!("2 idle 500ms:                   {}", ms(&a));
  let f = a.call(300);
  println!("3 one request in flight:        {}", ms(&a));
  f.await.unwrap();
  println!("4 it took 300ms:                {}", ms(&a));
  advance(Duration::from_secs(1)).await;
  a.call(10).await.unwrap();
  println!("5 idle 1s, then a 10ms reply:   {}", ms(&a));

  // same history, but load() is read every 100ms while idle
  let mut b = new();
  advance(Duration::from_millis(500)).await;
  let _ = b.load();
  b.call(300).await.unwrap();
  let _ = b.load();
  for _ in 0..10 {
    advance(Duration::from_millis(100)).await;
    let _ = b.load();
  }
  b.call(10).await.unwrap();
  println!("6 same, load() every 100ms:     {}", ms(&b));

  // a request dropped after 2s counts as a 2s round trip
  let f = a.call(5_000);
  advance(Duration::from_secs(2)).await;
  drop(f);
  println!("7 a call dropped after 2s:      {}", ms(&a));

  // ten rounds of 150ms: c answers in 50ms, d fails after 1ms
  let (mut c, mut d) = (new(), new());
  for _ in 0..10 {
    let _ = c.call(50).await;
    advance(Duration::from_millis(50)).await;
    let _ = d.call(-1).await;
    advance(Duration::from_millis(49)).await;
  }
  println!("8 c, ten 50ms replies:          {}", ms(&c));
  println!("  d, ten 1ms errors:            {}", ms(&d));
}

b 和 a 经历的请求完全一样:建好后空闲 500 ms,一个 300 ms 的请求,空闲 1 s,一个 10 ms 的请求。区别只在 b 在空闲的那 1 s 里每 100 ms 读一次 load()。a 在第 2、3、4 行各读过一次,b 在第 2、4 行对应的位置也读了;第 3 行和第 2 行是同一时刻,读不读不影响结果。

❗IMPORTANT
动脑筋:第 6 行和第 5 行一样吗?

第 5 行是 a 在 10 ms 的响应回来之后的负载,第 6 行是 b 的。两者都没有在途请求。第 6 行比第 5 行大、小,还是相等?完整输出在文末。

动脑筋答案

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

1 fresh, default_rtt 100ms:      100.000 ms
2 idle 500ms:                     60.653 ms
3 one request in flight:         121.306 ms
4 it took 300ms:                 300.000 ms
5 idle 1s, then a 10ms reply:    115.624 ms
6 same, load() every 100ms:      109.365 ms
7 a call dropped after 2s:      2000.000 ms
8 c, ten 50ms replies:            56.398 ms
  d, ten 1ms errors:              23.042 ms
  • 1:新节点,估计就是 default_rtt,在途 0,代价 = 100 × 1。
  • 2:空闲 500 ms 后读 load(),100 × e^(−0.5) = 60.653。
  • 3:call 之后 Handle 已经存在,在途 1,代价 = 60.653 × 2。延迟估计本身没变,变的是乘数。
  • 4:300 ms 的响应大于估计 60.653,直接取峰值 300,不做任何平均。
  • 5:115.624。 第 4 行读 load() 时 update_at 被设为那一刻。之后空闲 1 s,再发一个 10 ms 的请求,响应回来时 Δt = 1.01 s:300 × e^(−1.01) + 10 × (1 − e^(−1.01)) = 109.27 + 6.36 = 115.62。
  • 6:109.365,比第 5 行小。 b 在空闲期间每 100 ms 读一次 load(),估计已经衰减到 300 × e^(−1) = 110.36,update_at 也一直在往后挪。10 ms 的响应回来时 Δt 只有 10 ms,新观测的权重只有 1 − e^(−0.01) ≈ 0.01:110.36 × 0.990 + 10 × 0.010 = 109.36。两边的纯衰减部分其实一样(e^(−1.01) 拆成十几段乘起来还是 e^(−1.01)),差别在于 a 把 10 ms 这个观测按 0.64 的权重混了进来,b 只按 0.01。也就是说,读 load() 越频繁,低于估计的观测值就越难把估计拉下来,估计更多地由「峰值然后衰减到 0」决定。
  • 7:一个 5 s 的请求在 2 s 时被 drop(比如外层超时了),Handle 的 Drop 照样按 2 s 记一次观测,大于当前估计,成了新的峰值。
  • 8:c 每次 50 ms 成功返回,估计从 100 慢慢降向 50;d 每次 1 ms 就返回错误,估计降向 1。十轮之后 d 的代价只有 c 的四成。P2C 拿这两个比较时会偏向 d,也就是那个一直在报错的节点。

小结

  • Load::load(&self) -> Metric,Metric: PartialOrd,越小越闲。中间件不转发它,只有 ConcurrencyLimit 和 RateLimit 例外,所以度量层要放在每个后端栈的最外面。
  • Constant 给固定值,用于测试;三种度量都带一个把 Discover 里的服务包起来的 Stream 包装。
  • 计数和延迟都靠 Handle 被 drop 来更新:CompleteOnResponse 在响应到达时 drop,自定义的 TrackCompletion 可以让它跟着响应走;失败的响应 future 要被 drop 才释放句柄。
  • PendingRequests 的负载是 Arc::strong_count - 1,取消、失败、完成都会正确减一。
  • PeakEwma 的代价是 估计延迟 ×(在途 + 1):大的观测直接取峰值,小的观测按 1 − e^(−Δt/τ) 加权;load() 本身是一次「观测到 0」的更新,会让估计往 0 衰减;新节点从 default_rtt 起步。
  • 被取消的请求按取消时刻计入延迟,快速失败的节点会显得便宜。

💬 关联讨论

发起讨论
加载中...