正确做法是让主goroutine主动退出并在最后调用os.exit():监听sigint/sigterm,通过select接收信号后跳出循环,执行defer清理并以指定非零码退出。

Go 程序如何正确捕获 SIGINT 和 SIGTERM 并返回非零退出码
Go 程序被 kill -15 或 Ctrl+C 中断时,默认会以状态码 2 退出,但你无法控制这个值——除非显式调用 os.Exit()。直接在信号处理函数里写 os.Exit(1) 看似简单,实际会跳过 defer、资源清理和 main 函数收尾逻辑。
正确做法是让主 goroutine 主动退出,并在最后统一调用 os.Exit():
func main() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
defer cleanup() // 这里能执行
select {
case <-sigChan:
fmt.Println("received signal, exiting...")
os.Exit(1) // ← 放在这里才安全
}
}
- 必须用
signal.Notify()注册信号通道,不能只靠os.Interrupt(它只是SIGINT的别名) -
os.Exit()必须在main()函数内调用,否则会 panic;在 goroutine 里调用无效 - 不要在信号 handler 里启动新 goroutine 去调用
os.Exit(),容易竞态或漏执行
为什么 log.Fatal() 不适合做信号退出
log.Fatal() 内部调用了 os.Exit(1),但它还会先输出日志并加换行,问题在于:它不等 defer,也不等其他 goroutine 结束,且退出码固定为 1,无法自定义。
- 如果你需要退出码为 128+130(即
SIGINT对应的 shell 语义),log.Fatal()完全做不到 - 它会绕过所有
defer清理逻辑,比如未关闭的文件句柄、未 flush 的缓冲日志 - 常见误用:
go func() { log.Fatal("signal") }()—— 这根本不会终止主程序
如何让不同信号对应不同退出码(如 SIGQUIT → 130)
POSIX 规定:shell 中命令因信号终止时,退出码 = 128 + 信号编号。Go 默认不遵守这点,但你可以手动映射。
立即学习“go语言免费学习笔记(深入)”;
sigToExitCode := map[syscall.Signal]int{
syscall.SIGINT: 130,
syscall.SIGTERM: 143,
syscall.SIGQUIT: 131,
}
select {
case s := <-sigChan:
code := sigToExitCode[s]
fmt.Printf("exiting with code %d (%v)\n", code, s)
os.Exit(code)
}
- 查信号编号用
syscall.SIGxxx,不是字符串;Linux 下kill -l可看编号 - 注意
SIGKILL(9)和SIGSTOP(19)无法被捕获,任何注册都无效 - macOS 和 Linux 的信号编号基本一致,但 Windows 不支持 POSIX 信号语义,这类逻辑需条件编译
goroutine 泄漏导致 os.Exit() 前卡住?
Go 的 os.Exit() 是立即终止进程,不等待 goroutine 返回。但如果你在 main() 里启动了长期运行的 goroutine(比如 HTTP server),又没做超时或主动 shutdown,用户可能看到“程序没退出”的假象——其实是主 goroutine 早结束了,但后台 goroutine 还在打印日志或重试连接。
- HTTP server 应配合
srv.Shutdown(),并在 context 超时后调用os.Exit() - 用
pprof或runtime.NumGoroutine()检查是否真有 goroutine 残留 - 不要依赖
time.Sleep()等待 goroutine 结束——os.Exit()不会等它
退出码这件事,表面是数字,背后是信号语义、资源生命周期和跨平台兼容性。最容易被忽略的是:你以为程序退出了,其实只是主 goroutine 结束了,而一堆协程还在后台跑着打日志,还占着端口。










