← 返回笔记列表

端口被占用却查不到进程:winnat 保留端口与进程树终止

发布于 来自 Kite

  • 工程笔记
  • Panda Port Manager
  • Windows
  • 排障

先说数据源:别把 netstat 当唯一真相

netstat -ano 几乎是所有人的第一反应,但它的输出是本地化的、按空格切分的、随版本变化的 (旧版甚至把 TIME_WAIT 打印成带空格的 TIME WAIT)。 更好的做法是结构化 API 为主、netstat 只补它唯一没有的那一项 —— PID:

var props = IPGlobalProperties.GetIPGlobalProperties();

foreach (var ep in props.GetActiveTcpListeners())
    entries.Add(new PortEntry("TCP", ep.Address.ToString(), ep.Port, "-", 0, "LISTENING", 0));

foreach (var c in props.GetActiveTcpConnections())
    entries.Add(new PortEntry("TCP", c.LocalEndPoint.Address.ToString(), c.LocalEndPoint.Port,
        c.RemoteEndPoint.Address.ToString(), c.RemoteEndPoint.Port,
        c.State.ToString().ToUpperInvariant(), 0));

// netstat -ano 只用来拿 PID(IPGlobalProperties 不返回 PID)
MapPidsFromNetstat(entries);

地址、端口、状态来自 API,可靠性与语言无关;子进程与文本解析的成本被压缩到 PID 一项。

有一个边界要认:ReadToEnd() 在 WaitForExit(5000) 之前执行, 所以那个 5 秒并不是严格的超时上限 —— 输出流不结束时,ReadToEnd 自己就会先阻塞。

双栈监听要合并,否则列表成双成对

同一个服务同时监听 0.0.0.0 和 :: 是非常常见的,逐条显示会让人以为「两个进程占了同一个端口」。 按「协议 + 端口 + PID」分组合并:

var groups = entries
    .Where(e => e.State == "LISTENING" && e.RemotePort == 0)
    .GroupBy(e => (e.Protocol, e.LocalPort, e.Pid))
    .Where(g => g.Count() > 1)
    .ToList();

// 地址显示为 0.0.0.0 / ::,并继承组内最早的「首次发现时间」
var addrs = string.Join(" / ", g.Select(x => x.LocalAddr).Distinct());
var merged = rep with { LocalAddr = addrs };

这里还有个容易漏的点:「首次发现时间」要跟着条目走,而不是每次刷新时间。 否则一个端口跑了一小时,列表里永远显示「1 秒前出现」, 而这个字段恰恰是判断「是不是刚刚被那个新起的进程占了」的线索。

情况二:PID 是 0,根本没有进程

最常见的原因是 winnat:Hyper-V / WSL / Docker 会向系统申请一批保留端口, 这批端口不属于任何进程,netstat 自然查不到主人。 不要硬编码常见的保留段,直接问 Windows 自己的口径:

// netsh interface ipv4 show excludedportrange protocol=tcp
foreach (var raw in output.Split('\n')) {
    var m = Regex.Match(raw, @"^\s*(\d+)\s+(\d+)\s*$");
    if (m.Success
        && int.TryParse(m.Groups[1].Value, out int start)
        && int.TryParse(m.Groups[2].Value, out int end)
        && port >= start && port <= end)
        return true;
}

命中之后唯一的释放方式是重启 winnat(net stop winnat → net start winnat,各等 15 秒)。 代价是 WSL、Docker、Hyper-V 的 NAT 网络会短暂中断,因此这一步必须由用户显式确认, 不能自动做。这也是这个工具必须以管理员运行的原因之一。

情况一:PID 后面是一整棵进程树

很多开发服务不是「一个进程」:dotnet watch、npm run dev、Java 的 wrapper 都是 「父进程占着端口 + 若干子进程」。只杀那个 PID,子进程可能重新拉起一个父进程, 端口又被占回去。所以结束动作要按进程树:

using var proc = Process.GetProcessById(pid);
proc.Kill(entireProcessTree: true);

刻意的选择是只做强杀、不给优雅关闭选项:能占着端口不放的进程, 通常也已经不响应关闭信号了。代价写在使用条款里 —— 未保存的数据会丢。 强杀之后延迟 800ms 再刷新,给内核时间回收 socket。

一个小而真实的 UI 教训

批量结束时要按 PID 去重:同一个进程可能占着多条连接, 用户框选了三行其实是一个进程,第二次杀同一 PID 会算成「失败」, 弹一个莫名其妙的错误。本质是「UI 的行模型」和「进程实体模型」不是一回事。

现在的形态

端口与进程一屏看完(暗色)

已知边界

  • 只查了 IPv4 的排除端口范围,没有对应查 IPv6;
  • PID 映射键没有包含远程地址(不是完整五元组),理论上可能碰撞;
  • 刷新在 UI 线程同步执行,连接量很大时窗口会短暂失去响应;
  • 每次启动都要 UAC —— 即使只是想看看端口。