Linux-优雅退出在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 强杀,具体请看下最常见的信号列表。

信号列表

名称动作说明
SIGHUP1Term终端控制进程结束(终端连接断开)
SIGINT2Term用户发送INTR字符(Ctrl+C)触发
SIGQUIT3Core用户发送QUIT字符(Ctrl+/)触发
SIGILL4Core非法指令(程序错误、试图执行数据段、栈溢出等)
SIGABRT6Core调用abort函数触发
SIGFPE8Core算术运行错误(浮点运算错误、除数为零等)
SIGKILL9Term无条件结束程序(不能被捕获、阻塞或忽略)
SIGSEGV11Core无效内存引用(试图访问不属于自己的内存空间、对只读内存空间进行写操作)
SIGPIPE13Term消息管道损坏(FIFO/Socket通信时,管道未打开而进行写操作)
SIGALRM14Term时钟定时信号
SIGTERM15Term结束程序(可以被捕获、阻塞或忽略)
SIGUSR130,10,16Term用户保留
SIGUSR231,12,17Term用户保留
SIGCHLD20,17,18Ign子进程结束(由父进程接收)
SIGCONT19,18,25Cont继续执行已经停止的进程(不能被阻塞)
SIGSTOP17,19,23Stop停止进程(不能被捕获、阻塞或忽略)
SIGTSTP18,20,24Stop停止进程(可以被捕获、阻塞或忽略)
SIGTTIN21,21,26Stop后台程序从终端中读取数据时触发
SIGTTOU22,22,27Stop后台程序向终端中写数据时触发

入门例子

代码

上面说了,把几种常见情况的信号捕获一下即可,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)
		}
	}

效果

运行程序后,按下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为例,来演示如何使用上面封装的优雅退出功能:
main.go

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

陷阱和最佳实践

如果你等待10秒后,程序会崩溃,如果是你从C++转过来,你会奇怪为啥没有进入优雅退出环节( go panic机制和C++ 进程crash,被系统杀死的机制不一样,不会收到系统信号):

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,抛出了异常,但是没有被处理,从而导致进程退出。只需要把这段代码取消注释即可:

// 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 routine使用的最佳实践,尽量不要用野生go routine,如果忘记写 recover() ,进程就退出了!

比如,go-zero就封装了自己的 gosafe实现

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()
}

关于作者

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

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值