Skip to main content
  1. 经验技巧分享/
  2. 后端编程/

Go Graceful Shutdown

··778 字
howto go

golang 被广泛用在命令行、web 后台服务等领域。通常我们自己编写小程序时,在命令行启动后,使用ctrl+c 来终止进程。一般情况下不会有问题。但如果你的任务是资源消耗型或者长任务,特别是有远程 web 访问或者数据库操作的,为了避免资源泄漏,较好的做法是在退出前对使用的资源进行清理,有远程连接或者数据连接的,应该断开或者关闭,这些都是需要手工定制的。

为了能够在程序中捕获 ctrl+c 中断信号,需要在程序中编写信号捕捉逻辑。在介绍这个逻辑之后,先介绍一些背景的基础知识。

在本文中,将涉及到:

  • 部分 linux 操作系统信号
  • golang 的并发、协程
  • golang 的 channel

系统信号 #

linux 上的进程 POSIX 标准,使用 kill -l 可列出下面这些。

root@example:~# kill -l
 1) SIGHUP   2) SIGINT   3) SIGQUIT  4) SIGILL   5) SIGTRAP
 6) SIGABRT  7) SIGBUS   8) SIGFPE   9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG  24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM   27) SIGPROF 28) SIGWINCH    29) SIGIO   30) SIGPWR
31) SIGSYS  34) SIGRTMIN    35) SIGRTMIN+1  36) SIGRTMIN+2  37) SIGRTMIN+3
38) SIGRTMIN+4  39) SIGRTMIN+5  40) SIGRTMIN+6  41) SIGRTMIN+7  42) SIGRTMIN+8
43) SIGRTMIN+9  44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9  56) SIGRTMAX-8  57) SIGRTMAX-7
58) SIGRTMAX-6  59) SIGRTMAX-5  60) SIGRTMAX-4  61) SIGRTMAX-3  62) SIGRTMAX-2
63) SIGRTMAX-1  64) SIGRTMAX

macos 上的 kill -l

kill -l
HUP INT QUIT ILL TRAP ABRT EMT FPE KILL BUS SEGV SYS PIPE ALRM TERM URG STOP TSTP CONT CHLD TTIN TTOU IO XCPU XFSZ VTALRM PROF WINCH INFO USR1 USR2

呵呵,看花眼了。不急,我们挑出重点,常见的有:

信号 动作 说明
SIGHUP 1 Term 终端控制进程结束(终端连接断开)
SIGINT 2 Term 用户发送INTR字符(Ctrl+C)触发
SIGQUIT 3 Core 用户发送QUIT字符(Ctrl+/)触发
SIGABRT 6 Core 调用abort函数触发
SIGKILL 9 Term 无条件结束程序(不能被捕获、阻塞或忽略)
SIGTERM 15 Term 结束程序(可以被捕获、阻塞或忽略)

留意上面的 SIGINTSIGTERMSIGKILL,这些是最常碰到的。

killkill -9 #

平时通过命令 kill pid 或者 kill -9 pid 退出进程。它们的区别在于:

  • kill pid 是向操作系统发出指令,通知 pid 进程停止运行。并且在进程中可以捕获到信号 SIGTERM,让进程中可以针对这个退出信号作为响应逻辑。
  • kill -9 pid,操作系统会直接杀掉 pid 进程。并且进程是无法感知这个『停止』通知的,非常暴力。通常是用来教训不听话的应用程序。

那么,kill pid 对应的就是 SIGTERMkill -9 pid 对应的是 SIGKILL

使用 channel 在程序中捕获信号 #

来段简单的示例程序:

~/Projects/go/examples
➜  mkdir shutdown && cd shutdown

~/Projects/go/examples/zerovalue
➜  go mod init youwu.today/go/shutdown

~/Projects/go/examples/zerovalue
➜  touch main.go

上面这段是否需要完全看个人,它仅会影响下面 go build 之后的可执行程序名称。

// main.go
package main

import (
    "fmt"
    "os"
    "os/signal"
)

func main() {
    c := make(chan os.Signal, 1)
    // 监听信号
    signal.Notify(c)

    println("程序启动")
    fmt.Println("进程id: ", os.Getpid())
    // 捕获到信号
    r := <-c
    fmt.Println("信号: ", r)
}

解释:

  • c 是一个存储 os.Signal 类型的 channel 变量,用来接收操作系统发送的信号。
  • signal.Notify监听操作系统传入信号,若有信号,会捕获到信号并存储在 c channel 中。
  • r := <-c,会阻塞当前线程,进程停止在这个地方等待 c channel 传入信号。若此有按下 ctrl+c,程序逻辑会继续往下。

signal.Notify(c)r := <- c 是 go 中特有的写法,刚开始接触的时候,可能会非常不习惯。如果按照其他语言的逻辑来辅助理解,signal.Notify 在当前程序执行顺序外启动一个监听线程(当然,这完全是想象),它并不受 <- c 这段阻塞代码影响,想想这在 c 或者 java 中得该如何实现。go 将其它语言中复杂的信道、信号接收、并发的逻辑简化成过程式代码,而且是在语言级别上的支持。非常棒,不愧是天生为并发设计的语言。

        ┌─┐                                 ┌─┐
      │ │ │                                 │ │
      │ │ │                  signal ──────▶ │ │
      │ │p│                                 │l│
      │ │r│                                 │i│
      │ │o│                                 │s│
      │ │c│                                 │t│
      ▼ │e│                                 │e│
        │s│                                 │n│
