Rust in Everything
基于 tower 0.5.3

ReadyCache

主线位置:就绪主线的多服务版本。单个服务的 poll_ready 回答「这一个能不能接」,ReadyCache 同时问一组服务,把答「能」的放进一张表;请求主线上它什么也不包,call_ready_index 返回的就是内层服务的 future。

stateDiagram-v2
  [*] --> Pending: push
  Pending --> Ready: poll_pending 时就绪
  Pending --> Dropped: poll_ready 报错
  Pending --> Dropped: evict 后下次 poll_pending
  Ready --> Pending: call_ready 之后
  Ready --> Pending: check_ready 发现 Pending
  Ready --> Dropped: check_ready 报错
  Ready --> Dropped: evict
  Dropped --> [*]

Balance 每次选节点都要从「已经就绪的服务」里挑。如果每次都把所有服务的 poll_ready 问一遍,开销和节点数成正比;ReadyCache 的做法是把就绪状态缓存起来:没就绪的放在一个由 waker 驱动的集合里,谁就绪了谁就被挪到「就绪表」,选节点只看就绪表。

结构:两个集合,一张取消表

ReadyCache:

pub struct ReadyCache<K, S, Req>
where
    K: Eq + Hash,
{
    /// A stream of services that are not yet ready.
    pending: FuturesUnordered<Pending<K, S, Req>>,
    /// An index of cancelation handles for pending streams.
    pending_cancel_txs: IndexMap<K, CancelTx>,

    /// Services that have previously become ready. Readiness can become stale,
    /// so a given service should be polled immediately before use.
    ///
    /// The cancelation oneshot is preserved (though unused) while the service is
    /// ready so that it need not be reallocated each time a request is
    /// dispatched.
    ready: IndexMap<K, (S, CancelPair)>,
}
  • pending 是 futures 的 FuturesUnordered,里面每个元素是一个 Pending future:它拿着服务,被 poll 时去问服务的 poll_ready,就绪了就把 (key, 服务, 取消接收端) 交出来。FuturesUnordered 只 poll 被唤醒过的元素,一千个没就绪的服务不会被反复空问。
  • pending_cancel_txs 按 key 存每个待就绪服务的取消发送端。FuturesUnordered 不能按 key 查找或删除元素,要取消一个待就绪的服务,只能通过这张表找到它的发送端。
  • ready 是 IndexMap:既能按 key 查(get_ready、call_ready),又能按下标 0..len 访问(get_ready_index、call_ready_index)。后者是给 P2C 用的,它只需要「随机取两个」,不关心 key。服务就绪后,取消的发送端和接收端成对存在这里,回到 pending 时直接复用,不用重新分配。
💡TIP
打个比方:出租车站

车站有两块地方:保养区(pending)和候客车位(ready)。新车进站先去保养区(push),保养好了的车由调度员开到候客车位,车位按 0、1、2…… 编号,新来的停在最后一个(poll_pending 往 ready 末尾追加)。乘客来了,调度员按号叫车(call_ready_index(i)),车拉完客回保养区,空出来的车位不会留空:调度员把最后一个车位上的车开过来填上(swap_remove_index)。车站撤掉一辆车(evict)也一样,最后一辆补位。所以,刚才记下的「3 号车位是那辆白车」在任何一辆车离开之后都可能不对了。

比方到这里为止:保养区不是排队,哪辆车先保养好就先出来(FuturesUnordered 按唤醒顺序,不按进站顺序);撤掉一辆还在保养的车时,它不会马上离开,要等调度员下一次去保养区巡视(poll_pending)时才被拖走。

push 与 poll_pending

push 新建一对取消句柄,交给 push_pending:

    fn push_pending(&mut self, key: K, svc: S, (cancel_tx, cancel_rx): CancelPair) {
        if let Some(c) = self.pending_cancel_txs.insert(key.clone(), cancel_tx) {
            // If there is already a service for this key, cancel it.
            c.cancel();
        }
        self.pending.push(Pending {
            key: Some(key),
            cancel: Some(cancel_rx),
            ready: Some(svc),
            _pd: std::marker::PhantomData,
        });
    }

