P05 · RpcRouter:共享通道上的 RPC 关联
sleep / wake_up / collective_rpc 的结果和其他控制消息挤在同一条回传队列上,多个线程同时在等各自的结果——要按 key 把结果关联回正确的调用方。
mine/p05_rpc_router.py
验收 ./check.sh p05
提示 A-13 · 参考解 C-13 solutions/p05_rpc_router.py
题面速览
- 在系统中的位置:前端的 collective_rpc、abort、sleep 是同步 API:调用线程把请求放进 request 队列然后阻塞等结果。所有结果都从同一个
rpc_output_queue回来,由一个 router 线程按 key 分发给等待者。 - 三个经典坑:
- 先发送后注册:结果可能在注册之前就到了 → 被当成“没人要”丢掉 → 调用者永远等。必须先注册再发送。
- 超时后不注销:迟到结果塞进没人读的队列;同一个 key 再次使用会读到旧结果。必须在 finally 里注销,且只注销自己的 waiter(用
is比较)。 - 致命错误时没有广播:orchestrator 线程挂了,所有等待者要立即收到错误,而不是各自等到超时;之后的新调用也应立即失败(锁存)。
- 接口:
register / unregister / deliver / fail_all / call(key, send, timeout),所有方法都可能被不同线程并发调用。每个 waiter 是queue.Queue(maxsize=1)。
验收要点:50 个线程并发调用、结果乱序回传,各自拿到自己的 · send() 内同步回传的结果不丢 · 超时 → TimeoutError、迟到结果被丢弃并计数、key 可复用 · 重复 key 报错 · fail_all 立刻唤醒所有等待者并锁存错误。
直觉与类比
点单时店员先给你一个取餐器(register(key) 返回的 Queue(maxsize=1)),然后才把单子递进后厨(send())。后厨做好哪杯就呼叫哪个号(deliver),出餐顺序与点单无关。① 先领取餐器再下单:后厨手快,单子刚递进去饮料就好了,你手上还没取餐器,这杯只好放到“无人认领”(late_results += 1),你干等。② 等不及走了(超时),一定要把取餐器还回去(finally: unregister)。③ 还的是你手上那一个(is 比较),而不是“把 17 号作废”——17 号可能已经发给下一位客人。
店员不是让每个客人各自等到不耐烦,而是立刻广播“所有取餐器作废”(把错误放进每个在等的信箱),并在门口挂“暂停营业”(锁存):之后来的客人连单都不用下,进门就被告知(register / call 立即抛错,send 根本不会被调用)。
框架视角 · T · SBWC · LIFE
| P05 | |
|---|---|
| T | 纯线程:N 个调用方线程 + 1 个结果通道线程 + 偶尔调用 fail_all 的线程。没有事件循环,同步原语是 threading.Lock 和 queue.Queue |
| S | waiters: dict[key → Queue(1)]、锁存的 fatal、late_results——多线程读改写 → 同一把锁;“检查 fatal + 插入 waiter”必须在同一次加锁里 |
| B | 调用方阻塞在 waiter.get(timeout)——绝不能持锁;deliver / fail_all 的 put 也放在锁外 |
| W | 调用方:登记、发送、等待、注销自己的;结果线程:只按 key 取出 waiter 并交付,不看结果内容;fail_all:清空并广播 |
| C | 出:send();回:一条共享结果通道;每个调用一个私有 Queue(maxsize=1) 当信箱 |
| L | waiter 的一生:register → 被 deliver 取走 / 被 fail_all 清空 / 被 finally 的 unregister 删除,有且只有一条路真正摘掉它。router 一生:正常 → fail_all 后永久“锁死” |
| I | call 只有四种结局:返回结果 / raise 结果里的异常 / TimeoutError / raise 锁存的致命错误;无论哪种,返回时表里都不再有它的 waiter |
| F | ① 同一 key 最多一个活着的 waiter;② 每个 waiter 最多收到一个结果;③ fatal 一旦设置,表里不会再出现新 waiter;④ 登记一定先于发送 |
| E | 迟到 / 未知 key → 计数丢弃;重复 key → ValueError;send 自己抛错 → finally 照样注销;结果在“已 pop、调用方刚好超时”的缝里到达 → 悄悄丢弃(可接受,不算 late) |
易错点
- 先 send 后 register:send 内同步 deliver 的结果被计为 late,调用方等满超时。
- 超时后不注销:同一 key 第二次 call 报“重复 key”;或(若允许覆盖)读到上一轮迟到的旧结果。
- unregister 不比较身份:A 超时的 finally 把 B 刚注册的 waiter 删掉,B 的结果被当成 late,B 卡到超时。
- 持锁 put / 持锁 get:死锁或所有调用串行化。
- fail_all 只唤醒不锁存:之后的新调用照常 send 并等满超时。
- deliver 用 get 而不是 pop:同一个 key 可能被交付两次。
逐步图:为什么 unregister 要比较身份
A:register(k) → wA。表:k → wA
A 的 get 超时,准备进 finally。router:deliver(k, resA) 在锁里 pop 出 wA,锁外 put。表:(空)
wA 收到了 resA 但 A 已放弃——这条结果悄悄丢弃,它不算 late(确实有 waiter 接住了)。
B:register(k) → wB。表:k → wB
unregister(k, wA):表里是 wB,waiters.get(k) is wA 为 False → 不删。表:k → wB
pop 出 wB → True,B 拿到 resB。表:(空)
如果 t4 不比较直接 del waiters[k]:t5 找不到 waiter,resB 被当成 late,B 白等到超时。
三级提示
L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)
例 1:两个线程并发调用,结果乱序回来
r = RpcRouter()
def send_for(key, val, delay): # 模拟 router 线程稍后回传结果
return lambda: threading.Timer(delay, lambda: r.deliver(key, val)).start()
线程 1: r.call("sleep", send_for("sleep", "ok-sleep", 0.05), timeout=1) -> "ok-sleep"
线程 2: r.call("wake", send_for("wake", "ok-wake", 0.01), timeout=1) -> "ok-wake"
# 结果在 send() 内部就同步回来了,也不能丢:
r.call(("rpc", 7), lambda: r.deliver(("rpc", 7), {"gpu_mem": 3}), timeout=0.1) -> {"gpu_mem": 3}
例 2:超时、迟到、key 复用、异常结果
r.call("ping", lambda: None, timeout=0.1) # 约 0.1s 后抛 TimeoutError
r.deliver("ping", "pong") # -> False(已经没人在等)
r.late_results # -> 1
r.call("ping", lambda: r.deliver("ping", "pong2"), timeout=0.1) # -> "pong2"
r.call("bad", lambda: r.deliver("bad", ValueError("no such method")), timeout=0.1)
# -> 抛出 ValueError:结果本身是异常对象时 raise 而不是 return
w = r.register("dup"); r.register("dup") # 第二次 -> ValueError
例 3:fail_all 广播并锁存:3 个线程 call("w0"/"w1"/"w2", send=lambda: None, timeout=5) 阻塞;主线程 r.fail_all(RuntimeError("orch dead")) → 3 个线程几乎立刻各自抛出;之后 call("after", …) 立即抛、send 不会被调用;register("x") 同样立即抛;late_results 不变。
关键问题
- 为什么必须先 register 再 send()?构造一个“先 send 后 register”会永远等下去的时序。
- waiter 为什么是
Queue(maxsize=1)?deliver 放结果时会不会阻塞? - 一个 waiter 会被谁从表里移除:deliver、call 的 finally,还是两者?怎么保证不互相踩?
unregister(key, waiter)为什么要多传一个 waiter,并且只在“表里那个就是我这个”时才删?- deliver 需要锁吗?锁里做什么、锁外做什么?
waiter.get(timeout=...)超时抛什么异常?对外抛什么?- fail_all 之后,正在等的和以后才来的调用分别怎么处理?锁存存在哪、谁来检查?
细节
- key 是任意 Hashable(字符串或
("rpc", 7)元组);同一时刻一个 key 只能有一个活着的 waiter。 - deliver:有 waiter 交给它返回 True;否则返回 False 且
late_results += 1。 - 结果通道上传回来的可能是异常对象(远端执行失败或 fail_all 的致命错误):call 拿到 BaseException 实例就 raise。
L2思路与常见坑思路拆分与常见坑(中度剧透)
- 状态:
threading.Lock、waiters: dict[key, Queue]、fatal: BaseException | None、late_results。 - register:锁内:fatal 已设置 → raise fatal;key 已在表 → ValueError;否则新建
Queue(maxsize=1)放进表并返回。 - deliver:锁内
pop(key, None)——pop 保证同一 key 只交付一次;拿不到就计数返回 False。锁外put_nowait(result)。 - unregister:锁内仅当
waiters.get(key) is waiter时删除。 - call:
w = register(key)→try: send(); w.get(timeout),queue.Empty转成 TimeoutError;结果是异常就 raise,否则 return →finally: unregister(key, w)。register 在 try 外:register 失败时没有东西需要注销。 - fail_all:锁内设置 fatal、取出所有 waiter、清空表;锁外逐个
put_nowait(error)(防御性忽略 queue.Full)。
L3详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
- send 可能在返回前(甚至在 send 内部)就触发 deliver。先 send:deliver 时表里没 key → 计为 late;随后 register → 永远等不到。
- 每个 waiter 只会收到一个结果(deliver 用 pop 保证)。容量 1 足够,put_nowait 一定成功,router 线程不会被慢消费者卡住。
- 两者都可能:正常交付时 deliver 先 pop,finally 的 unregister 发现表里不是自己就什么都不做;超时时 deliver 没来,finally 负责删除。
- 见逐步图。
- 需要;锁内只做 dict 的 pop 和计数,锁外 put。持锁做任何可能阻塞的操作都会把其他线程堵住。
queue.Empty;对外转成内置 TimeoutError(可from None去掉无关的链式异常)。- 正在等的:fail_all 直接把 error 放进它们的 waiter,call 拿到后 raise。以后的:fatal 存在 router 里,register 检查到就立即 raise。
面试官追问 & 对照源码
- 为什么 orchestrator 内联执行 RPC 会阻塞后续请求准入?怎么改?
对照源码:engine/rpc_result_router.py:32-150 · engine/messages.py:125-148