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 了。
问: 我想给每个后端加超时,写成 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 的引用
#[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 > est | rtt,直接取峰值,不看时间 |
rtt ≤ est | est · e^(−Δt/τ) + rtt · (1 − e^(−Δt/τ)) |
调 load() | 上一行取 rtt = 0:est · e^(−Δt/τ) |
代价 Cost | est ×(在途数 + 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 转成纳秒。
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 行是同一时刻,读不读不影响结果。
第 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起步。- 被取消的请求按取消时刻计入延迟,快速失败的节点会显得便宜。