Prhub

#50965 [Bugfix] Fix get_open_port() livelock on DP-reserved ports and cover get_open_ports_list

原始 PR 作者 aoshen02 合并时间 2026-08-07 22:39 文件变更 2 提交数 4 评论 2 代码增减 +115 / -8

执行摘要

修复 DP 端口保留段内 VLLM_PORT 死循环并补齐批量端口过滤

issue #50024 明确指出:设置 VLLM_PORT 为数据并行保留窗口内的值时,vllm serve --data-parallel-size N 会在启动时永久挂起且无任何日志或报错。根因是 get_open_port() 的 while True 循环在 VLLM_PORT 固定时每次得到同一端口,拒绝后重试同一端口,永不推进。issue 还指出 #36191 已为 get_open_ports_list 引入 start_port/max_attempts 机制,但 get_open_port 的调用点从未接上,修复机器就在同一文件内。

值得精读。第一,根因分析教科书式清晰:确定性输入 + 无状态重试 = 死循环,修复利用「升序扫描从区间末尾开始不可能再落入区间」的数学性质,用一次有界重扫替换整个循环,是低成本高收益的小规模设计决策。第二,测试中用守护线程 + 超时断言把「死循环回归」转化为「快速失败」,该模式可复用到其他 hang 类缺陷的回归测试。第三,多个并行 PR 解决同一问题时,通过 commit 保留原作者署名、在正文致谢未纳入方案的做法,是社区协作的良好示范。

讨论亮点

该 PR 几乎没有 review 技术交锋:claude[bot] 注明 fork 拉取请求自动 review 被禁用,需维护者手动触发;ZJY0516 直接 APPROVED,未留下技术质疑。真正的技术讨论集中在 PR body 的 "Relationship to existing PRs":作者明确披露 #50049(首个引入越过区间扫描思路与回归测试)、#50043(引入 max_attempts=1000 有界扫描)两个并行 PR 的贡献,以 commit 形式保留原作者署名;#50025 采用相同重扫思路但未纳入,作者在正文致谢。最终 commit 将控制流简化为「共享 helper + 一次越界重扫」,去掉循环与新增异常路径,并补齐 get_open_ports_list 过滤与测试。

实现拆解

修复按以下步骤落地:

  1. 新增共享 helper _get_reserved_port_range():将「数据并行主进程保留端口区间」的计算从 get_open_port() 中提取出来(vllm/utils/network_utils.py)。未设置 VLLM_DP_MASTER_PORT 时返回空 range(0),调用方无需处理 None,两个调用点共享同一语义。
  2. 重写 get_open_port() 控制流:去掉 while True 重试循环,改为「先取一个候选端口 → 判断是否落入保留区间 → 落入则从 reserved_port_range.stop 起调用 _get_open_port(start_port=..., max_attempts=1000) 重扫一次」。利用升序扫描从区间末尾开始不可能再次落入该区间的性质,一次有界重扫即可替代整个循环,同时覆盖确定性路径(VLLM_PORT 固定)和临时端口路径。
  3. get_open_ports_list() 补齐保留区间过滤:该函数的 VLLM_PORT 分支原本完全没有保留窗口过滤,可能把 DP master 要绑定的端口发出去,造成确定性的未来冲突。现在每轮扫描后同样做区间判断,命中则从区间末尾跳档重扫,并保持 next_port 推进逻辑不变。
  4. 测试配套tests/utils_/test_network_utils.py 新增 _call_with_timeout() 守护线程辅助函数,并加入 5 个回归测试:VLLM_PORT == VLLM_DP_MASTER_PORT(原死循环场景)、VLLM_PORT 位于区间内部、get_open_ports_list(5) 全部避开保留区间、临时端口落入区间时被重扫替换、未配置保留区间时行为不变。守护线程 + 超时断言保证若死循环回归,测试会快速失败而不是挂死整个套件。
文件 模块 状态 重要度
vllm/utils/network_utils.py 网络工具 modified 6.62
tests/utils_/test_network_utils.py 回归测试 modified 6.81

关键符号

_get_reserved_port_range get_open_port get_open_ports_list _call_with_timeout

关键源码片段

vllm/utils/network_utils.py core-logic

核心修复文件:新增 _get_reserved_port_range() 共享 helper,重写 get_open_port() 的 while True 重试循环为一次越界重扫,并为 get_open_ports_list() 的 VLLM_PORT 分支补齐此前缺失的 DP 保留区间过滤。

