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 驱动的集合里,谁就绪了谁就被挪到「就绪表」,选节点只看就绪表。
结构:两个集合,一张取消表
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,里面每个元素是一个Pendingfuture:它拿着服务,被 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 时直接复用,不用重新分配。
车站有两块地方:保养区(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。 - 被取消:什么都不做,
Pendingfuture 被丢掉,里面的服务跟着 drop。 poll_ready报错:服务被丢掉,返回Failed(key, BoxError)。注意这里是return:一次只报一个错,剩下的要调用方再调poll_pending才处理。文档说「poll_pendingshould typically be called again」,Balance的promote_pending_to_ready就是在循环里记一条日志后接着调。
poll_pending 返回 Pending 时,FuturesUnordered 已经替每个没就绪的服务登记了 waker,任何一个就绪都会唤醒当前任务。这是 Balance::poll_ready 返回 Pending 之后还能被叫醒的原因之一。
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。
最早的 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 下换过服务实例。
第 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时它的Pendingfuture 看到取消标志,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之后可能指向另一个服务,而且复查不出来。