目录

Go 程序如何优雅退出?实战方法与代码示例

一、为什么需要优雅退出?

很多人觉得“程序退出就是停掉进程”,但对后端服务来说,粗暴退出会引发一系列问题。在生产环境中,“怎么退出”直接影响系统稳定性。

简单来说,优雅退出是指程序在收到退出信号后,不立即终止,而是先完成一系列准备工作再安全退出,核心要做这几件事:

  • 停止接收新的请求或任务(比如关闭HTTP服务监听);

  • 等待正在执行的任务(比如正在处理的API请求、数据计算)完成;

  • 释放占用的资源(比如数据库连接、Redis连接、文件句柄、网络连接);

  • 关闭正在运行的Goroutine,避免内存泄漏。

与之相对的“强制退出”(比如 kill -9 命令)会直接终止进程,上述工作全都会被跳过,轻则导致资源泄漏、数据丢失,重则可能引发数据不一致(比如正在写入数据库的事务被中断)。所以,只要是生产环境运行的Go程序,优雅退出都是必做的优化项。

二、核心原理

基于信号监听实现优雅退出

Go程序实现优雅退出的核心是信号监听

操作系统会通过信号与进程交互,比如我们常用的 Ctrl+C 其实就是向进程发送 SIGINT 信号,kill 命令默认发送 SIGTERM 信号。Go的标准库 os/signal 提供了监听信号的能力,我们可以通过它捕获退出信号,然后执行自定义的退出逻辑。

常见的退出信号有两种,开发中重点处理这两个即可:

  1. SIGINT(中断信号):由 Ctrl+C 触发,常用于手动终止程序;

  2. SIGTERM(终止信号):由 kill 命令触发,常用于服务编排工具(如 Kubernetes)自动停止程序。

三、基础版

信号监听与资源释放

先实现一个基础版本:监听 SIGINT 和 SIGTERM 信号,收到信号后关闭数据库连接并退出。这里以 MySQL 连接为例,使用 GORM 作为ORM框架,代码如下:

package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "time"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

// 全局数据库连接
var db *gorm.DB

// initDB 初始化数据库连接
func initDB() error {
    dsn := "root:123456@tcp(127.0.0.1:3306)/test_db?charset=utf8mb4&parseTime=True&loc=Local"
    var err error
    db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        return fmt.Errorf("init db failed: %v", err)
    }
    // 获取底层sql.DB连接,用于关闭
    sqlDB, err := db.DB()
    if err != nil {
        return fmt.Errorf("get sql db failed: %v", err)
    }
    // 设置连接池参数
    sqlDB.SetMaxOpenConns(10)
    sqlDB.SetMaxIdleConns(5)
    sqlDB.SetConnMaxLifetime(30 * time.Minute)
    fmt.Println("init db success")
    return nil
}

// closeResource 释放资源
func closeResource() {
    fmt.Println("start closing resource...")
    // 关闭数据库连接
    if db != nil {
        sqlDB, err := db.DB()
        if err != nil {
            fmt.Printf("get sql db failed when closing: %v\n", err)
            return
        }
        if err := sqlDB.Close(); err != nil {
            fmt.Printf("close db failed: %v\n", err)
        } else {
            fmt.Println("close db success")
        }
    }
    fmt.Println("all resource closed")
}

// listenSignal 监听退出信号
func listenSignal() {
    // 创建信号通道,缓冲1个信号
    sigChan := make(chan os.Signal, 1)
    // 监听SIGINT和SIGTERM信号
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
    // 阻塞等待信号
    sig := <-sigChan
    fmt.Printf("\nreceive signal: %v\n", sig)
}

func main() {
    // 1. 初始化资源
    if err := initDB(); err != nil {
        fmt.Printf("init resource failed: %v\n", err)
        os.Exit(1)
    }

    // 2. 模拟业务运行(这里用无限循环模拟)
    fmt.Println("service is running, press Ctrl+C to exit...")
    go func() {
        for {
            fmt.Println("handling business...", time.Now().Format("2006-01-02 15:04:05"))
            time.Sleep(2 * time.Second)
        }
    }()

    // 3. 监听退出信号
    listenSignal()

    // 4. 收到信号后释放资源
    closeResource()

    fmt.Println("service exit gracefully")
}

运行程序后,按 Ctrl+C 触发 SIGINT 信号,控制台会输出:收到信号、关闭数据库连接、优雅退出的日志,避免了数据库连接泄漏。

四、进阶版

等待任务完成与 Goroutine 管理

基础版能释放资源,但如果程序中有正在执行的耗时任务(比如处理一个大文件、复杂计算),直接释放资源会导致任务中断。

进阶版需要解决两个问题:等待正在执行的任务完成、关闭所有Goroutine。

这里用 sync.WaitGroup 等待任务完成,用 context.Context 通知Goroutine退出:

package main

import (
    "context"
    "fmt"
    "os"
    "os/signal"
    "sync"
    "syscall"
    "time"

    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

var db *gorm.DB
// 用于等待所有任务完成
var wg sync.WaitGroup

func initDB() error {
    dsn := "root:123456@tcp(127.0.0.1:3306)/test_db?charset=utf8mb4&parseTime=True&loc=Local"
    var err error
    db, err = gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        return fmt.Errorf("init db failed: %v", err)
    }
    sqlDB, err := db.DB()
    if err != nil {
        return fmt.Errorf("get sql db failed: %v", err)
    }
    sqlDB.SetMaxOpenConns(10)
    sqlDB.SetMaxIdleConns(5)
    sqlDB.SetConnMaxLifetime(30 * time.Minute)
    fmt.Println("init db success")
    return nil
}

