Golang Signal

信号是事件发生时对进程的通知机制。有时也称之为软件中断。信号与硬件中断的相似之处在于打断了程序执行的正常流程,大多数情况下,无法预测信号到达的精确时间。因为一个具有合适权限的进程可以向另一个进程发送信号,这可以称为进程间的一种同步技术。当然,进程也可以向自身发送信号。然而,发往进程的诸多信号,通常都是源于内核。引发内核为进程产生信号的各类事件如下。

  • 硬件发生异常,即硬件检测到一个错误条件并通知内核,随即再由内核发送相应信号给相关进程。比如执行一条异常的机器语言指令(除 0,引用无法访问的内存区域)。
  • 用户键入了能够产生信号的终端特殊字符。如中断字符(通常是 Control-C)、暂停字符(通常是 Control-Z)。
  • 发生了软件事件。如调整了终端窗口大小,定时器到期等。

针对每个信号,都定义了一个唯一的(小)整数,从 1 开始顺序展开。系统会用相应常量表示。Linux 中,1-31 为标准信号;32-64 为实时信号(通过 kill -l 可以查看)。

信号达到后,进程视具体信号执行如下默认操作之一。

  • 忽略信号,也就是内核将信号丢弃,信号对进程不产生任何影响。
  • 终止(杀死)进程。
  • 产生 coredump 文件,同时进程终止。
  • 暂停(Stop)进程的执行。
  • 恢复进程执行。

当然,对于有些信号,程序是可以改变默认行为的,这也就是 os/signal 包的用途。

兼容性问题:信号的概念来自于 Unix-like 系统。Windows 下只支持 os.SIGINT 信号。

Go 中的 Signal 发送和处理

Go 对信号的处理,无法捕获信号 SIGKILL 和 SIGSTOP (终止和暂停进程),因此 os/signal 包对这两个信号无效。

Go对信号的默认行为

Go 语言实现了自己的运行时,因此,对信号的默认处理方式和普通的 C 程序不太一样。

  • SIGBUS(总线错误), SIGFPE(算术错误)和 SIGSEGV(段错误)称为同步信号,它们在程序执行错误时触发,而不是通过 os.Process.Kill 之类的触发。通常,Go 程序会将这类信号转为 run-time panic。
  • SIGHUP(挂起), SIGINT(中断)或 SIGTERM(终止)默认会使得程序退出。
  • SIGQUIT, SIGILL, SIGTRAP, SIGABRT, SIGSTKFLT, SIGEMT 或 SIGSYS 默认会使得程序退出,同时生成 stack dump。
  • SIGTSTP, SIGTTIN 或 SIGTTOU,这是 shell 使用的,作业控制的信号,执行系统默认的行为。
  • SIGPROF(性能分析定时器,记录 CPU 时间,包括用户态和内核态), Go 运行时使用该信号实现 runtime.CPUProfile
  • 其他信号,Go 捕获了,但没有做任何处理。

信号可以被忽略或通过掩码阻塞(屏蔽字 mask)。忽略信号通过 signal.Ignore,没有导出 API 可以直接修改阻塞掩码,虽然 Go 内部有实现 sigprocmask 等。Go 中的信号被 runtime 控制,在使用时和 C 是不太一样的。

改变信号的默认行为

这就是 os/signal 包的功能。Notify 改变信号处理,可以改变信号的默认行为;Ignore 可以忽略信号;Reset 重置信号为默认行为;Stop 则停止接收信号,但并没有重置为默认行为。golang中对信号的处理主要使用os/signal包中的两个方法:

  • notify方法用来监听收到的信号
  • stop方法用来取消监听
1
2
3
4
5
6
7
8
9
10
func main()  {
//合建chan
c := make(chan os.Signal)
//监听所有信号
signal.Notify(c)
//阻塞直到有信号传入
fmt.Println("启动")
s := <-c
fmt.Println("退出信号", s)
}

编译 go build example-1.go

启动 ./example-1

ctrl+c退出,输出 退出信号 interrupt

