Go 优雅退出完全指南:signal.NotifyContext 实战
本文详细介绍 Go 语言中利用 signal.NotifyContext 实现优雅退出的标准实践,让你的程序在异常中断时不留"僵尸"进程或脏数据。
什么是优雅退出
优雅退出(Graceful Shutdown)是指程序在收到终止信号时,能够:
- 停止接收新请求
- 完成正在处理的任务
- 清理临时文件和资源
- 保存必要的状态数据
而不是直接 kill -9 强制终止,导致数据丢失或资源泄漏。
基本模式
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// 把 ctx 传给所有需要支持取消的操作
if err := doWork(ctx); err != nil {
if errors.Is(err, context.Canceled) {
log.Println("用户中断,已退出")
return
}
log.Fatal(err)
}
}
这段代码做了三件事:
signal.NotifyContext创建了一个可以被信号取消的 Context- 当收到
Ctrl-C(SIGINT)或kill(SIGTERM)时,Context 被取消 defer stop()确保释放信号监听资源
实际开发中的常见场景
场景 1:HTTP 服务器
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
srv := &http.Server{Addr: ":8080"}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务器异常: %v", err)
}
}()
<-ctx.Done() // 阻塞等待 Ctrl-C
// 给 5 秒让正在处理的请求完成
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("优雅退出失败: %v", err)
}
log.Println("服务已优雅退出")
}
执行流程:
1. 启动 HTTP 服务器(后台运行)
2. <-ctx.Done() 阻塞等待信号
3. 收到 Ctrl-C → 解除阻塞
4. 调用 srv.Shutdown() 停止接收新请求
5. 等待已有请求处理完成(最多 5 秒)
6. 退出程序
场景 2:数据库批量任务
func processBatch(ctx context.Context, items []Item) error {
for _, item := range items {
// 每次循环检查是否被取消
select {
case <-ctx.Done():
return ctx.Err() // 立即返回,不处理剩余 item
default:
}
if err := processItem(ctx, item); err != nil {
return err
}
}
return nil
}
使用方式:
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
items := loadItems()
if err := processBatch(ctx, items); err != nil {
if errors.Is(err, context.Canceled) {
log.Printf("任务被取消,已处理 %d 项", processedCount)
return
}
log.Fatalf("处理失败: %v", err)
}
}
效果:用户按 Ctrl-C 后,当前 item 处理完毕就停止,不继续处理剩余数据。
场景 3:带清理的长时间运行任务
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
// 初始化资源
tempDir, err := os.MkdirTemp("", "app-*")
if err != nil {
log.Fatal(err)
}
db, err := connectDB()
if err != nil {
os.RemoveAll(tempDir)
log.Fatal(err)
}
// 注册清理函数
go func() {
<-ctx.Done()
// Ctrl-C 后执行清理
log.Println("开始清理资源...")
os.RemoveAll(tempDir)
db.Close()
log.Println("清理完成")
}()
// 运行长时间任务
runLongTask(ctx)
}
关键规则
规则 1:ctx 要一路往下传
规则 2:耗时操作都用带 ctx 的版本
// HTTP 请求
✅ req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
❌ req, _ := http.NewRequest("GET", url, nil)
// 执行命令
✅ exec.CommandContext(ctx, "ffmpeg", "-i", input, output)
❌ exec.Command("ffmpeg", "-i", input, output)
// 定时器
✅ select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return ctx.Err()
}
❌ time.Sleep(5 * time.Second) // 无法取消
规则 3:区分用户中断和真正报错
if err := doWork(ctx); err != nil {
// 用户按了 Ctrl-C,不是程序错误
if errors.Is(err, context.Canceled) {
log.Println("用户中断,已退出")
return
}
// 真正的错误
log.Fatalf("执行失败: %v", err)
}
信号说明
signal.NotifyContext(ctx,
os.Interrupt, // Ctrl-C (SIGINT)
syscall.SIGTERM, // kill 命令、systemd 停服务
// syscall.SIGQUIT, // Ctrl-\ 带 goroutine 堆栈转储(用于调试)
)
| 信号 | 触发方式 | 用途 |
|---|---|---|
| SIGINT | Ctrl-C | 用户主动中断 |
| SIGTERM | kill <pid> | 外部请求终止 |
| SIGQUIT | Ctrl-\ | 调试时导出堆栈 |
常见错误
错误 1:Context 未传递
// 错误示例
func main() {
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
go func() {
doWork() // ctx 没传进去
}()
}
// 正确示例
func main() {
ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
go func() {
doWork(ctx) // ctx 传递进去
}()
}
错误 2:忽略了 ctx.Done()
// 错误示例
func processLoop(ctx context.Context) {
for {
doSomething()
time.Sleep(time.Second) // 永远无法响应取消
}
}
// 正确示例
func processLoop(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
doSomething()
}
}
总结
signal.NotifyContext 是 Go 服务端开发的标配:
- 简单直接:一行代码实现信号监听
- 标准模式:与 Go 的 context 体系无缝集成
- 资源安全:确保优雅退出,不留僵尸进程或脏数据
掌握这个模式,让你的程序在异常中断时也能安全、体面地退出。
参考资料:Go Blog - Contexts and structs