优雅退出在Golang中的实现

背景

为什么需要优雅关停

在Linux下运行我们的go程序,通常有这样2种方式:
  1. 前台启动。打开终端,在终端中直接启动某个进程,此时终端被阻塞,按CTRL+C退出程序,可以输入其他命令,关闭终端后程序也会跟着退出。
$ ./main
$ # 按CTRL+C退出
  1. 后台启动。打开终端,以nohup来后台启动某个进程,这样退出终端后,进程仍然会后台运行。
$ nohup main > log.out 2>&1 &
$ ps aux | grep main
# 需要使用 kill 杀死进程
$ kill 8120

针对上面2种情况,如果你的程序正在写文件(或者其他很重要,需要一点时间停止的事情),此时被操作系统强制杀掉,因为写缓冲区的数据还没有被刷到磁盘,所以你在内存中的那部分数据丢失了。

所以,我们需要一种机制,能在程序退出前做一些事情,而不是粗暴的被系统杀死回收,这就是所谓的优雅退出。

实现原理

在Linux中,操作系统要终止某个进程的时候,会向它发送退出信号:
  • 比如上面你在终端中按 `CTRL+C` 后,程序会收到 `SIGINT` 信号。
  • 打开的终端被关机,会收到 `SIGHUP` 信号。
  • kill 8120 杀死某个进程,会收到 `SIGTERM` 信号。
 
所以,我们希望在程序退出前,做一些清理工作,只需要`订阅处理下这些信号即可`!
 
但是,信号不是万能的,有些信号不能被捕获,最常见的就是 `kill -9` 强杀,具体请看下最常见的信号列表。
 
 

入门例子

代码

通过上文的分析,我们在代码里面,只要针对几种常见的信号进行捕获即可。go里面提供了`os/signal`包,用法如下:
package main
 
import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
    "time"
)
 
// 优雅退出(退出信号)
func waitElegantExit(signalChan chan os.Signal) {
    for i := range c {
        switch i {
        case syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT:
            // 这里做一些清理操作或者输出相关说明,比如 断开数据库连接
            fmt.Println("receive exit signal ", i.String(), ",exit...")
            os.Exit(0)
        }
    }
}
 
