什么是优雅退出?什么才叫优雅?

    这两个问题是本文的核心,因此需要首先分析这两个问题。我们的进程存在各种退出场景,比如收到terminate信号进而退出,也可能被ctrl-c终止了程序,也有可能是执行kill -9 pid指令直接杀死进程。对于这些异常且突发的进程退出,会导致正在执行任务的进程终止自己手头上的工作,比如正在计算到一半的任务就终止了;也可能是计算完的结果来不及存入数据库就终止了;也可能是我们的log信息也没来得及调用write就进程退出了。这种的进程退出只能叫暴力退出,因为此时的退出没有做任何保护措施,因此可能会导致一些数据的丢失。

    那什么样的退出才叫优雅?那对比暴力退出其实就可以得出,优雅退出的本质是留给进程清理剩余请求的时间,等到自己的子协程/线程完成本轮任务时,再发起进程退出,此时的退出不会丢失任何数据。对比生活场景就是,电脑突然关机就是非常不优雅的,因为此时我们可能正在编写文档,此时文档还没来得及保存系统就关闭了,我就丢失了我的已输入但未保存的文档数据。如果是系统提前告知我系统将在60秒后自动关闭,那我就是有时间保存好我的所有数据,再等待关机时间到达,这就不会造成数据丢失。

    日常的服务管理中,进程热重启是非常常见的操作,比如我们需要重启我们的web server进程,我们不要简单粗暴地使用kill -9 pid来杀死进程,然后再启动进程。正确的做法是先在进程内编写好优雅重启的代码,如果线上需要重启该进程时,请使用SIGTERM等可捕捉的信号来终止进程,进程内捕捉到这些信号后就开始停止接收新的请求,剩余请求都完成后,再退出进程。若发现一定时间内进程仍未优雅退出,此时的进程已陷入卡死状态,此时可以使用SIGKILL来强制终止进程。

    因此,简单梳理优雅退出的步骤如下:

    1. 捕捉进程终止信号,如syscall.SIGINT, syscall.SIGTERM, os.Kill
    2. 捕捉到信号后,开启进程退出倒计时,不管如何,必须在n秒后退出进程,这是底线
    3. 停止接收新的请求
    4. 等待进程内剩余请求的完成
    5. 终止时间已到,进程正式退出

    特别注意:kill -9 pid 触发的SIGKILL信号无法被处理,因为进程已经被系统强制关闭了。因此尽可能不要用这个指令终止程序,更好的方式是使用kill pid,触发的是SIGTERM。

    在go中,我们可以使用signal.Notify 和 context.WithTimeout 来完成我们上面提到的的优雅退出。

    我们以http server为例,介绍go是如何做到server进程的优雅退出。

    首先我们启动我们的web服务器

    1. r := gin.Default()
    2. r.GET("/hello", func(c *gin.Context) {
    3. HelloWeb(c)
    4. })
    5. s := &http.Server{
    6. Addr: ":8091",
    7. Handler: r,
    8. }
    9. go func() {
    10. if err := s.ListenAndServe(); err != nil {
    11. log.Printf("Listen err: %s\n", err)
    12. }
    13. }()

    然后我们使用signal.Notify 去捕捉syscall.SIGINT, syscall.SIGTERM, os.Kill。当channel signalChan有数据时,就是捕捉到信号了,此时调用context.WithTimeout 开启4秒后的强制退出。继续调用srv.Shutdown(ctx) 关闭 http server,而srv.Shutdown(ctx)的本质操作是,关闭监听端口,停止接收新的请求,通过context通知自己的子协程退出,即当自己的子协程处理完本轮请求后,因为子协程内case <- ctx.Done() 触发,因此协程退出。最后close(exitCh) 让主进程顺利退出。

    func gracefulExit(srv *http.Server) {
        signalChan := make(chan os.Signal, 1)
        signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM, os.Kill)
    
        sig := <-signalChan
        log.Printf("catch signal, %+v", sig)
    
        ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) // 4秒后退出
        defer cancel()
        if err := srv.Shutdown(ctx); err != nil {
            log.Fatal("Server Shutdown:", err)
        }
        log.Printf("server exiting")
        close(exitCh)
    }
    

    完整优雅退出代码如下:

    package main
    
    // main.go
    
    import (
        "github.com/gin-gonic/gin"
        "net/http"
        "os"
        "os/signal"
        "syscall"
        "context"
        "log"
        "time"
    
    )
    
    var exitCh = make(chan int)
    
    func HelloWeb(c *gin.Context) {
        c.String(http.StatusOK, "Hello, Go\n")
    }
    
    
    func main() {
    
        r := gin.Default()
        r.GET("/hello", func(c *gin.Context) {
            HelloWeb(c)
        })
    
        s := &http.Server{
            Addr:           ":8091",
            Handler:        r,
        }
    
        go func() {
            if err := s.ListenAndServe(); err != nil {
                log.Printf("Listen err: %s\n", err)
            }
        }()
    
        go gracefulExit(s)
    
        <-exitCh
    
        log.Printf("Server exit")
    }
    
    func gracefulExit(srv *http.Server) {
        signalChan := make(chan os.Signal, 1)
        signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM, os.Kill)
    
        sig := <-signalChan
        log.Printf("catch signal, %+v", sig)
    
        ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) // 4秒后退出
        defer cancel()
        if err := srv.Shutdown(ctx); err != nil {
            log.Fatal("Server Shutdown:", err)
        }
        log.Printf("server exiting")
        close(exitCh)
    }
    

    ctrl-c触发了进程的优雅退出。

    ^C2021/06/27 01:04:59 catch signal, interrupt
    2021/06/27 01:04:59 server exiting
    2021/06/27 01:04:59 Server exit
    junshideMacBook-Pro:gogo junshili$
    

    附上POSIX中定义的信号,供查阅
    截屏2021-06-27 上午1.17.43.png