作者:@Go和分布式IM
本文为作者原创,转载请注明出处:https://www.cnblogs.com/wishFreedom/p/16573575.html
背景
为什么需要优雅关停
在Linux下运行我们的go程序,通常有这样2种方式:
- 前台启动。打开终端,在终端中直接启动某个进程,此时终端被阻塞,按CTRL+C退出程序,可以输入其他命令,关闭终端后程序也会跟着退出。
1 2 | $ ./main$ # 按CTRL+C退出 |
- 后台启动。打开终端,以nohup来后台启动某个进程,这样退出终端后,进程仍然会后台运行。
1 2 3 4 | $ 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`包,用法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 | 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` 订阅了一批信号(注释中有说明这些信号的具体作用)。
然后,在一个死循环中,从通道中读取信号,一直阻塞直到收到该信号为主,如果你看不懂,换成下面的代码就好理解了:
1 2 3 4 5 6 7 8 9 10 | 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,我们发现程序退出前打印了对应的日志:
1 2 3 4 5 | 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`:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | package osutils import ( "fmt" "os" "os/signal" "syscall") // WaitExit will block until os signal happenedfunc 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 channelfunc 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为例,来演示如何使用上面封装的优雅退出功能:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 | package main import ( "context" "fmt" "github.com/gin-gonic/gin" "net/http" "os" "os/signal" "syscall" "time") // Recover the go routinefunc 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中直接停止:
1 2 3 4 5 | server run on: 127.0.0.1:8060^Creceive exit signal interrupt ,exit...http server closed Process finished with the exit code 0 |
陷阱和最佳实践
如果你等待10秒后,程序会崩溃,如果是你从C++转过来,你会奇怪为啥没有进入优雅退出环节(` go panic机制和C++ 进程crash,被系统杀死的机制不一样,不会收到系统信号`):
1 2 3 4 5 6 7 8 9 10 | server run on: 127.0.0.1:8060panic: dangerous goroutine 21 [running]:main.main.func2() /Users/fei.xu/repo/haoshuo/ws-gate/app/test/main.go:77 +0x40created by main.main /Users/fei.xu/repo/haoshuo/ws-gate/app/test/main.go:75 +0x250 Process finished with the exit code 2 |
这是,因为我们使用了`野生的go routine`,抛出了异常,但是没有被处理,从而导致进程退出。只需要把这段代码取消注释即可:
1 2 3 4 5 6 7 8 9 10 | // 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")}) |
通过查看go panic(runtime/panic.go)部分源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 | 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,会运行fatalpanicfunc 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`,如果忘记写 recover() ,进程就退出了!
比如,go-zero就封装了自己的 [gosafe实现](https://github.com/zeromicro/go-zero/blob/master/core/threading/routines.go):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | 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()} |
总结
至此,我们介绍了什么是优雅退出,以及在Linux下几种常见的退出场景,并给出了Go的入门代码例子和最佳实践。
在文章的最后,特别是对Linux C++ 转go的同学进行了一个提醒:go panic的时候,是不会收到退出信号的,因为它是程序自己主动退出(go runtime),而不是因为非法访问内存被操作系统杀掉。
针对上面这个问题,给出的建议是,谨慎使用原生go关键字,最佳实践是封装一个GoSafe函数,在里面进行 recover() 和打印堆栈,这样,就不会出现因为忘记 recover 而导致进程崩溃了!
---- The End ----
如有任何想法或者建议,欢迎评论区留言😊。
——————传说中的分割线——————
大家好,我目前已从C++后端转型为Golang后端,可以订阅关注下《Go和分布式IM》公众号,获取一名转型萌新Gopher的心路成长历程和升级打怪技巧。