同一个 key 已经有一个待就绪的服务,旧的被取消,新的顶替。但 push 不碰 ready:同一个 key 在就绪表里的旧服务留在原地,继续可用,直到新服务就绪把它替换掉,或者旧服务被用掉一次之后被丢弃(下文 call_ready_index)。push 的文档就是这么说的。这让 Discover 发来的「同 key 再 Insert」变成平滑替换:新连接建好之前,旧连接照常接请求。

poll_pending 一直从 pending 里取结果,直到它返回 Pending(还有没就绪的)或 Ready(None)(全部处理完):

    pub fn poll_pending(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), error::Failed<K>>> {
        loop {
            match Pin::new(&mut self.pending).poll_next(cx) {
                Poll::Pending => return Poll::Pending,
                Poll::Ready(None) => return Poll::Ready(Ok(())),
                Poll::Ready(Some(Ok((key, svc, cancel_rx)))) => {
                    trace!("endpoint ready");
                    let cancel_tx = self.pending_cancel_txs.swap_remove(&key);
                    if let Some(cancel_tx) = cancel_tx {
                        // Keep track of the cancelation so that it need not be
                        // recreated after the service is used.
                        self.ready.insert(key, (svc, (cancel_tx, cancel_rx)));
                    } else {
                        assert!(
                            cancel_tx.is_some(),
                            "services that become ready must have a pending cancelation"
                        );
                    }
                }
                Poll::Ready(Some(Err(PendingError::Canceled(_)))) => {
                    debug!("endpoint canceled");
                    // The cancellation for this service was removed in order to
                    // cause this cancellation.
                }
                Poll::Ready(Some(Err(PendingError::Inner(key, e)))) => {
                    let cancel_tx = self.pending_cancel_txs.swap_remove(&key);
                    assert!(
                        cancel_tx.is_some(),
                        "services that return an error must have a pending cancelation"
                    );
                    return Err(error::Failed(key, e.into())).into();
                }
            }
        }
    }

三种结果:

  • 就绪:从取消表里拿出发送端,和服务一起 insert 进 ready。IndexMap::insert 遇到新 key 追加到末尾;遇到已有的 key(上面说的同 key 替换)就原地换掉值,下标不变,旧服务被 drop。
  • 被取消:什么都不做,Pending future 被丢掉,里面的服务跟着 drop。
  • poll_ready 报错:服务被丢掉,返回 Failed(key, BoxError)。注意这里是 return:一次只报一个错,剩下的要调用方再调 poll_pending 才处理。文档说「poll_pending should typically be called again」,Balance 的 promote_pending_to_ready 就是在循环里记一条日志后接着调。

poll_pending 返回 Pending 时,FuturesUnordered 已经替每个没就绪的服务登记了 waker,任何一个就绪都会唤醒当前任务。这是 Balance::poll_ready 返回 Pending 之后还能被叫醒的原因之一。

evict 与取消标志

evict:

    pub fn evict<Q: Hash + Equivalent<K>>(&mut self, key: &Q) -> bool {
        let canceled = if let Some(c) = self.pending_cancel_txs.swap_remove(key) {
            c.cancel();
            true
        } else {
            false
        };

        self.ready
            .swap_remove_full(key)
            .map(|_| true)
            .unwrap_or(canceled)
    }
  • 在 ready 里的服务立即被移除、drop。
  • 在 pending 里的服务没法直接删(FuturesUnordered 不能按 key 找),只能把取消标志置位。它要等下一次 poll_pending 才真正被丢掉,在那之前 pending_len() 还会算上它。

取消句柄是自己写的,cancelable 上面的注释说明了为什么不用 oneshot:

/// Creates a cancelation sender and receiver.
///
/// A `tokio::sync::oneshot` is NOT used, as a `Receiver` is not guaranteed to
/// observe results as soon as a `Sender` fires. Using an `AtomicBool` allows
/// the state to be observed as soon as the cancelation is triggered.
fn cancelable() -> CancelPair {
    let cx = Arc::new(Cancel {
        waker: AtomicWaker::new(),
        canceled: AtomicBool::new(false),
    });
    (CancelTx(cx.clone()), CancelRx(cx))
}

Pending::poll 每次先看 canceled,再问服务;服务返回 Pending 时才把 waker 登记到 AtomicWaker 上,cancel() 置位后 wake() 它,于是这个 Pending 会在下一次 poll_pending 里被 poll 到并返回 Canceled。

