nano-vllm-omni · 提示 ← P04P10 →
#13 · Track A · 前端与 Orchestrator · 书 p.61

P05 · RpcRouter:共享通道上的 RPC 关联

sleep / wake_up / collective_rpc 的结果和其他控制消息挤在同一条回传队列上,多个线程同时在等各自的结果——要按 key 把结果关联回正确的调用方。

⏱ 40 min 难度 易 实现 mine/p05_rpc_router.py 验收 ./check.sh p05 提示 A-13 · 参考解 C-13 solutions/p05_rpc_router.py

题面速览

验收要点:50 个线程并发调用、结果乱序回传,各自拿到自己的 · send() 内同步回传的结果不丢 · 超时 → TimeoutError、迟到结果被丢弃并计数、key 可复用 · 重复 key 报错 · fail_all 立刻唤醒所有等待者并锁存错误。

直觉与类比

奶茶店的取餐器

点单时店员先给你一个取餐器(register(key) 返回的 Queue(maxsize=1)),然后才把单子递进后厨(send())。后厨做好哪杯就呼叫哪个号(deliver),出餐顺序与点单无关。① 先领取餐器再下单:后厨手快,单子刚递进去饮料就好了,你手上还没取餐器,这杯只好放到“无人认领”(late_results += 1),你干等。② 等不及走了(超时),一定要把取餐器还回去(finally: unregister)。③ 还的是你手上那一个(is 比较),而不是“把 17 号作废”——17 号可能已经发给下一位客人。

后厨着火(fail_all)

店员不是让每个客人各自等到不耐烦,而是立刻广播“所有取餐器作废”(把错误放进每个在等的信箱),并在门口挂“暂停营业”(锁存):之后来的客人连单都不用下,进门就被告知(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)

易错点

逐步图:为什么 unregister 要比较身份

▶ A 超时、B 复用同一个 key k
1. t1:A register

A:register(k) → wA。表:k → wA

2. t2:A 超时;router 恰好 deliver(k, resA)

A 的 get 超时,准备进 finally。router:deliver(k, resA) 在锁里 pop 出 wA,锁外 put。表:(空)

wA 收到了 resA 但 A 已放弃——这条结果悄悄丢弃,它不算 late(确实有 waiter 接住了)。

3. t3:B register

B:register(k) → wB。表:k → wB

4. t4:A 的 finally

unregister(k, wA):表里是 wB,waiters.get(k) is wA 为 False → 不删。表:k → wB

5. t5:deliver(k, resB)

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 不变。

关键问题

  1. 为什么必须先 register 再 send()?构造一个“先 send 后 register”会永远等下去的时序。
  2. waiter 为什么是 Queue(maxsize=1)?deliver 放结果时会不会阻塞?
  3. 一个 waiter 会被谁从表里移除:deliver、call 的 finally,还是两者?怎么保证不互相踩?
  4. unregister(key, waiter) 为什么要多传一个 waiter,并且只在“表里那个就是我这个”时才删?
  5. deliver 需要锁吗?锁里做什么、锁外做什么?
  6. waiter.get(timeout=...) 超时抛什么异常?对外抛什么?
  7. 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详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
  1. send 可能在返回前(甚至在 send 内部)就触发 deliver。先 send:deliver 时表里没 key → 计为 late;随后 register → 永远等不到。
  2. 每个 waiter 只会收到一个结果(deliver 用 pop 保证)。容量 1 足够,put_nowait 一定成功,router 线程不会被慢消费者卡住。
  3. 两者都可能:正常交付时 deliver 先 pop,finally 的 unregister 发现表里不是自己就什么都不做;超时时 deliver 没来,finally 负责删除。
  4. 见逐步图。
  5. 需要;锁内只做 dict 的 pop 和计数,锁外 put。持锁做任何可能阻塞的操作都会把其他线程堵住。
  6. queue.Empty;对外转成内置 TimeoutError(可 from None 去掉无关的链式异常)。
  7. 正在等的:fail_all 直接把 error 放进它们的 waiter,call 拿到后 raise。以后的:fatal 存在 router 里,register 检查到就立即 raise。

面试官追问 & 对照源码

对照源码:engine/rpc_result_router.py:32-150 · engine/messages.py:125-148