跳转至

Go 优雅退出完全指南:signal.NotifyContext 实战

本文详细介绍 Go 语言中利用 signal.NotifyContext 实现优雅退出的标准实践,让你的程序在异常中断时不留"僵尸"进程或脏数据。

什么是优雅退出

优雅退出(Graceful Shutdown)是指程序在收到终止信号时,能够:

  1. 停止接收新请求
  2. 完成正在处理的任务
  3. 清理临时文件和资源
  4. 保存必要的状态数据

而不是直接 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)
    }
}

这段代码做了三件事:

  1. signal.NotifyContext 创建了一个可以被信号取消的 Context
  2. 当收到 Ctrl-C(SIGINT)或 kill(SIGTERM)时,Context 被取消
  3. 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 要一路往下传

// 正确:ctx 传递到所有需要支持取消的操作
 db.QueryContext(ctx, sql)

// 错误:丢弃 ctx,无法取消
 db.Query(sql)

规则 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 服务端开发的标配:

  1. 简单直接:一行代码实现信号监听
  2. 标准模式:与 Go 的 context 体系无缝集成
  3. 资源安全:确保优雅退出,不留僵尸进程或脏数据

掌握这个模式,让你的程序在异常中断时也能安全、体面地退出。


参考资料:Go Blog - Contexts and structs

评论