📝NOTE
冷知识:取消曾经用 oneshot,被取消的服务会「没看见」取消

最早的 ReadyCache 用 tokio::sync::oneshot 做取消。issue #415 报告 poll_pending 里 panic:「missing cancelation」,也就是一个服务就绪了,取消表里却找不到它的发送端,说明它其实已经被取消了。当时找不到原因,PR #420(tower 0.4.0)先把 panic 改成 debug_assert!,release 构建里把这种服务当作已取消丢掉,注释写着「Since polling a Pending first polls the CancelRx, that should always see our CancelTx send. Yet empirically, that isn't true」。

原因是 PR #668(首次发布于 tower 0.4.13)找到的:「tokio::task enforces a cooperative scheduling regime that can cause oneshot::Receiver::poll to return pending after the sender has sent an update.」任务的协作预算用完时,oneshot::Receiver 即使发送端已经发了也返回 Pending,于是「the ready cache return service instances that should have been canceled, which breaks assumptions and causes an invalid state」。这个 PR 换成了上面的 AtomicBool 加 AtomicWaker,「cancelation status is now always known to the Pending future, by checking the AtomicBool immediately on polls」,并把两处 debug_assert! 改成了硬性的 assert!,就是今天 poll_pending 里那两条。协作预算见 tokio 卷的 协作式调度预算。

check_ready 与 call_ready

就绪表里的状态可能过期:服务一分钟前就绪,现在连接可能已经断了。文档要求在派发请求之前、同一次任务调用里用 check_ready* 再确认一次。check_ready_index:

        match svc.poll_ready(cx) {
            Poll::Ready(Ok(())) => Ok(true),
            Poll::Pending => {
                // became unready; so move it back there.
                let (key, (svc, cancel)) = self
                    .ready
                    .swap_remove_index(index)
                    .expect("invalid ready index");

                // If a new version of this service has been added to the
                // unready set, don't overwrite it.
                if !self.pending_contains(&key) {
                    self.push_pending(key, svc, cancel);
                }

                Ok(false)
            }
            Poll::Ready(Err(e)) => {
                // failed, so drop it.
                let (key, _) = self
                    .ready
                    .swap_remove_index(index)
                    .expect("invalid ready index");
                Err(error::Failed(key, e.into()))
            }
        }

下标越界时返回 Ok(false),不 panic。call_ready_index 把服务从就绪表里取出来调用,然后放回 pending:

    pub fn call_ready_index(&mut self, index: usize, req: Req) -> S::Future {
        let (key, (mut svc, cancel)) = self
            .ready
            .swap_remove_index(index)
            .expect("check_ready_index was not called");

        let fut = svc.call(req);

        // If a new version of this service has been added to the
        // unready set, don't overwrite it.
        if !self.pending_contains(&key) {
            self.push_pending(key, svc, cancel);
        }

        fut
    }
  • 用过一次就回 pending。服务的 poll_ready 是一次性的(Service trait 的规则),call 之后必须重新问,所以不能留在就绪表里。
  • pending_contains(&key) 为真,说明同一个 key 已经有新版本在等就绪了:旧服务这次用完就被 drop,不放回去,免得它的取消句柄顶掉新版本的。
  • 返回值是内层的 S::Future,ReadyCache 不包任何东西。Balance 在外面只加了一层 map_err。
  • 下标无效会 panic,信息是 check_ready_index was not called。按 key 的 call_ready 先查下标再调它。

服务的 call 返回错误,ReadyCache 看不到:future 已经交出去了,服务照样回到 pending。只有 poll_ready 报错才会让服务离开缓存。

下标会被打乱

就绪表的下标在下面这些操作之后可能指向别的服务:

操作对 ready 下标的影响
push不变
poll_pending新 key 追加到末尾;已有 key 原地换值;其他下标不动
evict(k),k 在 ready 里swap_remove:最后一个挪到 k 原来的位置
call_ready_index(i)swap_remove:最后一个挪到 i
check_ready_index(i) 返回 false 或错误同上