func main() {
    //
    // 你的业务逻辑
    //
    fmt.Println("server run on: 127.0.0.1:8000")
 
    c := make(chan os.Signal)
    // SIGHUP: terminal closed
    // SIGINT: Ctrl+C
    // SIGTERM: program exit
    // SIGQUIT: Ctrl+/
    signal.Notify(c, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
    
    // 阻塞,直到接受到退出信号,才停止进程
    waitElegantExit(signalChan)
}

  

详解

上面的代码中,我们先创建了一个无缓冲 `make(chan os.Signal)`  通道(Channel),然后使用`signal.Notify` 订阅了一批信号(注释中有说明这些信号的具体作用)。
 
然后,在一个死循环中,从通道中读取信号,一直阻塞直到收到该信号为主,如果你看不懂,换成下面的代码就好理解了:
for {
        // 从通道接受信号,期间一直阻塞
        i := <-c
        switch i {
        case syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT:
            fmt.Println("receive exit signal ", i.String(), ",exit...")
            exit()
            os.Exit(0)
        }
    }

  

然后判断信号,在调用 os.Exit() 退出程序前,执行一些清理动作,比如把日志从内存全部刷到硬盘(Zap)、关闭数据库连接、打印退出日志或者关闭HTTP服务等等。

效果

运行程序后,按下Ctrl+C,我们发现程序退出前打印了对应的日志:
server run on: 127.0.0.1:8060
# mac/linux 上按Ctrl+C,windows上调试运行,然后点击停止
receive exit signal interrupt ,exit...
 
Process finished with exit code 2

  

至此,我们就实现了所谓的优雅退出了,简单吧?
 

工具类

封装

为了方便在多个项目中使用,建议在公共pkg包中新建对应的文件,封装进去,便于使用,下面是一个实现。
 
新建 `signal.go`:
package osutils
 
import (
    "fmt"
    "os"
    "os/signal"
    "syscall"
)
 
// WaitExit will block until os signal happened
func WaitExit(c chan os.Signal, exit func()) {
    for i := range c {
        switch i {
        case syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT:
            fmt.Println("receive exit signal ", i.String(), ",exit...")
            exit()
            os.Exit(0)
        }
    }
}
 
// NewShutdownSignal new normal Signal channel
func NewShutdownSignal() chan os.Signal {
    c := make(chan os.Signal)
    // SIGHUP: terminal closed
    // SIGINT: Ctrl+C
    // SIGTERM: program exit
    // SIGQUIT: Ctrl+/
    signal.Notify(c, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
    return c
}

http server的例子

以gin框架实现一个http server为例,来演示如何使用上面封装的优雅退出功能:
package main
 
import (
    "context"
    "fmt"
    "github.com/gin-gonic/gin"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"
)
 
// Recover the go routine
func Recover(cleanups ...func()) {
    for _, cleanup := range cleanups {
        cleanup()
    }
 
    if err := recover(); err != nil {
        fmt.Println("recover error", err)
    }
}
 
// GoSafe instead go func()
func GoSafe(ctx context.Context, fn func(ctx context.Context)) {
    go func(ctx context.Context) {
        defer Recover()
        if fn != nil {
            fn(ctx)
        }
    }(ctx)
}
 
func main() {
    // a gin http server
    gin.SetMode(gin.ReleaseMode)
    g := gin.Default()
    g.GET("/hello", func(context *gin.Context) {
        // 被 gin 所在 goroutine 捕获
        panic("i am panic")
    })
 
    httpSrv := &http.Server{
        Addr:    "127.0.0.1:8060",
        Handler: g,
    }
    fmt.Println("server run on:", httpSrv.Addr)
    go httpSrv.ListenAndServe()
 
    // a custom dangerous go routine, 10s later app will crash!!!!
    GoSafe(context.Background(), func(ctx context.Context) {
        time.Sleep(time.Second * 10)
        panic("dangerous")
    })
 
    // wait until exit
    signalChan := NewShutdownSignal()
    WaitExit(signalChan, func() {
        // your clean code
        if err := httpSrv.Shutdown(context.Background()); err != nil {
            fmt.Println(err.Error())
        }
        fmt.Println("http server closed")
    })
}

   

运行后立即按Ctrl+C或者在Goland中直接停止:
server run on: 127.0.0.1:8060
^Creceive exit signal  interrupt ,exit...
http server closed
 
Process finished with the exit code 0

   

陷阱

上面的程序有一个小陷阱,不知道你注意到了没?
// WARNING!!!!
  // a custom dangerous go routine, 10s later app will crash!!!!
  go func() {
    time.Sleep(time.Second * 10)
    panic("dangerous")
  }()

这里会故意等待10秒,然后抛出一个panic,因为没有recover恢复,故程序会崩溃(姑且算是崩溃吧),那么问题来了:`此时会进入到优雅退出机制吗?`

为什么这么问?因为进程crash比如著名的c/c++空指针,被系统杀死时会收到kill信号,则理所当然认为会进入优雅退出机制。

但是,如果你看过panic源码,你肯定会说:不会!

server run on: 127.0.0.1:8060
panic: dangerous
 
goroutine 21 [running]:
main.main.func2()
        /Users/fei.xu/repo/haoshuo/ws-gate/app/test/main.go:77 +0x40
created by main.main
        /Users/fei.xu/repo/haoshuo/ws-gate/app/test/main.go:75 +0x250
 
Process finished with the exit code 2

  

再总结下,这里有2个问题:
- 为啥进程会退出?
- 为啥没有进入优雅退出机制?

第1个问题很简单:是因为我们使用了`野生的go routine`,抛出了异常,但是没有被处理,从而导致进程退出。只需要把这段代码取消注释即可:

// a custom dangerous go routine, 10s later app will crash!!!!
//go func() {
//    time.Sleep(time.Second * 10)
//    panic("dangerous")
//}()
// use above code instead!
GoSafe(context.Background(), func(ctx context.Context) {
    time.Sleep(time.Second * 10)
    panic("dangerous")
})

第2个问题,是由于painc机制,如果没有被处理,它会调用 os.Exit(2) 退出进程,所以算是进程主动退出,故操作系统不会发送kill信号,也就无法进入优雅退出机制。

下面是部分源码(runtime/panic.go):

func gopanic(e interface{}) {
    gp := getg()

    var p _panic
    p.arg = e
    p.link = gp._panic //p指向更早的panic
    gp._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

    atomic.Xadd(&runningPanicDefers, 1)
    //遍历defer链表
    for {
        d := gp._defer
        if d == nil {
            break
        }

        // 如果defer已经启动,跳过
        if d.started {
            gp._defer = d.link
            freedefer(d)  //释放defer
            continue
        }

        // 标识defer已经启动
        d.started = true

        // 记录是当前Panic运行这个defer。如果在defer运行期间,有新的Panic,将会标记这个Panic abort=true(强制终止)
        d._panic = (*_panic)(noescape(unsafe.Pointer(&p)))

        p.argp = unsafe.Pointer(getargp(0))
        // 调用 defer
        reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz))
        p.argp = nil

        // reflectcall did not panic. Remove d.
        if gp._defer != d {
            throw("bad defer entry in panic")
        }
        d._panic = nil
        d.fn = nil
        gp._defer = d.link //遍历到下一个defer
        pc := d.pc
        sp := unsafe.Pointer(d.sp) // must be pointer so it gets adjusted during stack copy
        freedefer(d)
        //已经有recover被调用
        if p.recovered {
            //调用recovery函数
            mcall(recovery)
            throw("recovery failed") // mcall should not return
        }
    }
    //defer遍历完,终止程序
    fatalpanic(gp._panic) // should not return
    *(*int)(nil) = 0      // not reached
}

