热重启本质是父进程fork新进程并传递listener文件描述符,旧进程等待请求结束再退出;核心依赖syscall.exec、os.newfile和net.filelistener,需清除fd_cloexec标志并配超时context.shutdown。

热重启的本质是让新进程接管连接,旧进程优雅退出
Go 本身不提供“进程内热重启”,所谓热重启实际是父进程 fork 新进程、传递 listener 文件描述符、旧进程等连接关闭后退出。关键不是 reload 代码,而是避免连接中断和请求丢失。核心依赖 syscall.UnixRights、os.NewFile 和 net.FileListener 这套机制,不是靠第三方库“魔法”实现的。
用 net.Listener + syscall.SIGHUP 触发重启流程
监听 SIGHUP 是最轻量且符合 Unix 习惯的做法,但注意:不能直接在主 goroutine 中用 signal.Notify(ch, syscall.SIGHUP) 后立刻 exec.LookPath —— 这会阻塞服务,且没处理 listener 传递逻辑。正确路径是:
- 启动时用
os.Getpid()记录当前进程 ID,用于后续判断是否需要继承 listener - 收到
SIGHUP后,调用syscall.Exec(不是os/exec.Command)并传入已打开的listener.(*net.TCPListener).File() - 子进程启动后,通过环境变量(如
LISTENER_FD=3)或命令行参数识别是否应从 fd 恢复 listener,而不是重新 bind 端口 - 旧进程进入“drain 模式”:关闭 accept,等待现存连接
http.Server.Shutdown完成,再退出
http.Server.Shutdown 必须配超时,否则 hang 死
很多人写 srv.Shutdown(context.Background()),结果旧进程卡住不动——因为 context 没 deadline,而某些长连接(如 WebSocket、流式响应)可能永远不结束。必须设明确截止时间:
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil && err != http.ErrServerClosed {
log.Printf("shutdown error: %v", err)
}
30 秒是常见经验值,但需根据业务最长请求耗时调整;若存在未关闭的底层 TCP 连接(比如客户端不发 FIN),Shutdown 不会强制 kill,只能靠 OS keepalive 或主动 close conn。
文件描述符传递容易漏掉 inherit 标志
Linux 下 fd 默认不被 exec 继承,即使你用 file.Fd() 得到 3,子进程也读不到。必须显式设置:
- 调用
file.SetDeadline(time.Time{})前,先执行syscall.Syscall(syscall.SYS_FCNTL, uintptr(file.Fd()), syscall.F_SETFD, 0)清除FD_CLOEXEC - 或者更安全:用
syscall.UnixRights(int(file.Fd()))通过 Unix socket 发送 fd(适用于父子进程通信场景) - Go 1.19+ 可用
os.File.Sysfd获取原始 fd,但仍需手动清除 cloexec 标志
漏掉这步,子进程调用 os.NewFile(fd, "listener") 会返回 bad file descriptor 错误,且无明确提示。
真正难的是状态同步:配置变更、内存缓存、连接池重置这些不在 listener 范畴里的东西,得自己设计信号协作或外部协调。fd 传递只是热重启的入口,不是全部。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