swap_remove 是 IndexMap 删除元素的 O(1) 做法:不把后面的元素整体前移,而是把最后一个搬进空位。代价就是下标不稳定。ReadyCache 的文档把这一点写明了:「calls to ReadyCache::poll_pending and ReadyCache::evict may perturb the order of the ready set, so any cached indexes should be discarded after such a call」。按上表,0.5.3 的 poll_pending 其实只追加、不挪动,真正挪动下标的是 evict 和用掉或检查失败时的 swap_remove。后两者本来就会让手上那个下标作废(服务都已经不在那里了),容易被忽略的是 evict:它删的是另一个 key,你缓存的下标却可能被换成了最后一个服务。

下面的程序用一个手动控制就绪的服务把上表走一遍。服务被 drop 时打印一行。

tower = { version = "=0.5.3", features = ["ready-cache"] }
use std::{
  cell::Cell,
  convert::Infallible,
  future::{ready, Ready},
  rc::Rc,
  task::{Context, Poll, Waker},
};
use tower::{ready_cache::ReadyCache, Service};

struct Node {
  name: &'static str,
  ready: Rc<Cell<bool>>,
}

fn node(name: &'static str, is_ready: bool) -> (Node, Rc<Cell<bool>>) {
  let flag = Rc::new(Cell::new(is_ready));
  (Node { name, ready: flag.clone() }, flag)
}

impl Service<()> for Node {
  type Response = &'static str;
  type Error = Infallible;
  type Future = Ready<Result<&'static str, Infallible>>;
  fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll<Result<(), Infallible>> {
    if self.ready.get() { Poll::Ready(Ok(())) } else { Poll::Pending }
  }
  fn call(&mut self, _: ()) -> Self::Future {
    ready(Ok(self.name))
  }
}

impl Drop for Node {
  fn drop(&mut self) {
    println!("    (drop {})", self.name);
  }
}

type Cache = ReadyCache<&'static str, Node, ()>;

fn show(step: &str, c: &Cache) {
  let ready: Vec<String> = c.iter_ready().enumerate().map(|(i, (k, s))| format!("{i}:{k}={}", s.name)).collect();
  println!("{step:<28} ready [{}]  pending {}", ready.join(" "), c.pending_len());
}

fn poll_pending(c: &mut Cache) {
  let mut cx = Context::from_waker(Waker::noop());
  let _ = c.poll_pending(&mut cx);
}

fn main() {
  let mut cx = Context::from_waker(Waker::noop());
  let mut c = Cache::default();
  for k in ["a", "b", "c", "d"] {
    c.push(k, node(k, true).0);
  }
  show("1 push a b c d", &c);
  poll_pending(&mut c);
  show("2 poll_pending", &c);

  let _ = c.call_ready_index(0, ());
  show("3 call_ready_index(0)", &c);
  poll_pending(&mut c);
  show("4 poll_pending", &c);

  let i = c.get_ready(&"b").unwrap().0;
  println!("  cached index of b = {i}");
  c.evict(&"b");
  show("5 evict(b)", &c);
  let ok = c.check_ready_index(&mut cx, i);
  let who = c.get_ready_index(i).map(|(k, _)| *k);
  println!("  check_ready_index({i}) = {ok:?}, now holds {who:?}");

  let (e, _) = node("e", false);
  c.push("e", e);
  show("6 push e (not ready)", &c);
  println!("  evict(e) = {}", c.evict(&"e"));
  show("7 after evict(e)", &c);
  poll_pending(&mut c);
  show("8 poll_pending", &c);

  let (d2, flag_d2) = node("d2", true);
  c.push("d", d2);
  show("9 push d again (d2)", &c);
  poll_pending(&mut c);
  show("10 poll_pending", &c);

  flag_d2.set(false);
  let ok = c.check_ready_index(&mut cx, 0);
  println!("  d2 turns unready; check_ready_index(0) = {ok:?}");
  show("11", &c);
}

show 打印 下标:key=服务名,key 和服务名不同的时候,说明这个 key 下换过服务实例。

❗IMPORTANT
动脑筋:第 5 步之后,下标 1 上是谁?

第 4 步结束时就绪表是 [0:d 1:b 2:c 3:a],程序记下 b 的下标 1,然后 evict(b),再拿这个旧下标去 check_ready_index(1)。它返回 Ok(true) 还是 Ok(false)?下标 1 上现在是哪个服务?完整输出在文末。

