不能直接用 dup() 迁移监听套接字,因为 Go 运行时 netpoll 不监控外部 dup 的 fd,导致 accept 阻塞或 panic;必须先关闭原 listener,再通过 SCM_RIGHTS 传递 fd,并由子进程用 os.NewFile + net.FileListener(或更稳妥的手动 socket 重建)恢复监听。

为什么不能直接用 dup() 迁移监听套接字
Go 程序里想把一个正在监听的 net.Listener 从主进程“交给”子进程(比如热重启),很多人第一反应是用 dup() 复制文件描述符。但这样会失败——Go 的 net/http.Server 内部持有对原始 fd 的引用和状态管理,单纯复制 fd 后调用 net.FileListener 构造出的新 listener 无法与 Go 运行时的网络轮询器(netpoll)协同工作,常见表现为 accept 阻塞、连接丢失或 panic:accept: invalid argument。
根本原因是:Go 的 runtime.netpoll 只跟踪它自己打开或接管的 fd,外部 dup 出来的 fd 不在它的监控列表里,也不会触发 goroutine 唤醒。
- 必须让 Go 运行时“认领”这个 fd,即通过
syscall.RawConn.Control()或net.FileConn+syscall.Dup()+net.FileListener组合,但仅限于 *已关闭* 的 listener 上游 fd(见下一点) - 迁移前,原 listener 必须已调用
Close(),否则 fd 被双重管理,竞争风险极高 - 子进程需用
os.NewFile()包装传入的 fd,再转成net.Listener,且不能复用原 listener 的地址结构(如tcpAddr)
如何安全地把监听 fd 传递给子进程
核心路径是:父进程先 syscall.Dup() 监听 fd → 用 os.NewFile() 封装 → 通过 Unix 域 socket 的 SCM_RIGHTS 控制消息发给子进程 → 子进程接收并构造 net.Listener。Go 标准库不直接暴露该流程,得手写 syscall.UnixRights() 和 unix.Recvmsg()。
关键约束:fd 传递必须发生在 listener 关闭之后,且父子进程需约定好 fd 编号(通常用 3 起始的最小可用 fd)。
- 父进程调用
l.Close()后立即syscall.Dup(int(l.(*net.TCPListener).File().Fd()))获取副本 - 用
unix.Sendmsg()把 fd 发到预先建立的 Unix socket,注意设置unix.SCM_RIGHTS控制消息 - 子进程用
unix.Recvmsg()接收,并检查len(oob) > 0和unix.ParseSocketControlMessage()解析 fd - 拿到 fd 后:
os.NewFile(uintptr(fd), "listener")→net.FileListener()→ 成功启动新 server
net.FileListener 的实际限制与绕过方式
net.FileListener 要求传入的 *os.File 必须是监听态的 socket,但它内部会尝试调用 getsockopt(SO_TYPE) 和 getsockname()。如果 fd 是从父进程 dup 来的,且父进程已 close 原 fd,子进程的 net.FileListener 仍可能因内核 socket 状态残留而失败。
更稳妥的做法是:子进程不依赖 net.FileListener,而是用 syscall.Socket() 重建 socket 结构,再用 syscall.SetsockoptInt() 恢复原监听配置(如 SO_REUSEADDR),最后 syscall.Bind() + syscall.Listen() —— 但这要求父进程把监听地址、端口、协议族等元信息也一并传过去。
-
net.FileListener仅支持 TCP/Unix,不支持 UDP 或 IP raw socket - 若监听的是
localhost:8080,子进程用net.FileListener构造后,Addr().String()返回的是"[::]:8080"而非原地址,业务逻辑若强依赖 host 字段会出错 - 推荐组合:
os.NewFile()+syscall.Getsockname()手动读取绑定地址,再用&net.TCPAddr{IP: ..., Port: ...}构造伪Addr()方法返回值
热重启中容易被忽略的 socket 选项同步
监听 fd 迁移后,子进程的 socket 默认不继承父进程设置的选项,比如 SO_REUSEPORT(Linux)、SO_LINGER、TCP_DEFER_ACCEPT。这些缺失会导致连接行为突变:TIME_WAIT 暴增、延迟 accept、甚至无法 bind 到相同端口。
必须显式在子进程中恢复关键选项。最简方案是在父进程 close listener 前,用 syscall.GetsockoptInt() 读取所有相关选项值,随 fd 一起传给子进程。
-
SO_REUSEADDR和SO_REUSEPORT必须设置,否则子进程 bind 可能失败(尤其多 worker 场景) -
TCP_NODELAY和TCP_KEEPALIVE虽然属于连接级选项,但部分内核版本会在 listen socket 上影响新连接初始状态 - Go 的
net.ListenConfig不支持传入原始 fd 选项,只能靠syscall.SetsockoptInt()手动设置
真正麻烦的不是迁移本身,而是父子进程间 socket 状态、选项、地址语义的一致性——少一个 SetsockoptInt(syscall.SOL_SOCKET, syscall.SO_REUSEPORT, 1),热重启后就可能丢连接。


















