执行摘要
- 一句话:修复 kill_process_tree 因 pidfd EINVAL 崩溃
- 推荐动作:该 PR 是典型的系统兼容性修复,值得合并。建议精读
_still_holding_resources 和新的 _wait_for_reap_or_raise 实现,理解如何用轮询替代 psutil.wait_procs 绕过内核限制。该模式在未来处理类似 os.pidfd_open 兼容性问题时可复用。
功能与动机
psutil.wait_procs 内部对非子进程使用 os.pidfd_open,在某些内核上对刚被 SIGKILL 的进程会抛出 OSError(EINVAL),导致测试 tearDownClass 中的 reap 等待意外中止,即使测试本身已通过。PR body 明确描述了这一问题。
实现拆解
- 新增
_still_holding_resources 函数(python/sglang/srt/utils/common.py):遍历进程列表,通过 p.is_running() 和 p.status() != psutil.STATUS_ZOMBIE 判断进程是否仍持有资源。僵尸进程(Zombie)视为资源已释放,因为内核已回收其 GPU context 等。对 psutil.NoSuchProcess 和 OSError 静默跳过。
- 重写
_wait_for_reap_or_raise 函数:将原本基于 psutil.wait_procs 的两阶段等待替换为轮询循环。计算 deadline 和 warn_deadline,每轮调用 _still_holding_resources 检查存活进程。若超过 deadline 仍有进程未退出则抛出 RuntimeError;若首次达到 warn_deadline 则打印警告日志。每次轮询后 time.sleep(0.1) 避免忙等待。
- 更新函数文档字符串:在
_wait_for_reap_or_raise 的 docstring 中说明改用轮询 /proc 而非 psutil.wait_procs 的原因(绕过 pidfd EINVAL)。
- 调用方
kill_process_tree 无需修改:其接口与行为完全不变,仅底层等待逻辑被替换。
关键文件:
python/sglang/srt/utils/common.py(模块 工具函数;类别 source;类型 core-logic;符号 _still_holding_resources): 包含全部变更:新增 _still_holding_resources 函数,重写 _wait_for_reap_or_raise 用轮询替代 psutil.wait_procs,修复 OSError(EINVAL) 崩溃。
关键符号:_still_holding_resources, _wait_for_reap_or_raise, kill_process_tree
关键源码片段
python/sglang/srt/utils/common.py
包含全部变更:新增 _still_holding_resources 函数,重写 _wait_for_reap_or_raise 用轮询替代 psutil.wait_procs,修复 OSError(EINVAL) 崩溃。
def _still_holding_resources(procs):
"""Procs still holding GPU context, pinned memory or fds.
A zombie has already had its resources freed by the kernel (only the exit
status lingers), so it counts as gone; NoSuchProcess / OSError (see
_wait_for_reap_or_raise) mean the same.
"""
alive = []
for p in procs:
try:
# 调用 psutil.Process.is_running() 会检查 /proc/<pid> 是否存在;
# status() 读取 /proc/<pid>/status,Zombie 状态表示进程已死但保留退出码。
# 只有真正还活着的非 Zombie 进程才视为仍持有资源。
if p.is_running() and p.status() != psutil.STATUS_ZOMBIE:
alive.append(p)
except (psutil.NoSuchProcess, OSError):
# 进程已消失或 pidfd_open 失败(如 EINVAL),视为已释放。
pass
return alive
def _wait_for_reap_or_raise(procs, wait_timeout: float) -> None:
"""Wait for `procs` to exit; warn at ~10s, raise on `wait_timeout`.
SIGKILL is asynchronous -- children hold GPU context, pinned memory and
fds until the kernel reaps them. Raise on timeout so a stuck process
surfaces instead of leaving a latent race.
Polls /proc via is_running()/status() rather than psutil.wait_procs, whose
os.pidfd_open path (used for non-child procs) raises OSError(EINVAL) against
a just-killed process on some kernels and aborts the whole wait.
"""
warn_at = min(10.0, wait_timeout / 2)
deadline = time.monotonic() + wait_timeout
warn_deadline = time.monotonic() + warn_at
warned = False
while True:
alive = _still_holding_resources(procs)
if not alive:
return
now = time.monotonic()
if now >= deadline:
raise RuntimeError(
f"kill_process_tree: {len(alive)} process(es) not reaped within "
f"{wait_timeout}s after SIGKILL; pids={[p.pid for p in alive]}"
)
if not warned and now >= warn_deadline:
logger.warning(
"kill_process_tree: %d process(es) still alive after %.1fs SIGKILL; "
"continuing to wait up to %.1fs total. pids=%s",
len(alive),
warn_at,
wait_timeout,
[p.pid for p in alive],
)
warned = True
# 短 sleep 避免忙等;轮询间隔 100ms 可接受。
time.sleep(0.1)
评论区精华
PR 没有 review 评论或讨论线程。开发者独立完成了分析和修复。
风险与影响
- 风险:
- 轮询开销:
_still_holding_resources 在 while True 循环中每 0.1 秒调用一次 is_running() 和 status(),在进程数量多时可能有一定 CPU 开销,但通常 kill_process_tree 的调用频率很低(仅测试清理场景)。
- 行为改变:新逻辑采用固定轮询间隔而非内核通知,可能导致释放检测延迟(最多 100ms),但绝大多数场景仍能正常工作。
psutil.wait_procs 被完全替换:原本 wait_procs 利用内核事件驱动,新实现为纯用户态轮询,但考虑到原行为在特定内核上根本不可用,这一 trade-off 可以接受。
- 僵尸进程处理:Zombie 被视为已释放资源(
status() == psutil.STATUS_ZOMBIE 时跳过),这是正确的——僵尸进程只保留 exit status,不持有 GPU 资源。
- 影响:影响范围仅限于 python/sglang/srt/utils/common.py 中的 _wait_for_reap_or_raise 和新增的 _still_holding_resources。影响用户为所有调用 kill_process_tree(wait_timeout=...) 的代码路径,主要是测试清理和进程管理场景。修复了在某些内核版本上测试 teardown 因 OSError(EINVAL) 意外失败的问题,提升测试稳定性和开发体验。
- 风险标记:缺少测试覆盖
关联脉络
参与讨论