端口被占用却查不到进程:winnat 保留端口与进程树终止
先说数据源:别把 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 —— 即使只是想看看端口。
