Address already in use(地址已被占用)
通常发生在快速重启服务的时候——旧进程刚关,端口还「没还干净」,新进程就 bind 不上去了,得干等一分钟。
但是:同一台机器上,Nginx 的好几个 worker 进程、Go 程序的多个 goroutine,却能同时监听同一个端口,互不打架。 一边是「端口被占了」,一边是「大家都能用」,到底差在哪?
答案是 端口重用(SO_REUSEPORT) 。下面先介绍这个概念,再看 INFINI Gateway 是怎么用它做到「零停机热更新」的。
TCP 连接靠「四元组」区分
一个 TCP 连接由四元组唯一定位:
(源 IP,源端口,目的 IP,目的端口)
- • 监听 socket(服务器端)绑定的是「本地 IP + 本地端口」,等待客户端连接;
- • 已建立连接(established)由完整四元组区分。即使 1000 个客户端都连到服务器的
:8000,它们的「源端口」各不相同,所以互不冲突。
这就是为什么一个服务器端口能同时服务成千上万个客户端——因为连接是被四元组区分的,而不是只靠服务器端口。理解这一点,是理解端口复用的钥匙。
而端口绑定默认是独占的:进程调用 bind() 绑定 IP:Port 时,另一个进程再 bind 同一个地址就会报 Address already in use。端口重用,就是要打破这个「独占」默认,让 bind 同一地址从报错变成合法。
确认TIME_WAIT
TCP 关闭连接是四次挥手。主动关闭的一方(通常是服务器调 close())在发送最后一个 ACK 后,会进入 TIME_WAIT 状态,持续约 2MSL(Maximum Segment Lifetime,Linux 默认 60 秒)。
它存在有两个目的:
- 1. 保证最后一个 ACK 能被对方收到(若 ACK 丢了,对方会重发 FIN,需要能再次应答);
- 2. 让旧连接的迟到报文在网络里「死掉」,避免同一个四元组被新连接复用后收到残留数据。
麻烦在于:进入 TIME_WAIT 的 socket,其 本地IP:本地端口 二元组被锁定了。服务器快速重启时,新进程马上 bind 同一端口会撞上 TIME_WAIT 地址,报 Address already in use,必须等 60 秒。
这就是「重启服务器要等 1 分钟端口才能再用」这个经典现象的根源。
两个关键socket选项
针对「独占 bind」和「TIME_WAIT 锁」这两个问题,内核提供了两个 socket 选项:
- •
SO_REUSEADDR:允许新 socket 绑到 TIME_WAIT 状态的地址,跳过 2MSL 等待,典型场景是快速重启。它不允许多进程「同时」监听同一端口,是「先后复用」。 - •
SO_REUSEPORT:允许多个 socket 同时绑到完全相同的 IP:Port,内核把它们组成「监听组」,把新连接按哈希负载均衡分发到组内某个 socket。分发粒度是「连接」(不是请求),按四元组哈希,保证同一连接始终落在同一 socket。
REUSEADDR 解决「先后复用」,REUSEPORT 解决「同时共存」。
(补充一个易混点:UDP 里 SO_REUSEADDR 配合 SO_REUSEPORT 能让多个 socket 同时收广播/多播,那是另一套语义,与 TCP 不同,这里不展开。)
Gateway 应用 SO_REUSEPORT
gateway 用 SO_REUSEPORT(封装在 framework 库的 reuseport 包),默认全局开启,配置热更新的零停机切换。
配置层:一个 *bool 指针的设计巧思
端口重用开关是 NetworkConfig.ReusePort,定义在 framework/core/config/system.go:75:
type NetworkConfig struct {
SkipOccupiedPort bool `config:"skip_occupied_port"`
// ReusePort 为 nil 表示「未配置」,用于区分「未设置」和「显式 false」
ReusePort *bool `config:"reuse_port"`
}
注意它是 *bool(指针),不是 bool。这个设计很关键:指针能区分三种状态——nil(没配)、&true(显式开)、&false(显式关)。而补默认值的逻辑只对 nil 生效,不覆盖用户的显式选择:
// proxy/gateway.go:397
func applyDefaultReusePort(cfg *common.EntryConfig, disableReuseByDefault bool) {
if cfg.NetworkConfig.ReusePort == nil { // 只处理「未配置」的
reuse := !disableReuseByDefault // 默认 = 不 disable,即开启
cfg.NetworkConfig.ReusePort = &reuse
}
}
gateway.yml 里对应配置:
gateway:
disable_reuse_port_by_default: false # false = 所有 entry 默认开启 reuse_port
entry:
- name: my_es_entry
network:
binding: $[[env.GW_BINDING]] # 0.0.0.0:8000
reuse_port: true # 单 entry 显式开启
容易踩坑:reuse_port 与 skip_occupied_port互斥,同时开启直接报错——前者靠 SO_REUSEPORT 共存,后者靠「自动换一个空闲端口」绕开占用,两种策略语义矛盾。
监听层:核心分支
Entrypoint.Start() 里决定用哪种方式创建 listener(proxy/entry/entry.go:248-270):
var ln net.Listener
var err error
if this.config.NetworkConfig.ReusePortEnabled() {
// 根据监听地址是 IPv4 还是 IPv6,决定用 tcp4 / tcp6
network := "tcp4"
if host, _, err := net.SplitHostPort(this.listenAddress); err == nil {
if ip, err := netip.ParseAddr(host); err == nil && ip.Is6() {
network = "tcp6"
}
}
ln, err = reuseport.Listen(network, this.config.NetworkConfig.GetBindingAddr())
} else {
ln, err = net.Listen("tcp", this.listenAddress)
}
- • 开了
reuse_port → 走 reuseport.Listen(底层设 SO_REUSEPORT); - •
reuseport.Listen 只支持 tcp4/tcp6,所以要先按地址族判断 network 类型。
用途:三阶段零停机切换
这是 gateway 里 SO_REUSEPORT 的核心价值——不是为了「多进程长期共享」,而是为了配置变更时不停机。
启动完成后,gateway.go:417 调 handleConfigureChange() 监听配置变化。当 entry 配置变更时,新旧 entry 按 reuse_port 分成两组,用三阶段切换:
- •
reuse_port=false 的 entry:必须先停旧的再启新的(否则 "address already in use"),切换瞬间有短暂中断; - •
reuse_port=true 的 entry:新 listener 靠 SO_REUSEPORT 和旧 listener 并存,实现「先启动新、再停旧」的零停机切换。
这正是 gateway 默认开启 reuse_port 的原因:让配置热更新不丢连接。
理论上,这样也可以支持单机上部署多个gateway,监听相同的端口,有兴趣可以验证一下,这样可以提高服务器的资源利用率
底层实现:没用 Go 标准库,手工走 socket
reuseport.Listen 只是 tcplisten 的一层薄封装(framework/lib/fasthttp/reuseport/reuseport.go:35):
func Listen(network, addr string) (net.Listener, error) {
ln, err := cfg.NewListener(network, addr)
if err != nil && strings.Contains(err.Error(), "SO_REUSEPORT") {
return nil, &ErrNoReusePort{err}
}
return ln, err
}
var cfg = &tcplisten.Config{
ReusePort: true, // SO_REUSEPORT —— 端口复用核心
DeferAccept: true, // TCP_DEFER_ACCEPT —— 等有数据才 accept
FastOpen: true, // TCP_FASTOPEN —— 快速握手
}
一次性开了 3 个性能选项,不只是端口复用。
tcplisten.NewListener 没有走 net.Listen,而是手动执行整个 socket 生命周期:
socket(SOCK_STREAM)
→ setsockopt(SO_REUSEADDR=1) # 快速复用 TIME_WAIT
→ setsockopt(TCP_NODELAY=1) # 关 Nagle
→ setsockopt(SO_REUSEPORT=1) # ★ 端口复用核心
→ setsockopt(TCP_DEFER_ACCEPT=1) # 等有数据才 accept
→ setsockopt(TCP_FASTOPEN=1) # TFO 快速握手
→ bind(addr) → listen(somaxconn)
→ net.FileListener 包回 net.Listener
为什么要手工走系统调用?因为 Go 标准库的 net.Listen 只在内部设 SO_REUSEADDR,不暴露 SO_REUSEPORT。要复用端口,就必须在 bind() 之前自己设好 SO_REUSEPORT,所以只能手工控制 socket 创建全过程。
完整调用链:
socket 选项的设置顺序:
平台差异
① SO_REUSEPORT 在 Linux 是魔法数字 0x0F——tcplisten_linux.go:15 里硬编码了 const soReusePort = 0x0F,因为 Linux 当年 syscall 包没导出该常量;BSD 系则直接 syscall.SO_REUSEPORT。
② backlog 从内核读取——soMaxConn() 读 /proc/sys/net/core/somaxconn,超过 65535 截断(Linux backlog 是 uint16);fastopen 队列 fastOpenQlen = 16*1024。
③ 平台差异是关键,framework 库按编译平台分文件实现:
| | |
tcplisten_linux.go | | SO_REUSEPORT(0x0F) + TCP_DEFER_ACCEPT + TCP_FASTOPEN |
tcplisten_bsd.go | darwin/freebsd/netbsd/openbsd | SO_REUSEPORT(DEFER_ACCEPT/FASTOPEN 空实现 TODO) |
reuseport_windows.go | | 只有 SO_REUSEADDR,注释明确「Windows 不支持 SO_REUSEPORT,用 SO_REUSEADDR 替代」 |
reuseport_aix.go | | SO_REUSEADDR |
Windows 上「名存实亡」
这是最值得警惕的一点——Windows 上 reuse_port: true 实际退化成 SO_REUSEADDR,语义已经变了:不是内核负载均衡的多进程共享,只是 TIME_WAIT 快速复用。
gateway.yml 里 disable_reuse_port_by_default 的注释也印证了这点:「If the host doesn't support SO_REUSEPORT, set true to disable this behavior」。
也就是说:在 Linux(注意版本) 上部署才有真正的端口复用与零停机切换;在 Windows 上 reuse_port 名存实亡,只是把选项「模拟」出来避免报错。
总结
回到开头那个问题:一个端口不是「只能被一个进程占用」,而是「默认被独占」。SO_REUSEPORT 打破了独占——它让多个 socket 同时绑到同一个 IP:Port,内核按四元组哈希把连接负载均衡地分发出去,所以 Nginx 的多个 worker、Go 的多个 listener 才能共享一个端口。
gateway 的 reuse_port 走的是这条链路:reuse_port(*bool 指针区分三态)→ framework/reuseport.Listen → valyala/tcplisten 手工 socket + setsockopt(SO_REUSEPORT) + bind + listen。在 Linux 上它真正实现了内核级端口复用与负载均衡,服务于配置热更新的零停机切换;在 Windows 上则退化为 SO_REUSEADDR。
理解端口复用,本质是理解两件事:连接靠四元组区分,以及「先后复用」和「同时共存」是两码事。搞清这两点,SO_REUSEADDR 和 SO_REUSEPORT 就不会再混了。
欢迎点个「赞」+「转发」+「关注」。
参考来源:INFINI Gateway / framework 官方源码,valyala/tcplisten
阅读原文:原文链接
该文章在 2026/11/13 12:45:50 编辑过