乐!上一次看分布式死锁还是 2020 年做 一种基于图强连通分量算法的死锁检测机制 这个专利的时候=。=
在 RL 其实很不简单 里我提过, 我们把 DSA R3 做到了近乎 zero overhead. 这个项目关注度非常高, 我为此一度昼夜颠倒不知天地为何物: 周一睡了 3 小时 45 分钟, 周二 4 小时 37 分钟. 优化的其中一环, 是提升 mooncake 的写入速度. 于是某天, 我在存储写入端的 python 里加了一行配置:
config.with_soft_pin = True
config.allow_direct_reclaim = 1 # 就是这一行
ret = self._store.put_parts(key, header, *mv_list, config=config)
动机后面细说, 总之这行代码的每个局部动机都非常合理: 写满了就先回收一把, 回收不出来就降级. 上线之后不久, 集群开始成片 hang: 写入卡死, 读取卡死, 新发起的请求石沉大海, 而且不会自愈. 最后定位下来, 这是一个教科书级别的分布式死锁 —— 而且死锁住的资源不是锁, 也不是内存, 是 io 线程.
这篇文章面向对 mooncake 完全不了解的读者, 把这个死锁的前因后果讲清楚. 为此需要先铺三层背景: 这是个什么系统; 一个节点上跑着哪些线程; 内存满了系统会干什么. 背景铺完之后, 死锁本身只需要一张等待图.
背景一: 这是个什么系统
Mooncake 是 LLM 推理场景里的分布式 KV 存储, 最典型的用途是存 KV cache: 推理引擎把算好的 KV cache put 进来, 后续请求 get 回去复用, 省掉重算. 在我们的场景 (RL 训练 + 推理) 里, 它还承担了各类中转数据的存取. 我们跑的是它的一个特化形态: local master + 统一内存池 G. 展开说就是:
- 每个节点上有一个常驻的 RealClient 进程, 进程内嵌一个 MasterService, 即 “本地 master”. 它管理本节点的一大块共享内存池
G: 谁要写入, 先向它要一块内存 (这个操作叫PutStart), 写完数据再提交 (PutEnd). - 用户的训练/推理进程里嵌的是 DummyClient, 一个轻客户端. 它和 RealClient 之间靠共享内存交换数据:
G同时映射在两边的地址空间里, 数据 memcpy 进G就算写进了存储. - 多个节点各有自己的本地 master, 节点之间通过一个最终一致的元数据服务交换两张视图: “谁有哪些 key” 和 “谁还剩多少内存”. 注意是最终一致: 你看到的对端剩余容量, 可能是几秒前的.
对这块感兴趣可以看 RL 下 Mooncake Store 演进分析, 这里不展开. 本文只需要一层心智模型: 节点内有个本地 master 管内存分配, 节点之间会互相搬数据.
背景二: 一个节点上的两个 RPC server
RealClient 进程对外开了两个 RPC server, 都基于 yalantinglibs coro_rpc:
- local master server: 服务 master 协议 (
PutStart/PutEnd/…), 客户端是本机的 dummy client 以及其他节点. - real client server: 服务数据面协议 (
get_buffer等), 客户端是本机的 dummy client.
coro_rpc 有个对本文至关重要的实现事实: server 构造时指定 N 个 io 线程, 每条连接绑定到其中一个; 对于普通同步 handler, 框架会在连接的读循环里内联执行它. 换句话说, handler 阻塞多久, 这个 io 线程就停摆多久 —— 停摆期间, 绑在它身上的所有连接的请求都读不出来, 连 accept 新连接都轮不到执行. 这个模型本身没有问题, 前提是一个约定: 同步 handler 必须快进快出, 不能原地等待外部事件. master 协议的 handler 基本都是纯内存操作, 天然满足. 具体到 PutStart (向本地 master 要内存), 一个节点上有三类来源:
| # | 来源 | handler 跑在哪 | 会不会阻塞等外部 (改动前) |
|---|---|---|---|
| 1 | 本机 dummy client 写入数据 | local master server io 线程 | 不会 |
| 2 | 其他节点迁移数据过来 | local master server io 线程 | 不会 (被代码强制) |
| 3 | 本机读 miss 回填 (get_buffer) | real client server io 线程 | 会 (direct reclaim, 见下节) |
来源 3 有个实现细节: RealClient 进程内自己调本地 master 不走 RPC, 是进程内函数直调, 所以它占用的是 real client server 的 io 线程.
记住这张表的格局: local master server 的 handler 从不等人; 会等人的 handler 只出现在 real client server 上. 这是整个系统不死锁的隐含前提 —— 只是当时没有人把它当作一条不变量郑重写下来.
背景三: 内存满了怎么办
G 是有限的, 总会写满. 满了之后有两条回收路径:
- 后台回收: 独立线程周期性挑一批冷对象搬走, 类似 Linux 的 kswapd.
- direct reclaim:
PutStart分配失败时, 由这个请求自己同步发起一轮回收, 回收出字节后重试分配, 类似 Linux 的 direct reclaim. 这套设计在 Mooncake 统一内存池:从默认 Evict 到 Linux Reclaim 里详细写过.
关键在于 “搬走” 是什么. 我们场景里多数对象是用户主动写入且带 softpin 语义的 —— 在租约内不允许从系统里语义性消失 —— 所以回收不能简单删除, 必须先把数据挪到安全的地方. 去处有两个, 按优先级:
- remote swapout: 从容量视图里挑一个内存还有富余的节点, 把对象迁过去. 怎么迁? 向对端的本地 master 发
PutStart要内存, 传输数据,PutEnd提交. 没错, swapout 的下游就是另一个节点的 local master server. - offload: 远端迁移失败, 就写到下级存储 (CPFS 这类分布式文件系统) 里去, 慢但兜底.
再看 direct reclaim 的执行结构, 它是彻底同步的: 分配失败的 PutStart 在 handler 里原地发起回收 (多个并发失败者会合并成一轮, leader 执行, follower 原地等待, 味道有点像 group commit); 回收挑出一批候选对象, 丢给 swapout 线程池并行搬运, 但 handler 会原地等到整批搬完才重试分配. 而单个搬运任务里发向对端的那个 PutStart, 是一次同步 RPC. 当时的设计其实闻到过危险的味道, Mooncake 统一内存池:从默认 Evict 到 Linux Reclaim 里有这么一句原话:
单次读 miss 这种没有 fallback 的路径, 会更希望
PutStart尽量成功, 因此可以允许allow_direct_reclaim=true; batch read 和 remote swapout 写远端时则不行, 否则很容易把 reclaim 做成环路.
代码里也确实有对应防线: swapout 向对端发 PutStart 时强制 allow_direct_reclaim=0, 保证 “我为了腾内存迁数据给你” 不会递归触发 “你为了接收数据再去腾内存”. 上面表格里来源 2 的 “不会 (被代码强制)” 就是它.
于是全系统只有来源 3 (读 miss 回填) 会触发 direct reclaim. 它会阻塞, 但阻塞的是 real client server 的 io 线程, 等的是别的节点的 local master server —— 后者的 handler 永远快进快出. 等待图是单向的:
real client server io 线程 (可以等人) ────► local master server io 线程 (从不等人)
单向则无环, 无环则无死锁. 一切都很稳.
那一行代码
现在说动机. 写入端本来就有降级逻辑: put 时如果本地 master 分配不出内存, dummy client 就把数据直接写去 offload. 功能上没问题, 但写满之后所有新数据都直落 offload, 读回来要过 SSD/CPFS, 比内存慢得多. 于是我想: 宣告失败之前, 能不能先努力一次? allow_direct_reclaim 参数正好是干这个的. 加一行, 让写入路径也允许一次 direct reclaim: 回收成功, 数据留在内存里, 读取快; 回收不出来, PutStart 照样失败, 走原有的 offload 降级, 无非多花一次回收的时间. 看起来纯赚:
config.with_soft_pin = True
config.allow_direct_reclaim = 1 # this
ret = self._store.put_parts(key, header, *mv_list, config=config)
每个局部推理都对. 我漏掉的只有一件事: 写入数据的 PutStart 是表格里的来源 1, 它的 handler 跑在 local master server 的 io 线程上. 这行代码第一次让那个 “从不等人” 的 server 开始等人了.
死锁: 等待图成环
想象两个节点 A 和 B, 内存都满了, 都在持续写入. 某一刻:
A 的 local master io 线程: 正在执行 PutStart (来源 1, reclaim=1)
└─ 分配失败 → direct reclaim → swapout → PutStart ────► 等 B 应答
B 的 local master io 线程: 正在执行 PutStart (来源 1, reclaim=1)
└─ 分配失败 → direct reclaim → swapout → PutStart ────► 等 A 应答
A 发给 B 的 PutStart 需要 B 的 io 线程来处理, 但 B 唯一的 io 线程正原地等着 A 应答自己的 PutStart; 反之亦然. 两边的等待都没有超时. 互斥、占有并等待、不可剥夺、循环等待 —— 四个条件凑齐, 死锁成立, 而被循环持有的资源是 io 线程. 死锁一旦形成还会向全集群扩散. A 和 B 的本地 master 从此对一切请求无响应: 本机 dummy client 的读写、发起 put 的那个 python 调用 (同样没有超时) 全部挂起; 更阴险的是其他节点 C/D —— 它们的后台回收和读 miss 回填一旦选中 A 或 B 做迁移目标, 发出去的 PutStart 也永远不会返回, 调用线程被逐个吸进去有去无回. 死的是两个节点, 瘫的是一片, 这就是 “成片 hang” 的由来.
回头看, 旧格局的安全性从来不是因为 “没有阻塞”, 而是因为等待图单向. 这行代码的真正后果, 是让 “给别人提供 swapout 落点的服务” 自己也开始等待别人的 swapout 落点. 同类服务之间一旦互相等待, 成环只是时间问题.
后记
四个月前写下 “否则很容易把 reclaim 做成环路” 的人, 和四个月后亲手把它做成环路的人, 是同一个人.