• 优雅退出在Golang中的实现


    作者:@Go和分布式IM
    本文为作者原创,转载请注明出处:https://www.cnblogs.com/wishFreedom/p/16573575.html


    背景

    为什么需要优雅关停

    在Linux下运行我们的go程序,通常有这样2种方式:
    1. 前台启动。打开终端,在终端中直接启动某个进程,此时终端被阻塞,按CTRL+C退出程序,可以输入其他命令,关闭终端后程序也会跟着退出。
    1
    2
    $ ./main
    $ # 按CTRL+C退出
    1. 后台启动。打开终端,以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 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为例,来演示如何使用上面封装的优雅退出功能:
    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 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中直接停止:
    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: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

      

    这是,因为我们使用了`野生的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,会运行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`,如果忘记写 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的心路成长历程和升级打怪技巧。

      

  • 相关阅读:
    Lab3 存储过程与触发器
    现代营销杂志现代营销杂志社现代营销编辑部2022年第11期目录
    软件测试学习(四)自动测试和测试工具、缺陷轰炸、外包测试、计划测试工作、编写和跟踪测试用例
    物理机环境搭建-linux部署nginx
    【T+】余额表联查明细账,提示未将对象引用设置到对象的实例;参数格式错误,solutionID不能为空。
    Sui第六轮资助:15个项目共获得106万美元的资助
    音视频 ffmpeg命令转封装
    计算机网络的OSI七层模型
    信息安全软考 第二章 网络攻击原理与常用方法笔记总结
    2022/7/4前端开发面经
  • 原文地址:https://www.cnblogs.com/wishFreedom/p/16573575.html