LMCache 多进程模式下的 Worker 存活追踪与回收(Worker Liveness Tracking and Reaping)设计解析
【免费下载链接】LMCacheLMCache: Supercharge Your LLM with the Fastest KV Cache Layer项目地址: https://gitcode.com/GitHub_Trending/lm/LMCache
本指南基于 LMCache 仓库中的设计文档 docs/design/v1/multiprocess/worker_liveness.md 展开,配合 lmcache/v1/multiprocess/ 服务端源码与 vLLM 多进程适配器实现,完整讲解:引擎 worker 异常退出时实例状态泄漏的问题根因、PING 心跳驱动的 liveness 追踪协议设计、服务端两档沉默窗口与独立 reap 线程的实现、UUID 实例 ID 生成策略、以及故障恢复与边界情形。读完本文,你将掌握 LMCache 多进程模式下“worker 死而状态不清”这一问题的完整解决方案,并能准确配置
worker_reap_timeout_seconds与worker_registration_grace_seconds两个关键参数。
1. 问题:worker 死亡后泄漏的实例状态
1.1 泄漏什么
在多进程(MP)模式下,每个引擎 worker 都会在 MP server 上登记一份“per-instance 状态”。当 worker 正常退出时会发送UNREGISTER_KV_CACHE清理状态,但一旦 worker 以非正常方式死亡(SIGKILL、被 OOM-killer 杀死、所在节点丢失),UNREGISTER_KV_CACHE永远不会发出,于是以下状态会在 server 上永久残留:
- LMCache 驱动的
ContextEntry:一个持有 CUDA IPC 句柄的GPUCacheContext; EngineDrivenContextEntry+TransferStrategy配对:非 GPU 传输路径的上下文与传输策略;- 混合(blend)模式的 per-instance 状态:例如 CB rope 缓存。
设计文档(docs/design/v1/multiprocess/worker_liveness.md的 Section 1)明确指出:没有任何机制观察 worker 的死亡;在共享服务器上,泄漏的 context 会不断累积,直至设备内存耗尽。
1.2 为什么 PID 复用会让问题更糟
更糟糕的是,原设计中instance_id直接使用os.getpid()。容器化 pod 会复用较小的 PID 号,因此一个新 worker 可能注册到一个已死 worker 的 ID 上;而注册逻辑是幂等的,新 worker 会被静默绑定到那份陈旧的 context —— 结果是错误的 IPC 句柄、损坏的传输。
这一根因决定了修复方案的两个方向:一是换掉 PID 这个可复用的标识(见第 4 节 UUID 方案),二是必须引入主动的存活观察机制(见第 2 节心跳方案)。
2. 设计总览:把现成的心跳升级为存活信号
2.1 核心思路
server 本来就周期性地收到每个活跃 worker 的信号:心跳 PING。设计方案没有引入新的探测通道,而是:
- 在 PING 消息中携带 worker 的
instance_id; - 在已有的 per-instance 条目上盖时间戳
last_seen; - 运行一个周期性扫描线程,把沉默超过超时阈值的条目,用与客户端主动 unregister 完全相同的清理路径回收掉。
2.2 心跳的既有行为保持不变
心跳保留其惰性启动(lazy start)语义——第一个 store/retrieve 请求到来时才启动,因此 warmup 阶段不产生 PING。设计文档特别强调:
- 心跳初始即为健康状态(health_event 在构造时置位),首个 store/retrieve 不会被 gate;
- 存活的 worker 每个间隔 ping 一次,刷新 server 端的
last_seen,因此存活期间永远不会被 reap,启动时也无需重注册; - 从未产生过存活信号的条目,落入一个更宽松的注册宽限期(registration grace)内判定。
2.3 恢复复用既有客户端机制
真正的宕机恢复路径同样复用已有机制:宕机期间心跳 PING 持续失败,health_event被清除;当 server 恢复、下一次 PING 成功时,unhealthy→healthy 的边沿触发 recover 回调,回调执行重注册——如果原条目幸存(例如只是短暂分区),重注册走 NOOP 路径,不产生任何新建。
文档中的时序图可概括为:
engine worker adapter MP server +---------------------------+ +--------------------------------------+ | HeartbeatThread | PING [id] | ManagementModule | | (instance_id, 10s) -----+------------------>| ping(id) -> touch_instance(id) | | lazy start on first req, | (NORMAL pool) | reaper thread (scan = timeout/4) | | (starts healthy) | | -> reap_stale_instances( | | unhealthy->healthy edge | | timeout, registration_grace) | | -> re-register callback | REGISTER | -> drop_instance_state(id) fan-out | | +------------------>| | | | | register_kv_caches | STORE/RETRIEVE | v v | | (no pings until traffic) | (refresh too) | LMCacheDriven/ Blend | | | | EngineDriven (CB rope | | | | TransferModule dropped) | | | | Entry{.., last_seen, | | | | has_liveness_signal} | +---------------------------+ | _lock (leaf); pop -> cleanup | +--------------------------------------+3. 协议变更:PING 载荷从[]变为[int | None]
3.1 载荷与调度
PING 的载荷原地变更:从[]变为[int | None],响应仍是bool,恒为True。其中None用于标记未被追踪的探针——例如 scheduler adapter,它不注册任何 KV cache,因此永远不会被 reap。
PING 继续在NORMAL 线程池上以 BLOCKING 方式分发。设计文档记录了曾经考虑过 SYNC 分发但被否决:SYNC 运行在 MQ 主循环上,一个慢的REGISTER_KV_CACHE(同样是 SYNC)会阻塞 PING,从而让一个存活的 worker 看起来像死了一样。反之,共享 NORMAL 池恰恰是期望的语义——如果线程池在心跳超时内无法应答 PING,worker 本来就应当进入降级模式,这正是同一套背压信号。
3.2 兼容性约束
载荷变化是线上可见(wire-visible)的,因此客户端与服务端必须同时升级。混合版本部署时,每一次 PING 都会因载荷长度校验失败而失败,客户端会永久处于 unhealthy 状态——这是“响亮”的失败,而绝不是静默的数据损坏(详见第 7 节失败模式表)。
4. 实例 ID 生成:从 PID 换成 UUID
4.1 生成方式与理由
worker 适配器将os.getpid()替换为:
uuid.uuid4().int & ((1 << 63) - 1)uuid4读取操作系统熵(OS entropy),相同种子启动的进程也不会碰撞;& ((1 << 63) - 1)的 63 位掩码保证数值在有符号 int64范围内安全(兼容 msgpack 等任何 int64 对端)。
该 ID 在构造时以 INFO 级别打日志,便于运维人员把 reap 告警与具体 pod 关联起来。
4.2 改动面很小
所有携带 ID 的请求读取的都是同一个字段,因此除 PING 之外的其他载荷(REGISTER、STORE、RETRIEVE 等)完全不需要改动。实现位置见 lmcache/integration/vllm/vllm_multi_process_adapter.py:
# Instance id for GPU worker. uuid4-derived (OS entropy) rather # than pid, so a restarted pod can't alias a dead worker. # Masked to 63 bits to stay signed-int64-safe for any msgpack peer. self.instance_id: int = uuid.uuid4().int & ((1 << 63) - 1)5. 服务端实现细节
5.1 liveness 状态与两档沉默窗口
ContextEntry(LMCache 驱动)与EngineDrivenContextEntry各新增两个字段:
last_seen: float(使用time.monotonic(),不受系统时钟跳变影响);has_liveness_signal: bool,只有 PING 会将其置位(latch)。
该标志决定条目的沉默窗口判定档位:
| 条目状态 | 判定窗口 |
|---|---|
| 已经 PING 过(证明在跑心跳协议) | reap timeout(如默认120.0s) |
| 从未 PING(例如 lazy start 下仍在 warmup) | 更宽松的 registration grace(默认3600.0s) |
last_seen的刷新时机包括:PING(touch_instance:存在则刷新、绝不插入)、register(create 与 NOOP 两条路径都刷新)、以及每一条传输路径——因此正在传输中的 worker 绝不会被 reap;但流量不会置位has_liveness_signal标志(只有 PING 能 latch)。
5.2 加锁:独立线程与 MQ 线程的并发安全
reap 线程独立运行,因此 per-instance dict 现在会在MQ handler 线程之外被修改。每个传输模块各持有一把threading.Lock,防止 reap 的 scan-and-pop 与并发的 register/unregister/transfer 竞争(否则会破坏 dict 或把半删除的条目交给调用方)。
设计要点(文档 Section 5.2):
- 在
EngineDrivenTransferModule中,context dict 与 strategy dict 在同一把锁下成对变更,因此 reap 与 re-register 竞争时,绝不会出现“新 context 没有配套 strategy”的悬空状态; - 它是叶锁(leaf lock)——绝不跨 context 构造、存储调用或其他组件持锁,因此任何线程都不会同时持有两把锁;
- 外部读者改用加锁访问器:
get_and_touch_context_entry(get 并刷新 last_seen)与context_entries_snapshot,而不是直接触碰 dict。
源码佐证:在 lmcache/v1/multiprocess/modules/engine_driven_transfer.py 中,_resolve_for_transfer在同一锁内同时取 entry 与 strategy 并刷新last_seen,且注释明确说明“Refreshes last_seen (no latch) so an active worker is not reaped mid-transfer”。
5.3 Reaper 线程:ManagementModule持有
ManagementModule(PING 的拥有者)同时拥有 reap 线程,其工作方式为:
- 每
reap_timeout / 4扫描一次(见 lmcache/v1/multiprocess/modules/management.py 中create_periodic_thread(name="lmcache-mp-worker-reaper", interval=self._reap_timeout / 4, ...),因此实例在最后一次信号后的timeout到timeout + interval之间被回收); - 每次扫描对每个目标调用
reap_stale_instances(reap_timeout, registration_grace):- 模块锁内:收集沉默时间超过其窗口的 id,将其 pop 出 dict;
- 锁外:执行与客户端 unregister 相同的清理,并对每个实例记录 WARNING 日志——同一 id 被重复 reap 提示 timeout 设置过小;
- 被 reap 的 id 随后对每个目标调用
drop_instance_state(id);BlendModule在此处丢弃该实例的 per-instance CB 状态(如 rope state)。设计文档特别说明:Blend 不再镜像 GPU cache context(该镜像上游已移除),因此 reap GPU 条目现在会直接释放 context; - collect+pop 与 register 的刷新共享模块锁,从而串行化每一次 register-vs-reap 竞争;关闭时 reap 线程在任何模块清理状态之前先 stop 并 join。
5.4 公开协议与配置
服务端通过一个 Protocol 覆盖两类角色(liveness owner 与 state mirror),见 lmcache/v1/multiprocess/engine_module.py:
class InstanceLivenessTarget(Protocol): # All methods default to a no-op; an implementer overrides only its role. def touch_instance(self, instance_id: int) -> None: ... def reap_stale_instances( self, reap_timeout_s: float, registration_grace_s: float ) -> list[int]: ... def tracked_instance_count(self) -> int: ... def drop_instance_state(self, instance_id: int) -> None: ...角色分工:
- 传输模块(
LMCacheDrivenTransferModule、EngineDrivenTransferModule)重写 liveness 方法(touch/reap/count); BlendModule只重写drop_instance_state,用于丢弃镜像的 CB 状态;ManagementModule通过一次注入拿到全部目标列表。文档还记录了演进:早先存在单独的单方法InstanceReapListener(只含drop_instance_state),因只有BlendModule实现它而被合并进本协议。
服务端状态上报:ManagementModule.report_status()在存在 liveness targets 时返回worker_liveness摘要(enabled、reap_timeout_seconds、registration_grace_seconds、tracked_instances),便于通过 HTTP/状态接口观测 reap 是否启用、当前跟踪多少实例。
5.5 配置参数与校验
配置定义于 lmcache/v1/multiprocess/config.py(MPServerConfig):
| 参数 | 默认值 | 约束 | 语义 |
|---|---|---|---|
worker_reap_timeout_seconds | 120.0 | 0表示禁用(不启动 reap 线程);否则>= 30.0 | 已证明存活(PING 过)的 worker 的沉默预算 |
worker_registration_grace_seconds | 3600.0 | >= reap timeout | 已注册但从未 PING 的 worker 的沉默预算(warmup 或首次请求前死亡) |
校验逻辑(__post_init__)会抛出ValueError,若:reap timeout 非有限、为负、或非零却低于 30 秒下限;或 grace 小于 reap timeout。源码中的校验提示还点明了调参原则:reap timeout 应>= 3 x客户端的lmcache.mp.heartbeat_interval(默认 10s),这样错过几次 PING 也绝不会 reap 一个存活的 worker;且 grace 若比 reap timeout 更紧,反而会比崩溃的 worker 更快地 reap 掉仍在 warming 的 worker。
两个参数都有对应的 CLI 标志(--worker-reap-timeout-seconds、--worker-registration-grace-seconds),并在服务端构造时传入ManagementModule。
6. 客户端(worker 适配器)侧实现
6.1 惰性启动与健康状态
- 心跳保持首个 store/retrieve 才启动的惰性语义——warmup 期间无 PING,这段窗口由 registration grace 覆盖;
- 构造时即置位 health_event(初始健康),首个 store/retrieve 不会被 gate;
- 存活的 worker 之后按
lmcache.mp.heartbeat_interval(默认 10s)周期 ping,刷新服务端last_seen,因此存活期间永不 reap、启动时也无需重注册; - recover 回调只在真正的恢复边沿触发(见 6.2);
- server 不健康期间被丢弃的 retrieve 仍通过
get_finished上报,异步加载不会悬挂。
适配器启动时还会做一次告警检查:当3 x heartbeat_interval > _SERVER_REAP_TIMEOUT_FLOOR_SECONDS(30s 下限)时,打印 WARNING 提示服务端 timeout 必须同步调大(lmcache/integration/vllm/vllm_multi_process_adapter.py 中_SERVER_REAP_TIMEOUT_FLOOR_SECONDS相关逻辑)。
6.2 被 reap 之后的恢复时序
设计文档给出如下时间线:
T0 outage begins; pings time out -> health_event cleared, traffic stops T0+120s server: entry stale -> reap pops it, frees GPUCacheContext/IPC, layout-desc refcount; blend rope state dropped via listener <- leak fixed T1 connectivity back; next ping succeeds -> unhealthy->healthy edge T1 recover callback re-registers (id absent -> fresh context) before health_event is set; traffic resumes <- exactly one context关键语义:recover 回调在health_event置位之前执行(HeartbeatThread.register_recover_callback明确“The callback runsbeforethe health event is set”),且要求返回 bool——成功返回True才置位事件,失败返回False则事件保持清除、下一次成功 PING 会再次调用回调。回调绝不应抛异常。
- 长宕机:条目被 reap 后,恢复边沿的重注册会创建一个全新 context(旧 id 已不在,走 create 路径)——恰好一个 context,无泄漏;
- 短宕机(分区小于 reap 窗口):走 NOOP register 路径,只刷新
last_seen、不构建任何东西——server 永远不会要求 worker 重注册,零 context 抖动。
6.3 关闭顺序:杜绝“幽灵 context”
shutdown()在发送UNREGISTER之前先停止心跳线程,确保不会有迟到 PING 落在一个正在关闭的客户端上;- 心跳循环一旦观察到 stop 请求,就跳过 recover 回调与
health_event.set(); - recover 回调在已请求 stop 时也跳过重注册——迟到的循环绝不可能重建一个幽灵 context。
7. 失败模式全表
设计文档用一张表系统覆盖了各场景的行为(以下为完整继承并补充说明):
| 场景 | 行为 |
|---|---|
| worker 崩溃(SIGKILL,无 UNREGISTER),已服务过 | PING 停止;约timeout + timeout/4内被 reap。Context、IPC 句柄、layout-desc 引用计数与非 GPU strategy 通过与干净 unregister 相同的清理路径释放;blend rope 状态经 reap listener 丢弃。这正是本设计要修复的 bug。 |
| warmup 期间崩溃(已注册、从未 PING) | 在 registration grace 上被 reap。泄漏有界,而非永久。 |
| worker 存活但从未 PING,空闲超过 grace | 只有它从未 PING(心跳从未启动)才可能被 reap;一旦心跳运行,PING 每间隔刷新last_seen,无论流量多少,存活的 worker 都不会被 reap。 |
| 心跳线程被饿死、worker 正在传输 | store/retrieve/prepare/commit 都会刷新last_seen;不会被 reap。 |
| 分区短于 reap 窗口 | 不 reap。恢复时 recover 回调重注册;NOOP 路径刷新last_seen;零 context 抖动。 |
| worker 崩溃后重启 | 新进程获得全新 uuid 派生 ID 与新条目;旧 id 被独立 reap。无 PID 复用别名问题。 |
| 混合客户端/服务端版本 | 每个 PING 都过不了载荷长度检查;客户端永久 unhealthy。这是响亮的失败,绝不静默损坏;两侧须同时升级。 |
8. 测试验证:无 GPU 即可覆盖 liveness 逻辑
仓库中已有针对本设计的单元测试 tests/v1/multiprocess/test_worker_liveness.py,覆盖传输模块的公开 liveness 接口、management reap 装配、blend reap listener 与配置校验。测试通过__new__绕过__init__(避免启动 CUDA host-func dispatcher),用threading.Lock+ 空 dict 构建“裸模块”,从而在没有 GPU、没有真实 server的环境下验证touch_instance/reap_stale_instances/tracked_instance_count/drop_instance_state与MPServerConfig的校验逻辑。若要在本地跑这组测试:
# 在仓库根目录,仅运行 worker liveness 相关测试 python -m pytest tests/v1/multiprocess/test_worker_liveness.py -v(前提:已按 docs/getting_started/installation.rst 完成环境与测试依赖安装。)
9. 调参与运维要点速查
- 先确认心跳间隔:客户端
lmcache.mp.heartbeat_interval(默认 10s)与服务端worker_reap_timeout_seconds(默认 120s)要满足reap_timeout >= 3 x heartbeat_interval;若把心跳间隔调大,必须同步调大 reap timeout,否则适配器启动时会打印 WARNING。 - reap timeout 不是越低越好:默认 120s 已满足“错过几次 PING 不误杀”。过小的 timeout 会导致日志中出现同一 id 的重复 reap WARNING。
- grace 一定要 >= reap timeout:否则 warming 中的活 worker 会比崩溃 worker 更快被回收;配置校验(
ValueError)会拒绝不合法的组合。 - 观察手段:
ManagementModule.report_status()返回的worker_liveness字段(enabled/reap_timeout_seconds/registration_grace_seconds/tracked_instances)可用来确认 reap 是否启用、当前跟踪多少实例;reap 日志以 WARNING 级别记录每个被回收的实例 ID(含沉默时长与是否 PING 过),可与 worker 启动时 INFO 打印的 UUID 实例 ID 对应排查。 - 升级纪律:PING 载荷变化是线上可见的,服务端与所有 worker 适配器必须同步升级,混合版本会导致客户端永久 unhealthy(安全但需及时处理)。
- 关闭要禁用:将
worker_reap_timeout_seconds设为0可禁用 reap(不启动 reap 线程),适用于不希望自动回收的任何自定义运维场景。
10. 总结
Worker liveness 追踪与回收机制以“复用现成心跳”为核心,用最小协议改动(PING 携带实例 ID)+ 两个状态字段(last_seen、has_liveness_signal)+ 一个独立 reap 线程,系统性解决了 MP 模式下 worker 异常死亡导致的实例状态永久泄漏问题;UUID 实例 ID 消除了 PID 复用造成的错误别名;recover 回调与 NOOP 重注册让长短故障都能自愈且不产生多余 context。整个方案在源码(lmcache/v1/multiprocess/modules/management.py、engine_driven_transfer.py、lmcache_driven_transfer.py、engine_module.py、config.py)与单元测试(tests/v1/multiprocess/test_worker_liveness.py)中均有完整落地,可作为在共享 GPU 服务器上长期稳定运行 LMCache 多进程模式的可靠性基石。
【免费下载链接】LMCacheLMCache: Supercharge Your LLM with the Fastest KV Cache Layer项目地址: https://gitcode.com/GitHub_Trending/lm/LMCache
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考