结论:os.exit() 不能做清理,defer 在信号退出时基本失效,唯一可靠路径是「显式通知 + 主动等待 + 同步协调」;signal.notify 必须用带缓冲 channel 并显式指定 syscall.sigint 和 syscall.sigterm。

直接说结论:os.Exit() 不能做清理,defer 在信号退出时基本失效,唯一可靠路径是「显式通知 + 主动等待 + 同步协调」。
signal.Notify 必须用带缓冲 channel 并明确指定信号
常见现象是 Ctrl+C 没反应,或容器里 kill -15 后进程秒退、没执行清理。根本原因不是 Go 不支持,而是信号被丢弃或压根没注册对。
-
sigCh := make(chan os.Signal, 1)—— 缓冲区至少为 1,无缓冲 channel 在接收前发信号会直接丢弃 - 必须显式传入
syscall.SIGINT和syscall.SIGTERM,不能只写os.Interrupt或留空(留空只监听默认的SIGINT和SIGQUIT) -
syscall.SIGKILL(即kill -9)无法捕获,也不该尝试——这是内核强制终止,任何用户态代码都收不到 - 别在
signal.Notify之后才启动业务逻辑(比如 HTTP server),否则程序一运行就卡在上
http.Server.Shutdown 必须配超时 context,且顺序不能错
很多服务看似调了 Shutdown(),但活跃连接仍被粗暴断开,日志里一堆 write: broken pipe。问题不在 Shutdown 本身,而在怎么用。
- 绝不能用
srv.Shutdown(context.Background())—— 这等于立刻关,不等任何请求完成 - 必须用
context.WithTimeout(ctx, 10*time.Second),超时值要大于最长业务处理时间(如文件上传、长轮询),但不宜设成几分钟,影响部署节奏 -
srv.Close()要在Shutdown()之前调用,否则新连接还能进来,违背“优雅停止”本意 -
Shutdown()只处理已有连接,不阻塞;真正阻塞的是你是否等它返回 —— 所以得把它放进 goroutine 或主流程里合理安排
goroutine 退出必须靠自己检查 done 信号,不能 kill
Go 没有 kill goroutine API,所有后台任务(定时器、日志 flush、消息轮询)都得主动退出。靠 defer 或 sync.Once 都不可靠,因为 goroutine 栈帧不会“返回”。
- 推荐统一用
done := make(chan struct{}),主 goroutine 收到信号后close(done),worker 里用select { case - 若需父子关系或超时控制,用
ctx, cancel := context.WithCancel(parentCtx),worker 监听ctx.Done(),主流程调cancel() - 绝对避免只轮询
ctx.Err() != nil—— 这会 CPU 空转;必须配合select+case - 已关闭的 channel 上再 send 会 panic,所有写操作(比如发日志、存状态)必须在
case分支内提前return
WaitGroup + 显式 cleanup 是唯一能落地的组合
很多人以为等所有 goroutine 自己退出就完了,结果临时文件残留、数据库连接未 close、goroutine 泄漏。根本问题是:没人确认它们真干完了清理。
- 每个长期运行的 goroutine 启动前必须
wg.Add(1),退出前必须wg.Done() - 信号到来后,先发退出通知(
close(done)或cancel()),再wg.Wait() - 真正的清理逻辑(如
os.RemoveAll(tempDir)、db.Close())必须放在wg.Wait()之后、main函数return之前 - 加超时保护更安全:
done := make(chan struct{}); go func() { wg.Wait(); close(done) }(); select { case
最容易被忽略的点是:信号可能快速连续到达(比如反复 Ctrl+C),而 signal.Notify 的 channel 是有缓冲的,若未及时消费,旧信号堆积会导致重复执行 shutdown 逻辑 —— 所以收到信号后应立即 signal.Stop(sigCh) 或重置监听,避免二次触发。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