// 模拟耗时任务(比如处理API请求)
func handleTask(ctx context.Context, taskID int) {
    defer wg.Done() // 任务完成后通知WaitGroup
    fmt.Printf("start handling task %d\n", taskID)
    // 模拟耗时3秒的任务
    select {
    case <-time.After(3 * time.Second):
        fmt.Printf("finish handling task %d\n", taskID)
    case <-ctx.Done():
        // 收到退出通知,中断任务
        fmt.Printf("cancel handling task %d: %v\n", taskID, ctx.Err())
    }
}

func closeResource() {
    fmt.Println("start closing resource...")
    if db != nil {
        sqlDB, err := db.DB()
        if err != nil {
            fmt.Printf("get sql db failed when closing: %v\n", err)
            return
        }
        if err := sqlDB.Close(); err != nil {
            fmt.Printf("close db failed: %v\n", err)
        } else {
            fmt.Println("close db success")
        }
    }
    fmt.Println("all resource closed")
}

func listenSignal(ctx context.Context) context.Context {
    // 创建带取消功能的上下文
    cancelCtx, cancel := context.WithCancel(ctx)
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        sig := <-sigChan
        fmt.Printf("\nreceive signal: %v\n", sig)
        // 收到信号后取消上下文,通知所有任务退出
        cancel()
    }()

    return cancelCtx
}

func main() {
    if err := initDB(); err != nil {
        fmt.Printf("init resource failed: %v\n", err)
        os.Exit(1)
    }

    // 创建根上下文
    rootCtx := context.Background()
    // 监听信号,获取可取消的上下文
    cancelCtx := listenSignal(rootCtx)

    fmt.Println("service is running, press Ctrl+C to exit...")

    // 模拟启动多个任务(比如并发处理请求)
    for i := 1; i <= 3; i++ {
        wg.Add(1) // 增加WaitGroup计数
        go handleTask(cancelCtx, i)
    }

    // 等待所有任务完成
    wg.Wait()
    fmt.Println("all tasks finished or canceled")

    // 释放资源
    closeResource()

    fmt.Println("service exit gracefully")
}

运行程序后,在任务执行过程中按 Ctrl+C,会看到程序先等待正在执行的任务完成(或收到取消通知),再释放资源退出。

比如任务1刚执行1秒时收到信号,会被取消;任务如果已执行2.5秒,会继续执行完再退出。

五、生产版

设置退出超时时间

进阶版还存在一个问题:如果有任务一直阻塞(比如网络请求超时),程序会一直等待,无法退出。

生产环境中需要设置退出超时时间,超过时间后强制终止任务。这里用 context.WithTimeout 实现:

// 修改listenSignal函数,增加超时逻辑
func listenSignal(ctx context.Context) context.Context {
    // 设置退出超时时间为5秒
    timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)

    go func() {
        sig := <-sigChan
        fmt.Printf("\nreceive signal: %v\n", sig)
        // 收到信号后,等待超时时间到再取消
        // 也可以直接cancel(),让任务在超时时间内完成
        select {
        case <-time.After(5 * time.Second):
            fmt.Println("exit timeout, force cancel tasks")
            cancel()
        }
    }()

    return timeoutCtx
}

这样即使有任务阻塞,超过5秒后程序也会强制取消任务并退出,避免无限等待。

常见问题

Q1:kill -9 命令能触发优雅退出吗?

不能。

kill -9 发送的是 SIGKILL 信号,操作系统会直接终止进程,不会给进程执行退出逻辑的机会。

所以生产环境中,一定要避免用 kill -9 停止程序,优先用 kill(默认SIGTERM)或 Ctrl+C(SIGINT)。

Q2:多个Goroutine同时运行时,怎么确保都能收到退出通知?

context.Context 传递退出信号是最佳方案。

根上下文取消后,所有基于它衍生的子上下文都会收到 Done 信号,Goroutine 中通过 select 监听 <-ctx.Done() 就能及时退出。

注意不要在Goroutine中使用无缓冲通道或无限循环,否则可能收不到退出通知。

Q3:数据库连接、Redis连接这些资源,一定要在优雅退出时关闭吗?

建议关闭。

虽然进程退出后,操作系统会回收进程占用的文件句柄和网络连接,但数据库、Redis服务端可能需要一段时间才会释放这些“僵尸连接”,如果频繁强制退出,可能导致服务端连接数超限。

优雅关闭能主动释放连接,减轻服务端压力。

Q4:HTTP服务怎么实现优雅退出?

Go 1.8+ 版本的 net/http 包支持 Server.Shutdown 方法,结合信号监听即可实现。

核心逻辑:收到退出信号后,调用 server.Shutdown(ctx),它会停止接收新请求,等待正在处理的请求完成后关闭服务。

总结

Go 程序优雅退出看似简单,实则是生产环境稳定性的关键一环。总结如下:

  1. os/signal 监听 SIGINT 和 SIGTERM 信号,捕获退出触发时机;

  2. context.Context 向Goroutine传递退出通知,避免僵尸Goroutine;

  3. sync.WaitGroup 等待正在执行的任务完成,确保业务不中断;

  4. 主动关闭数据库、网络连接等资源,避免泄漏和服务端压力。

最后,优雅退出的逻辑要在开发初期就融入程序,不要等线上出现问题再回头优化。

不同场景(如HTTP服务、消息消费服务)的优雅退出细节可能不同,但核心原理是一致的,灵活运用信号、上下文和等待组就能轻松实现。

如果大家有其他场景的优雅退出需求,欢迎在评论区交流。

版权声明

未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!

本文原文链接: https://fiveyoboy.com/articles/go-signal-exist/

备用原文链接: https://blog.fiveyoboy.com/articles/go-signal-exist/