kill pid 输出 退出信号 terminated

1
2
3
4
5
6
7
8
9
10
11
12
// 监听指定信号
func main() {
//合建chan
c := make(chan os.Signal)
//监听指定信号 ctrl+c kill
signal.Notify(c, os.Interrupt, os.Kill, syscall.SIGUSR1, syscall.SIGUSR2)
//阻塞直到有信号传入
fmt.Println("启动")
//阻塞直至有信号传入
s := <-c
fmt.Println("退出信号", s)
}

编译 go build example-2.go

启动 ./example-2

ctrl+c退出,输出 退出信号 interrupt

kill pid 输出 退出信号 terminated

kill -USR1 pid 输出 退出信号 user defined signal 1

kill -USR2 pid 输出 退出信号 user defined signal 2

Notify 函数

func Notify(c chan<- os.Signal, sig ...os.Signal)

类似于绑定信号处理程序。将输入信号转发到 chan c。如果没有列出要传递的信号,会将所有输入信号传递到 c;否则只传递列出的输入信号。

channel c 缓存如何决定?因为 signal 包不会为了向 c 发送信息而阻塞(就是说如果发送时 c 阻塞了,signal 包会直接放弃):调用者应该保证 c 有足够的缓存空间可以跟上期望的信号频率。对使用单一信号用于通知的 channel,缓存为 1 就足够了。

相关源码:

1
2
3
4
5
6
7
8
9
10
// src/os/signal/signal.go process 函数
for c, h := range handlers.m {
if h.want(n) {
// send but do not block for it
select {
case c <- sig:
default: // 保证不会阻塞,直接丢弃
}
}
}

可以使用同一 channel 多次调用 Notify:每一次都会扩展该 channel 接收的信号集。唯一从信号集去除信号的方法是调用 Stop。可以使用同一信号和不同 channel 多次调用 Notify:每一个 channel 都会独立接收到该信号的一个拷贝。

优雅关机

http-server运行过程中,若进程被关闭,那么正在处理的请求可能只被处理了一半就停止了,可能会产生数据的不一致。优雅关机是指:

  • 首先,停止接收新请求;
  • 然后,等待队列中的请求被处理完毕;
  • 最后,应用程序退出;

net/http如何实现优雅关机

net/http原生支持优雅关机。

首先,在goroutine中启动http-server:

1
2
3
4
5
6
7
8
9
10
11
srv := &http.Server{
Addr: ":8090",
Handler: r,
}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal("listen: ", err)
} else {
log.Println("ListenAndServe break")
}
}()

然后,在main中监听关机信号:

  • SIGTERM: kill的默认信号;
  • SIGINT:kill -2,一般是Ctrl+C的退出;
  • SIGKILL:kill -9,捕获不到;
1
2
3
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<- quit

最后,监听到关机信号后,执行优雅关机:

  • 调用srv.Shutdown()执行优雅关机;
  • 默认等待所有队列中的请求处理完毕后,才返回;
  • 传入timeoutContext,增加超时时间,超时时间到后返回;
1
2
3
4
5
ctx, cancel := context.WithTimeout(context.TODO(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server shutdown: ", err)
}

net/http优雅关机的实现原理

优雅关机是调用net/http的srv.Shutdown(ctx)实现的,该方法会:

  • 先拒绝后面的connection请求;
  • 然后再慢慢的处理未处理完毕的请求;
  • 当未处理的请求一旦被处理完毕,其connection变成Idle,然后被Close()掉;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func (srv *Server) Shutdown(ctx context.Context) error {
...
for _, f := range srv.onShutdown {
go f()
}
...

ticker := time.NewTicker(shutdownPollInterval)
defer ticker.Stop()
for {
if srv.closeIdleConns() { //关闭idle连接
return lnerr
}
select {
case <-ctx.Done(): //ctx到期,如cancel()被调用
return ctx.Err()
case <-ticker.C:
}
}
}

另外,当调用Shutdown()后,srv.ListenAndServer方法将退出,并返回ErrServerClose错误。