Balance 正是这样缓存下标的:poll_ready 选出一个节点,把下标存进 ready_index,下一次 poll_ready 先处理 Discover 的变化,再用 check_ready_index 复查这个下标。poll_ready 里有一句注释「These updates cannot disturb the order of existing ready services」,对 push 和 poll_pending 成立,对 Remove 触发的 evict 不成立。这在 0.5.3 里是一个真实的 bug(issue #856,tag 之后才修),细节见 P2C 负载均衡。

动脑筋答案

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

1 push a b c d               ready []  pending 4
2 poll_pending               ready [0:a=a 1:b=b 2:c=c 3:d=d]  pending 0
3 call_ready_index(0)        ready [0:d=d 1:b=b 2:c=c]  pending 1
4 poll_pending               ready [0:d=d 1:b=b 2:c=c 3:a=a]  pending 0
  cached index of b = 1
    (drop b)
5 evict(b)                   ready [0:d=d 1:a=a 2:c=c]  pending 0
  check_ready_index(1) = Ok(true), now holds Some("a")
6 push e (not ready)         ready [0:d=d 1:a=a 2:c=c]  pending 1
  evict(e) = true
7 after evict(e)             ready [0:d=d 1:a=a 2:c=c]  pending 1
    (drop e)
8 poll_pending               ready [0:d=d 1:a=a 2:c=c]  pending 0
9 push d again (d2)          ready [0:d=d 1:a=a 2:c=c]  pending 1
    (drop d)
10 poll_pending              ready [0:d=d2 1:a=a 2:c=c]  pending 0
  d2 turns unready; check_ready_index(0) = Ok(false)
11                           ready [0:c=c 1:a=a]  pending 1
    (drop d2)
    (drop c)
    (drop a)
  • 2:四个服务都立刻就绪,这次运行里按 push 的顺序进入就绪表。FuturesUnordered 不保证这个顺序,不要依赖它。
  • 3:调用下标 0 的 a。最后一个 d 被搬到 0,a 回到 pending。
  • 4:a 再次就绪,追加在末尾。别的下标没动:poll_pending 只追加。
  • 5:Ok(true),下标 1 上是 a。 evict(b) 立即 drop 了 b,最后一个 a 搬进 b 的位置。旧下标 1 仍然有效,check_ready_index 问的是 a 的 poll_ready,a 是就绪的,于是返回 Ok(true)。手上拿着「1 号是 b」的人看不出任何异常。
  • 6~8:e 没就绪,留在 pending。evict(e) 返回 true,但 e 没有马上被 drop,pending 还是 1;下一次 poll_pending 时它的 Pending future 看到取消标志,e 才被 drop。
  • 9、10:同一个 key d 再 push 一个 d2。d 在就绪表里原地不动,直到 d2 就绪,insert 在下标 0 上原地换值,旧的 d 被 drop。
  • 11:d2 变成不就绪,check_ready_index(0) 返回 Ok(false),d2 被 swap_remove 移回 pending,最后一个 c 搬到下标 0。
  • 程序结束时三个服务随 ReadyCache 一起 drop。

小结

  • ReadyCache = pending(FuturesUnordered,waker 驱动)+ pending_cancel_txs(按 key 找取消发送端)+ ready(IndexMap,能按 key 也能按下标)。
  • push 进 pending,同 key 的旧待就绪服务被取消,就绪表里的旧服务留到新服务就绪;poll_pending 把就绪的追加或原地替换进 ready,报错的服务被丢掉,一次只返回一个错误。
  • call_ready* 取出服务调用,服务回到 pending 重新等就绪;返回的就是内层 future。check_ready* 在派发前复查,不就绪的挪回 pending,报错的丢掉。
  • evict 立即移除就绪的服务;待就绪的只置取消标志,下一次 poll_pending 才 drop。取消用 AtomicBool + AtomicWaker,不用 oneshot(#668)。
  • 所有移除都用 swap_remove,最后一个服务会搬进空位;缓存的下标在 evict 之后可能指向另一个服务,而且复查不出来。

💬 关联讨论

发起讨论
加载中...