LOGO 首页 OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 技术文档 其他文档  
 
网站管理员

一个端口只能被一个进程占用?如何实现端口重用?

admin
2026年11月13日 0:24 本文热度 262
写网络服务的人大概都遇到过这个报错:
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. 1. 保证最后一个 ACK 能被对方收到(若 ACK 丢了,对方会重发 FIN,需要能再次应答);
  2. 2. 让旧连接的迟到报文在网络里「死掉」,避免同一个四元组被新连接复用后收到残留数据。

麻烦在于:进入 TIME_WAIT 的 socket,其 本地IP:本地端口 二元组被锁定了。服务器快速重启时,新进程马上 bind 同一端口会撞上 TIME_WAIT 地址,报 Address already in use,必须等 60 秒。

这就是「重启服务器要等 1 分钟端口才能再用」这个经典现象的根源。

两个关键socket选项

针对「独占 bind」和「TIME_WAIT 锁」这两个问题,内核提供了两个 socket 选项:


解决什么问题
能否「同时」多进程监听
本质
SO_REUSEADDR
TIME_WAIT 等待
❌ 不能
先后复用(重启)
SO_REUSEPORT
独占绑定
✅ 能
同时共存(负载均衡/平滑切换)

  • • 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);
  • • 没开 → 走普通 net.Listen;
  • • 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
linux
SO_REUSEPORT
(0x0F) + TCP_DEFER_ACCEPT + TCP_FASTOPEN
tcplisten_bsd.go
darwin/freebsd/netbsd/openbsd
SO_REUSEPORT
(DEFER_ACCEPT/FASTOPEN 空实现 TODO)
reuseport_windows.go
windows
只有 SO_REUSEADDR
,注释明确「Windows 不支持 SO_REUSEPORT,用 SO_REUSEADDR 替代」
reuseport_aix.go
aix
SO_REUSEADDR
 + SO_REUSEPORT 两个都设

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 编辑过
关键字查询
相关文章
正在查询...
点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
Copyright 2010-2026 ClickSun All Rights Reserved  粤ICP备13012886号-9  粤公网安备44030602007207号