Go 小工具怎么优雅退出:信号处理与资源清理
一个跑在后台的小工具,收到 Ctrl+C 或者被 kill 的时候,最理想的状态是:把手上这段活干完,关掉连接,写一行日志,然后退出。默认行为是立刻死掉,什么都来不及做。
这篇记一下用标准库把这件事做对需要几步。
一、信号不是异常,别用 recover 去接
新手常见写法是在 main 里 defer recover(),指望它接住 Ctrl+C。接不住:信号是操作系统发给进程的,不是 Go 的 panic。
正确方式是 os/signal:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
NotifyContext 返回的 ctx 在收到信号时被取消,接下来所有支持 context 的操作都会跟着退出来。比手动注册 channel 干净,也不用自己写 select。
二、把 ctx 一路传下去
光有 ctx 不够,得让它真的能中断正在做的事。判断标准很简单:所有可能阻塞的调用,都应该收一个 ctx。
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
数据库、子进程、sleep 也一样。一项漏了,退出时就会卡在那一项上,等超时。
还有一个细节:time.Sleep 不能被打断,要换成下面这个:
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(d):
}
定时任务里这个很常见——退出时正好在退避等待,于是要等满 5 秒才退。
三、清理动作放在 defer 里,但要能被执行
defer 只在函数正常返回或 panic 时执行。如果最后走的是 os.Exit,defer 一律不跑。
这就是为什么 main 里不要直接 os.Exit,而是把逻辑收进一个函数:
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "错误:"+err.Error())
os.Exit(1)
}
}
run 里的 defer 正常执行,清理做完之后再决定退出码。
四、给清理留一个兜底时间
收到第一个信号后开始清理,这时候如果清理本身卡住了,进程会僵在那里。用户再按一次 Ctrl+C,期望的是「别管了,直接退」。
标准做法是第一个信号触发优雅退出,第二个信号强制退出。NotifyContext 之后 stop() 掉,信号就恢复默认行为:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-ctx.Done()
stop() // 之后再收到信号,就是默认的「立刻死」
}()
再配一个超时兜底,防止某些清理逻辑没有 context 可用:
select {
case <-done:
case <-time.After(10 * time.Second):
slog.Warn("清理超时,强制退出")
}
五、退出时写清楚「为什么退」
这一条和优雅无关,但同样重要。退出日志里至少要能看出是被信号打断的、还是任务失败的:
slog.Info("收到退出信号,开始清理")
...
slog.Info("清理完成,退出", "reason", ctx.Err())
定时任务的日志是事后看的。一条 exit 什么都没说,一条 exit reason=context canceled 能立刻区分「被人停了」和「自己崩了」。
六、小结
四件事:用 NotifyContext 接信号、把 ctx 传到底、清理放 defer 且避开 os.Exit、第二次信号强制退出。
代码量不大,但每一条漏掉,都会在实际运维时以「进程退不干净」的形式还回来,而且往往是在半夜。
- 点赞
- 收藏
- 关注作者
评论(0)