┌ ─ ─ ─ ┤s├ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐       │e│
   block│ │  wait for  ┌─────────┐          │r│
│  here │ │◀──signal───│ channel │◀─notify──│ │
        │ │            └─────────┘          │ │
│       └─┘                         │       └─┘
 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─           

编译并运行(以下过程演示中的 进程 id,是进程实例化时的操作系统进程号,每次都是不一样的):

~/Projects/go/examples/shutdown 
➜  go build && ./shutdown
程序启动
进程id:  69833 # 当前命令行窗口 `ctrl+c`
^C信号:  interrupt
~/Projects/go/examples/shutdown 
➜  go build && ./shutdown
程序启动
进程id:  68908 # 在另外一个 terminal 窗口 `kill 68908`
信号:  terminated
~/Projects/go/examples/shutdown 
➜  go build && ./shutdown
程序启动
进程id:  69571 # 在另外一个 terminal 窗口 `kill -9 69571`
[1]    69571 killed     ./shutdown # 这一行是 shell 打印的反馈信息,不是程序中的 println

以上示例通过 signal.Notify 来监听信号、信号 := <- 信号的 channel 方式来实现正常退出的控制,非常简单。

    c := make(chan os.Signal, 1)
    // 监听信号,并把捕获到的信号写入 c 中
    signal.Notify(c)
    // 捕获到信号
    r := <-c
    // 在这下面写退出前的清理逻辑,实现应用程序的友好退出

使用 Signal.Notify 限定具体信号 #

上面示例中的 signal.Notify(c) 会捕获到一切可以被捕获到的信号,但有时可能不会每一种能捕获到的信号都需要同样的友好退出前清理。那这时可以指定具体信号。signal.Notify 函数参数签名支持带有具体操作系统信号。

// main.go
func main() {
    interrupt()
}

func interrupt() {
    c := make(chan os.Signal, 1)
    // 监听信号
    signal.Notify(c, os.Interrupt)
    println("程序启动")
    fmt.Println("进程id: ", os.Getpid())
    // 捕获到信号
    var r os.Signal
    r = <-c
    fmt.Println("信号: ", r)
    // 在这里编写退出前清理逻辑
}
~/Projects/go/examples/shutdown 
➜  go build && ./shutdown
程序启动
进程id:  70974 # 在另一个 terminal 窗口 `kill 70974`
[1]    70974 terminated  ./shutdown # 注意,这里并不是 "信号:  terminated"

因为 signal.Notify(c, os.Interrupt) 只监听了 SIGINT 信息,所以非 SIGINT 终止信号都会让进程就地退出。signal.Notify 支持监控多种不同信号。

另外,signal.Notify 可以多次执行,用于接收多个信号。比如,你的命令行应用可能需要两次 ctrl+c 才能真正终止并退出,因为第一次可能会是误操作,加一次确认。如果你使用过 aria2 下载器,可以联想到在命令行终止它执行时,需要使用双 ctrl+c

...
func main() {
    double_ctrl_c()
}

func double_ctrl_c() {
    c := make(chan os.Signal, 1)
    // 监听并接收信号二镒
    signal.Notify(c, os.Interrupt)
    signal.Notify(c, os.Interrupt)

    println("程序启动")
    fmt.Println("进程id: ", os.Getpid())
    // 捕获到信号
    var r os.Signal
    r = <-c
    fmt.Println("信号1: ", r)
    r = <-c
    fmt.Println("信号2: ", r)
    // 在这里编写退出前清理逻辑
}
...

编译并运行:

~/Projects/go/examples/shutdown 
➜  go build && ./shutdown
程序启动
进程id:  72588
^C信号1:  interrupt
^C信号2:  interrupt
~/Projects/go/examples/shutdown 
➜  go build && ./shutdown
程序启动
进程id:  72725  # 在另一个 terminal 窗口执行 `kill 72725`
[1]    72725 terminated  ./shutdown

应用程序只监听了 SIGINT 信号,SIGTERM 不会正常被捕获到,而是看起来进程被强制中断了。

由于可以使用 signal.Notify 来限定信号,那么就可以根据不同的信号来定制不同的退出前逻辑,上面的 double_ctrl_c 仅是一个例子。

signal.Notify(c chan<- os.Signal, sig ...os.Signal) // 监听多种信号 //| 多次
...                                                              //| 监听
signal.Notify(c chan<- os.Signal, sig ...os.Signal) // 监听多种信号 //| 信号
...
消费 c chan 中的信号

使用 os.Exit 来指定返回状态 #

顺序说下,我们编写 shell 脚本时,较常会使用 $? 来判断最后一次命令的执行状态。然后 go程序可以在正常退出前,使用 os.Exit 来返回一个整数值,用来表示某种可能的结果。通常我们会使用 0 来表示执行成功,但并不代表一定需要使用 0. 这个值是多少,它有何种含义,完全是编写者自己决定,因为它取决于你想对这个返回值做何种后续响应动作。

// main.go
...
func main() {
    // interrupt()
    // double_ctrl_c()
    exitfunc()
}

func exitfunc() {
    os.Exit(4)
}
...

编译并运行:

~/Projects/go/examples/shutdown 
➜  go build && ./shutdown           

~/Projects/go/examples/shutdown 
echo $?
4
# 或
~/Projects/go/examples/shutdown 
➜  go run .              
exit status 4