# vllm/utils/network_utils.py 核心修复片段
​
​
def _get_reserved_port_range() -> range:
    """计算数据并行主进程保留的端口区间(未设置时返回空 range)。    当前为 DP master 预留 10 个端口([VLLM_DP_MASTER_PORT, +10)),
    实际使用 2 个;返回空 range 可以让调用方免去 None 判断。
    """
    if "VLLM_DP_MASTER_PORT" not in os.environ:
        return range(0)
    dp_master_port = envs.VLLM_DP_MASTER_PORT
    return range(dp_master_port, dp_master_port + 10)
​
​
def get_open_port() -> int:
    """获取一个可绑定的端口,且避开 DP master 的保留窗口。    旧实现是 while True 重试:候选端口若落入保留区间会被拒绝,
    但 VLLM_PORT 固定时 _get_open_port() 每次返回同一个端口,于是死循环。
    新实现只做一次判断:从区间末尾(stop)重新扫描必然不会再落入区间,
    因此一次有界重扫即可替代整个重试循环。
    """
    reserved_port_range = _get_reserved_port_range()
    port = _get_open_port()
    if port in reserved_port_range:
        port = _get_open_port(start_port=reserved_port_range.stop, max_attempts=1000)
    return port
​
​
def get_open_ports_list(count: int = 5) -> list[int]:
    """批量获取唯一端口;VLLM_PORT 分支同样过滤 DP 保留窗口。"""
    ports_set = set[int]()
    if envs.VLLM_PORT is not None:
        reserved_port_range = _get_reserved_port_range()
        next_port = envs.VLLM_PORT
        for _ in range(count):
            port = _get_open_port(start_port=next_port, max_attempts=1000)
            if port in reserved_port_range:
                # 与 get_open_port 相同策略:从保留区间末尾继续扫描
                port = _get_open_port(
                    start_port=reserved_port_range.stop, max_attempts=1000
                )
            ports_set.add(port)
            next_port = port + 1
        return list(ports_set)
    else:
        while len(ports_set) < count:
            ports_set.add(get_open_port())
    return list(ports_set)

评论区精华

fork 自动审查被禁用,改为维护者直接审核 other

claude[bot] 指出本 PR 来自 fork,自动 review 被关闭;maintainer 可评论 @claude review 触发一次性审查。

结论:ZJY0516 未触发 bot review,直接 APPROVED。 · 已解决

与 #50049/#50043/#50025 并行方案的合并与署名 设计

PR body 披露三个并行 PR 解决同一 livelock;作者以 commit 形式保留 #50049、#50043 的原作者署名,并在最终 commit 中简化控制流、补齐 get_open_ports_list 过滤,同时致谢 #50025 的重扫思路但未将其作为 commit 带入。

结论:合并时保留两份原作者 credit,最终方案统一为「共享 helper + 一次越界重扫」。 · 已解决

风险与影响

行为变更集中在端口分配路径,具体风险点:

  • 未设置 VLLM_DP_MASTER_PORT 的用户_get_reserved_port_range() 返回空 range(0),任何端口都不在区间内,逻辑与原实现等价,回归风险低。
  • 端口选择结果可能变化:设置保留区间后,若候选端口命中区间,现在会跳档到区间末尾之后;这是修复意图,但依赖该端口分配行为的调用方(如显式端口绑定的部署脚本)可能观察到端口变化。
  • 边界行为VLLM_PORT 恰好等于 reserved_range.stop 时不在右开区间内,行为正常;若从 stop 起连续 1000 个端口均不可用,会按既有 _get_open_port 逻辑抛 RuntimeError,相比旧实现的无限循环是更可诊断的失败。
  • TOCTOU 竞态:bind 与返回之间端口仍可能被其他进程抢占,多进程并发启动时该问题存在,但非本次变更引入,测试也未覆盖并发场景。

用户侧:修复了 vllm serve --data-parallel-size NVLLM_PORTVLLM_DP_MASTER_PORT 冲突时静默挂死的启动缺陷——该问题在 0.26.0 镜像中可复现(挂起超过 120 秒无输出),端到端验证显示修复后 DP server 在一分钟内恢复健康。系统侧:改动仅限 vllm/utils/network_utils.py 的两个函数与一个共享 helper,不涉及模型执行、调度、注意力等核心路径,影响面窄;get_open_ports_list() 的端口分布可能变化,但方向是消除与 DP master 的确定性冲突。团队侧:已附带完整回归测试和端到端验证记录,维护成本低,后续合入风险可控。

核心启动路径变更 端口分配语义变化 回归测试覆盖充分

关联 Issue

#50024 [Bug]: get_open_port() hangs forever when VLLM_PORT falls inside the data parallel reserved port range

完整报告

参与讨论