//panic没有被recover,会运行fatalpanic
func fatalpanic(msgs *_panic) {
    systemstack(func() {
        if startpanic_m() && msgs != nil {
            //打印panic messages
            printpanics(msgs)
        }
        //打印panic messages
        docrash = dopanic_m(gp, pc, sp)
    })

    //终止整个程序,所以需要注意:如果goroutine的Panic没有 recover,会终止整个程序
    systemstack(func() {
        exit(2)
    })

    *(*int)(nil) = 0 // not reached
}

我们可以确定,当panic没有被处理时,runtime 会调用 exit(2) 退出整个应用程序! 

go routine最佳实践

其实,这也是一个go routine使用的最佳实践,`尽量不要用野生go routine`,如果忘记写 recover() ,进程就退出了!
 
比如,go-zero就封装了自己的 [gosafe实现](https://github.com/zeromicro/go-zero/blob/master/core/threading/routines.go):
package threading
 
import (
    "bytes"
    "runtime"
    "strconv"
 
    "github.com/zeromicro/go-zero/core/rescue"
)
 
// GoSafe runs the given fn using another goroutine, recovers if fn panics.
func GoSafe(fn func()) {
    go RunSafe(fn)
}
 
// RoutineId is only for debug, never use it in production.
func RoutineId() uint64 {
    b := make([]byte, 64)
    b = b[:runtime.Stack(b, false)]
    b = bytes.TrimPrefix(b, []byte("goroutine "))
    b = b[:bytes.IndexByte(b, ' ')]
    // if error, just return 0
    n, _ := strconv.ParseUint(string(b), 10, 64)
 
    return n
}
 
// RunSafe runs the given fn, recovers if fn panics.
func RunSafe(fn func()) {
    defer rescue.Recover()
 
    fn()
} 

  recover.go

// Recover is used with defer to do cleanup on panics.
// Use it like:
//  defer Recover(func() {})
func Recover(cleanups ...func()) {
  for _, cleanup := range cleanups {
    cleanup()
  }
  if p := recover(); p != nil {
    logx.ErrorStack(p)
  }
}

特别注意:logx.ErrorStack(p) 是把 recover() 返回的错误和堆栈信息打印到控制台了,如果你要写入到日志文件中,别忘记这里要改成自己的log实现!  

 

总结

至此,我们介绍了什么是优雅退出,以及在Linux下几种常见的退出场景,并给出了Go的入门代码例子和最佳实践。

在文章的最后,特别是对Linux C++ 转go的同学进行了一个提醒:go panic的时候,是不会收到退出信号的,因为它是程序自己主动退出(go runtime),而不是因为非法访问内存被操作系统杀掉。

针对上面这个问题,给出的建议是,谨慎使用原生go关键字,最佳实践是封装一个GoSafe函数,在里面进行 recover() 和打印堆栈,这样,就不会出现因为忘记 recover 而导致进程崩溃了! 

 

---- The End ----

如有任何想法或者建议,欢迎评论区留言😊。

 

——————传说中的分割线——————

大家好,我目前已从C++后端转型为Golang后端,可以订阅关注下《Go和分布式IM》公众号,获取一名转型萌新Gopher的心路成长历程和升级打怪技巧。

  

posted @ 2022-08-10 18:54  Go和分布式IM  阅读(3652)  评论(0编辑  收藏  举报