Go 监听信号退出并回收资源

目录
  1. 1. 操作系统中的信号
    1. 1.1. kill 和 kill -9 的区别
    2. 1.2. USR1 和 USR2
  2. 2. Go 优雅退出
    1. 2.1. Notify
    2. 2.2. 基本用法
    3. 2.3. 源码阅读
    4. 2.4. 项目中的实践
    5. 2.5. Reference

为什么取这个名字,而不是直接写优雅退出呢?

实际上我一直觉得优雅退出这个名字并不直观,而优雅退出的本质,其实就是监听一些操作系统信号。在监听到退出信号的时候,可以做一些资源回收的操作,而不至于什么都不管直接退出,等到操作系统自己去处理回收这些资源。

操作系统中的信号

关于信号的介绍,可以用man signal命令进行查看,这里不多做介绍了。

在POSIX.1-1990标准中定义的信号列表

信号 动作 说明
SIGHUP 1 Term 终端控制进程结束(终端连接断开)
SIGINT 2 Term 用户发送INTR字符(Ctrl+C)触发
SIGQUIT 3 Core 用户发送QUIT字符(Ctrl+/)触发
SIGILL 4 Core 非法指令(程序错误、试图执行数据段、栈溢出等)
SIGABRT 6 Core 调用abort函数触发
SIGFPE 8 Core 算术运行错误(浮点运算错误、除数为零等)
SIGKILL 9 Term 无条件结束程序(不能被捕获、阻塞或忽略)
SIGSEGV 11 Core 无效内存引用(试图访问不属于自己的内存空间、对只读内存空间进行写操作)
SIGPIPE 13 Term 消息管道损坏(FIFO/Socket通信时,管道未打开而进行写操作)
SIGALRM 14 Term 时钟定时信号
SIGTERM 15 Term 结束程序(可以被捕获、阻塞或忽略)
SIGUSR1 30,10,16 Term 用户保留
SIGUSR2 31,12,17 Term 用户保留
SIGCHLD 20,17,18 Ign 子进程结束(由父进程接收)
SIGCONT 19,18,25 Cont 继续执行已经停止的进程(不能被阻塞)
SIGSTOP 17,19,23 Stop 停止进程(不能被捕获、阻塞或忽略)
SIGTSTP 18,20,24 Stop 停止进程(可以被捕获、阻塞或忽略)
SIGTTIN 21,21,26 Stop 后台程序从终端中读取数据时触发
SIGTTOU 22,22,27 Stop 后台程序向终端中写数据时触发

第1列为信号名;
第2列为对应的信号值,需要注意的是,有些信号名对应着3个信号值,这是因为这些信号值与平台相关,将man手册中对3个信号值的说明摘出如下,the first one is usually valid for alpha and sparc, the middle one for i386, ppc and sh, and the last one for mips.
第3列为操作系统收到信号后的动作
- Term表明默认动作为终止进程
- Ign表明默认动作为忽略该信号
- Core表明默认动作为终止进程同时输出core dump
- Stop表明默认动作为停止进程。
第4列为对信号作用的注释性说明
需要特别说明的是,SIGKILL和SIGSTOP这两个信号既不能被应用程序捕获,也不能被操作系统阻塞或忽略。

实际上我们用的比较多的也是上面的其中几个信号,如 SIGHUP、SIGINT、SIGQUIT、SIGKILL、SIGTERM 这几个

kill 和 kill -9 的区别

既然是监听退出信号,我们就不得不提一下 kill 这个命令。通过上面的表格,我们可以知道,SIGKILL 和 SIGTERM  这两个信号对应的就是 kill 命令

  • kill pid 就是向这个 pid 的进程发送 SIGTERM 信号,这个信号可以被捕获接收、

  • kill -9 pid 就是向这个 pid 的进程发送 SIGKILL 信号,这个信号既不能被应用程序捕获,也不能被阻塞或忽略,相当于我们平时说的强制退出,在这种情况下应用想反应都来不及(SIGKILL 信号是直接发给 init 进程的,由 init 进程负责终止 pid 指定的进程)

USR1 和 USR2

我们继续看上面的表格,USR1 和 USR2 也是我们平常可能用到的,是为用户保留使用的两个信号,我们可以用来做一些额外的自定义功能

Go 优雅退出

要做到优雅退出,其实就是两件事:

  1. 在程序运行中监听信号

  2. 当收到了信号后,做资源关闭回收、打印日志、保留服务状态等操作

Notify

在 go 语言中,监听信号主要是用到了 os/signal 包中的notify方法,也有Stop停止监听信号的方法以及Reset取消信号调用notify的效果的方法等,感兴趣的可以去看一下源码实现,这里主要是提到notify方法

基本用法

  1. 监听所有信号的操作
1
2
3
4
5
6
c := make(chan os.Signal)
// 监听所有信号
signal.Notify(c)
fmt.Println("启动了程序")
s := <-c
fmt.Println("收到信号:", s)
  1. 监听指定信号的操作
