P13 · ChunkAdapter:后台收发线程与顺序/清理语义
async_chunk 的心脏:调度线程只做快照入队,save 线程构造 payload 并 put,recv 线程轮询 get;还要处理 cancel、None payload,以及“清理时恰好有一个 chunk 在读”。
mine/p13_chunk_adapter.py
验收 ./check.sh p13
提示 A-21 · 参考解 C-21 solutions/p13_chunk_adapter.py
题面速览
- 在系统中的位置:每个 stage 进程的 scheduler 里有一个 ChunkAdapter:它是 connector(只管传输,P12)和 scheduler(P11)之间的一层,负责 chunk 的完整生命周期。
发送端(stage 0) 接收端(stage 1)
scheduler 线程: save_async(req, snapshot, final) scheduler 线程: load_async(req) / poll_ready()
│ 只做 deepcopy + 入队(不阻塞调度) ▲ 已提交的 chunk,按序
save 线程: build_payload(snapshot) recv 线程: get(f"{req}_{src}_{next_id}")
put(f"{req}_{stage}_{chunk_id}") 拿到 → 在锁内检查“还注册着吗” → 提交
成功后 chunk_id += 1
| 规则 | 不遵守会怎样 |
|---|---|
| save_async 深拷贝快照 | scheduler 下一步继续修改请求对象,后台线程读到的是“未来”的数据 |
| build_payload 和 put 都在锁外、在 save 线程 | 慢的 Python 整形和 SHM 写阻塞调度,GPU 空等 |
| chunk_id 只在 put 成功后递增;None payload 不占 id | 接收端按连续 id 轮询,出现空洞就永远卡住 |
| cancel 不能吞掉 final chunk | final chunk 是下游唯一的 EOS,丢了下游请求就永远停在 WAITING_FOR_CHUNK |
| get 在锁外;提交前在锁内检查注册 token 是否还是同一个 | 请求刚被清理,后台线程把迟到的 chunk 又写回去,污染下一个同 id 请求 |
| 没有进展时退避 ~1ms,空闲时在 Condition 上睡 | busy-poll 吃满一个核,和 tokenize 抢 GIL |
验收要点:单请求 50 个 chunk 有序 · 10 请求 × 30 chunk、随机延迟下各自有序 · save_async 永不阻塞调用者 · 快照隔离 · cancel 后 final chunk 仍送达、chunk id 连续 · None payload 被跳过且 id 无空洞 · cleanup 是提交栅栏:正在 get 中的 chunk 不会在 cleanup 之后出现。
直觉与类比
后厨(发送端)每出一道菜贴一张编号小票:0、1、2…,放到传菜窗口(connector)。跑堂(recv 线程)按编号去窗口取:没看到 k 号,就绝不去拿 k+1 号——这就是顺序的来源。所以后厨只在菜真的放上窗口之后才撕下一张小票(put 成功后 id+1);“这一步没有新菜”(payload 为 None)就别撕小票。最后一道菜带牌子“本桌已上齐”(finished=True);客人中途退单,还没做的菜都可以不做,但“已上齐”的牌子一定要送(空盘子,data=None)。厨师下单时拿的是点菜单的复印件(deepcopy)。
7 号桌的客人走了(cleanup_receiver),马上又坐下一拨新客人,桌号还是 7。跑堂手里端着上一拨客人点的菜回来——如果他只问“7 号桌有人吗?”(有!)就会把旧菜端给新客人。正确做法:每拨客人入座时发一张独一无二的桌卡(object()),跑堂出发前记下桌卡,回来时核对“现在桌上的是不是同一张卡”(is),不是就倒掉。核对和上菜要在同一把锁里一口气做完。
框架视角 · T · SBWC · LIFE
| P13 | |
|---|---|
| T | 每个 stage 进程:调度线程(P10 busy loop)+ save 线程 + recv 线程(daemon)。线程之间 Lock / Condition / Event;进程之间只有 connector 上的 key |
| S | 发送端:待发送队列(调度线程 append、save 线程 popleft)、每请求下一个发送 id(save 线程写)、已取消集合(调度线程写、save 线程读 → 加锁)。接收端:注册表 req → token、下一个接收 id、已提交未取走的列表(两边都写 → 同一把锁) |
| B | build_payload、connector.put、connector.get:慢,全部在锁外、在后台线程。空闲时 Condition.wait(timeout);有请求没数据时退避 ~1ms。调度线程上没有任何阻塞 |
| W | 调度线程:只做快照入队、登记、取结果。save 线程:只做 build + put,FIFO。recv 线程:只做 get + 提交,不准持锁 get |
| C | 调度线程 → save 线程:deque + Condition;save → recv(跨进程):connector 的 key;recv → 调度线程:ready 字典,poll_ready 在锁内整体换出 |
| L | start:建两个线程;stop:置 Event → 对两个 Condition notify_all → 带超时 join。每个请求:load_async 发 token → 收到 final 或 cleanup_receiver 时注销;发送端在 final 发出后复位 id 和取消标记 |
| I | 除 start / stop,所有公开方法都在调度线程上调用,持锁时间必须是微秒级 |
| F | ① chunk id 从 0 连续,只在 put 成功后前进,None 的非 final 块不占 id;② final 块一定发出(被取消时 data=None);③ 只有 token 身份没变时才提交;④ 每个提交的块在 poll_ready 里恰好出现一次、按顺序 |
| E | chunk 在 load_async 之前就 put 了(connector 一直保存它,照样能收到);put 抛异常(id 不前进);清理时恰好在读;同名请求重新注册;stop() 时线程正睡在 wait 上 |
易错点
- 没 deepcopy → 例 2 收到
["a","b","c"]。 - 入队时就 +1 → 例 1 出现
u9_2_2之后跳到u9_2_4,接收端永远卡在等u9_2_3。 - cancel 连 final 一起吞掉 → 下游永远收不到 finished。
- 只在 get 之前检查注册状态(或只查
req in registered)→ get 期间被清理的块在 cleanup 之后出现在 poll_ready。 - 空闲时 busy-loop → CPU 100%,其他线程(含测试)变慢。
- 在锁外遍历 ready(recv 线程可能正在 append)。
- load_async 不幂等:同一 req 在轮询队列里出现两次,两份轮询抢同一个 chunk id。
- stop 不 notify:线程在 wait 里多睡一个 timeout;wait 不带 timeout:丢通知就永远睡。
逐步图:例 1 的 save 线程(build = n if n % 3 else None,final 在 n=6)
build → 1 → put u9_2_0 → next_send_id = 1
build → 2 → put u9_2_1 → next_send_id = 2
build → None 且非 final → 跳过,不消耗 id(仍是 2)
put u9_2_2、u9_2_3 → next_send_id = 4
build → None,但是 final → 仍然 put,data=None → u9_2_4;之后删除计数并复位。
conn.puts == ["u9_2_0", "u9_2_1", "u9_2_2", "u9_2_3", "u9_2_4"]——6 次 save,5 个连续 id
recv 线程 next_recv_id 依次 0→1→2→3→4→5;拿到 u9_2_4 的 finished=True 后 u9 被注销,不再放回轮询队列。
rcv.poll_ready() -> {"u9": [{data:1},{data:2},{data:4},{data:5},{data:None, finished:True}]}
逐步图:cleanup 竞态(connector 的 get 很慢)
registered[q] = tok1
锁内读到 (tok1, id=0);锁外进入 get("q_0_0")……
registered[q] 删除(recv 线程还在 get 中)
registered[q] = tok2
锁内比较 registered[q] is tok1?
结果
False → 丢弃。如果只检查 q in registered,t4 会是 True,旧请求的数据就混进了新请求。
三级提示
L1I/O 示例与关键问题I/O 示例 + 关键问题 + 细节(轻度剧透)
例 1 见逐步图(注意 key 里的数字:发送端用自己的 stage_id,接收端用 src_stage_id 拼同一个 key)。
例 2:快照隔离 + poll_ready 是“取走自上次以来的”
live = ["a"]; rcv.load_async("v")
snd.save_async("v", live, is_final=False)
live.append("b") # 调度线程继续改活对象
snd.save_async("v", live, is_final=True)
live.append("c")
# 收到的 data 依次是 ["a"]、["a", "b"];conn.puts == ["v_0_0", "v_0_1"]
snd.save_async("q", 1, False); snd.save_async("q", 2, False) # 稍等
rcv.poll_ready() -> {"q": [{"data": 1, ...}, {"data": 2, ...}]}
rcv.poll_ready() -> {} # 已经被取走了
snd.save_async("q", 3, True) # 稍等
rcv.poll_ready() -> {"q": [{"data": 3, "finished": True}]}
chunk 在 load_async 之前就已被 put,load_async 之后照样能收到。
例 3:cancel_sender(build_payload 阻塞在 gate 上):save "p0"(开始 build,卡住)→ 排队 p1、p2、p3 → save "end"(final) → cancel_sender("w") → 放行。接收端得到 [{"data": "p0", "finished": False}, {"data": None, "finished": True}],conn.puts == ["w_0_0", "w_0_1"]。p0 会不会被发出取决于取消时它是否已过了取消检查——两种都可接受;但 id 必须连续、final 必须到。
关键问题
- save_async 在调度线程上执行,必须立即返回。它最多只能做哪两件事?build_payload 为什么不能在这里调用?
- chunk_id 什么时候 +1:入队时、build 之后、还是 put 成功之后?
- build_payload 返回 None 时什么情况跳过、什么情况仍要 put?final 块被 cancel 后 data 是什么?
- 接收端怎么知道下一个要 get 的 key?每个请求要记住哪些状态?收到 finished 后还要轮询吗?
- recv 线程在锁外 get 时,调度线程 cleanup 又 load_async 同一个 req。recv 回来怎么判断“这块属于已作废的那次注册”?
- 10 个请求都在轮询但都没数据时 recv 线程该做什么?完全没有请求时呢?
- stop() 要迅速返回。save 线程正睡在 Condition.wait() 上,怎么叫醒?如果它恰好在 notify 之前一瞬间才去 wait 呢?
细节
- save_async / cancel_sender / load_async / poll_ready / cleanup_receiver 都在调度线程上调用;SAVE 线程只做 build + put;RECV 线程只做 get + 提交。
- put 的对象固定是
{"data": payload, "finished": is_final};poll_ready 原样返回,按 id 顺序放在{req: [...]};没有新内容的请求不出现。 - per-registration token:load_async 时生成
object()存起来;cleanup 时删掉;recv 线程 get 之前记下 token,回来后在锁内用is比较。 Condition.wait(timeout=...)返回后必须重新检查条件;给 wait 加较短超时(如 0.1s),丢通知也只是多睡一会儿。
L2思路与常见坑思路拆分与常见坑(中度剧透)
- 发送端:FIFO deque
(req, snapshot, is_final)+ Condition 唤醒 save 线程;next_send_id: dict[req, int];cancelled: set[req]。只有一个 save 线程按 FIFO 处理,同一请求的 chunk 天然有序。 - save 线程一次迭代:取一项 → 查 cancelled(短暂持锁)→ 被取消且非 final:丢弃 → 被取消且 final:data=None → 否则锁外 build → data 为 None 且非 final:跳过 → put 到当前 id 的 key → 成功后 id+1;final 时清理 id 计数和 cancelled 标记。put 抛异常时不 +1。
- 接收端:
registered: dict[req, token]、next_recv_id、ready: dict[req, list]、轮询队列 + Condition。load_async 幂等。 - recv 线程一轮:对当前队列里的每个请求轮询一次:锁内读 (token, next_id),没注册了就扔掉;锁外 get;没数据放回队尾;有数据锁内比对 token → 一致才 append、id+1;finished 就注销(不再放回),否则放回。一轮结束:队列空 → Condition 上睡(带超时);有请求但没进展 → 睡 ~1ms;有进展 → 立刻下一轮。
- poll_ready 原子交换:锁内把 ready 整个换成新的空 dict 并返回旧的。
- cleanup_receiver 是提交栅栏:锁内删除 registered、next_recv_id、ready。由于“比对 token + 提交”也在同一把锁内,cleanup 返回之后任何提交要么已发生(已被删掉),要么会看到 token 不匹配而丢弃。
- stop():设 Event,对两个 Condition 分别
with cond: cond.notify_all(),再 join(带超时)。线程设为 daemon。
L3详解:答案详解:答案、逐步追踪、骨架提示(重度剧透)
关键问题答案
- 只做:deepcopy 快照 + 入队(并 notify)。build_payload 可能很慢,会阻塞调度。
- put 成功之后。入队时 +1 会让被跳过的 None 和 put 失败的块各占一个 id,接收端按 id 顺序轮询,会永远等那个不存在的 key。
- None 且非 final → 跳过;final → 无论如何都 put;被 cancel 的 final → data=None。
- key =
f"{req}_{src_stage_id}_{next_id}";每个请求:注册 token、下一个 id、已提交未取走的列表。收到 finished 后停止轮询并注销。 - 用 token 的身份比较(
is);只查是否在 registered 中不够(见竞态逐步图)。 - 有请求无数据:短暂退避(~1ms)后再轮询;没有请求:在 Condition 上睡,直到 load_async 唤醒(带超时兜底)。
- stop 设 Event 并 notify_all;wait 带超时,即使错过通知也最多晚一个超时周期醒来,醒来后检查 Event 退出。
面试官追问 & 对照源码
- 如果有多个 save 线程,顺序怎么保证?
- build_payload 需要 TP collective 时能放在后台线程吗?
对照源码:distributed/omni_connectors/transfer_adapter/base.py:20-140 · chunk_transfer_adapter.py:381-503 · :539-555 · :714-723 · :991-1042