1
2
3
4
5
6
c := make(chan os.Signal)
// 监听指定信号
signal.Notify(c, os.Interrupt, os.Kill, syscall.SIGUSR1, syscall.SIGUSR2)
fmt.Println("启动了程序")
s := <-c
fmt.Println("收到信号:", s)

需要注意的是,代码里的os.Interrupt等于syscall.SIGINTos.Kill等于syscall.SIGKILL

我们可以进一步实践一下触发信号的效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
c := make(chan os.Signal)
signal.Notify(c)
for s := range c {
  switch s {
    case os.Kill: // kill -9 pid,下面的fmt无效
    fmt.Println("强制退出", s)
    case syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT: // ctrl + c
    fmt.Println("退出", s)
    case syscall.SIGUSR1: // kill -USR1 pid
    fmt.Println("usr1", s)
    case syscall.SIGUSR2: // kill -USR2 pid
    fmt.Println("usr2", s)
  }
}

源码阅读

我们主要还是看notify方法:

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
var handlers struct {
  // 同步锁
 sync.Mutex
 // 用于将 ch 和信号接收处理 handler 关联映射
 m map[chan<- os.Signal]*handler
 // 映射每一类 signal 有几个 channel 需要接收
 ref [numSig]int64
 // 用于暂存需要停止监听的 signal,具体可以看 Stop 方法
 stopping []stopping
}

type handler struct {
 mask [(numSig + 31) / 32]uint32
}

// ...还有 handler 的方法

// signal.go
func Notify(c chan<- os.Signal, sig ...os.Signal) {
  // 判空
 if c == nil {
  panic("os/signal: Notify using nil channel")
 }

  // handlers 是一个全局的变量
 handlers.Lock()
 defer handlers.Unlock()

  // 将 ch 和 handler 进行映射
 h := handlers.m[c]
 if h == nil {
  if handlers.m == nil {
   handlers.m = make(map[chan<- os.Signal]*handler)
  }
  h = new(handler)
  handlers.m[c] = h
 }

  // 添加需要监听的信号的函数封装
 add := func(n int) {
  if n < 0 {
   return
  }
  if !h.want(n) {
   h.set(n)
   if handlers.ref[n] == 0 {
    enableSignal(n)

    // The runtime requires that we enable a
    // signal before starting the watcher.
    watchSignalLoopOnce.Do(func() {
     if watchSignalLoop != nil {
      go watchSignalLoop()
     }
    })
   }
   handlers.ref[n]++
  }
 }

 if len(sig) == 0 {
    // 如果没有传入任何 sig,则将全部信号都加入 ch 监听映射中
  for n := 0; n < numSig; n++ {
   add(n)
  }
 } else {
  for _, s := range sig {
   add(signum(s))
  }
 }
}
// ...省略其他代码
func process(sig os.Signal) {
 n := signum(sig)
 if n < 0 {
  return
 }

 handlers.Lock()
 defer handlers.Unlock()

 for c, h := range handlers.m {
  if h.want(n) {
   // send but do not block for it
   select {
   case c <- sig:
   default:
   }
  }
 }

 // Avoid the race mentioned in Stop.
 for _, d := range handlers.stopping {
  if d.h.want(n) {
   select {
   case d.c <- sig:
   default:
   }
  }
 }
}

在官方注释说明中提到,需要传入一个带有缓冲区的 channel,因为监听的信号是不阻塞地发到 ch 中的,如果 sig 当前没有被 recv,则直接丢弃,造成了 sig 可能丢失的情况产生

1
2
3
4
5
6
7
8
9
10
11
// signal_unix.go
func loop() {
 for {
    // process 方法实现在 signal.go 中,看上一代码块的后面
  process(syscall.Signal(signal_recv()))
 }
}

func init() {
 watchSignalLoop = loop
}

项目中的实践

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
// 优雅退出go守护进程
func main() {
 pid := syscall.Getpid()
 fmt.Println("pid : ", pid)
 //创建监听退出chan
 c := make(chan os.Signal, 1)
 defer close(c)
 //监听指定信号 ctrl+c kill
 signal.Notify(c, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
 go NotifyAndExit(c)
  // 初始化
 db.Init()
 redis.Init()
 server.Init()
 fmt.Println("进程启动...")
  server.Serve(":8080")
}

func NotifyAndExit(c <-chan os.Signal) {
 for s := range c {
  switch s {
  case syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT: // ctrl + c
   fmt.Println("退出", s)
   db.Close()
   redis.Close()
   server.Close()
      os.Exit(0)
     default:
  }
 }
}

这里面有几个关键点:

  1. syscall.Getpid():可以直接获取进程启动的 pid,一般我们之间会持久化到一个文件中,可以方便直接查看,而不需要再去 ps -ef

  2. 我们可以起一个 goroutine 去监听信号,当收到 kill 信号后对资源连接进行关闭,并打印关键日志,用于后续日志检索等

  3. 在做完这些事情后,记得主动调用os.Exit(0)进行退出,因为我们已经捕获到 kill信号,进入了自定义的处理逻辑,如果调用exit方法,则程序就不会进入结束环节了(kill -9 还是可以直接 kill 掉的)

Reference

https://blog.csdn.net/skh2015java/article/details/99468586