Golang中的信号处理

1. 什么是信号

信号（Signal) 是 OS 中的一种用来进行进程间通信的方法，对于 Linux 系统来说，信号就是软中断，用来通知进程发生了某个事件，通常用于中断进程的正常执行流，以便处理特定事件或者异常情况。

在不同的平台可能信号的定义会存在差异，每个信号对应着不同的值、动作和说明，在 Linux 系统中，我们可以使用 man signal查看对应的信号介绍。

这里提供了 POSIX signals 的参考，读者可以自行查看。一个信号可能对应多个值，这个是因为这些信号值与平台相关。

信号的默认行为中，Term 表明默认动作为终止进程，Ign 表明默认动作为忽略该信号，Core 表明默认动作为终止进程同时输出 core dump，Stop 表明默认动作为停止进程。

最后，SIGKILL 和 SIGSTOP 这两个信号既不能被应用程序捕获，也不能被操作系统阻塞或忽略。

2. Golang 中的信号处理

在 Golang 中提供了对应的信号处理包: os/signal，主要使用下面 2 个方法：

signal.Notify () 方法用来监听信号

signal.Stop () 方法用来取消监听

2.1 signal.Notify 方法

函数签名： func Notify(c chan <- os.Signal, sig ... os.Signal)

这个方法的第二个参数是一个变长列表，可以指定多个监听信号，这些信号会被转发至第一个参数中传入的通道中。如果未指定任何信号，则所有信号都会被转发。

这个通道 c 应该是非阻塞的，signal 包不会为了向 c 发送信息而阻塞（如果阻塞了，singal 包会直接放弃），一般如果使用单一信号通知，容量设置为 1 即可。

下面我们使用一个简单的例子来看看如何捕获信号：

Copy
package main

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

func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGHUP)
for {
s := <-ch
switch s {
case syscall.SIGINT:
fmt.Println("\nSIGINT!")
return
case syscall.SIGQUIT:
fmt.Println("\nSIGQUIT!")
return
case syscall.SIGTERM:
fmt.Println("\nSIGTERM: Elegant exit")
return
case syscall.SIGHUP:
fmt.Println("\nSIGHUP: Terminal connection disconnected")
return
default:
fmt.Println("\nUnknown Signal!")
return
}
}
}()
wg.Wait()
}

当运行了这个程序后，我们可以使用键盘快捷键或者使用 kill 命令向这个进程发送对应的信号。

一个使用的例子比如我们可以监听终止信号完成优雅退出：

Copy
package main

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

// 检测信号
func listenSignal(file *os.File) {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
for {
select {
case sig := <-c:
fmt.Printf("接收到信号 %s, 将退出\n", sig)
file.Close()
os.Exit(0)
}
}
}

func main() {
file, err := os.Open("example.txt")
if err != nil {
fmt.Println("无法打开文件:", err)
return
}
defer file.Close()

go listenSignal(file)

fmt.Println("程序正在运行，按下 control+c 退出")
select {} // 阻塞主线程
}

2.2 signal.Stop 方法

函数签名： func Stop(c chan<- os.Signal)

signal.Stop 方法会取消 channel 对信号的监听行为：

调用 signal.Stop (c) 后，会停止将任何信号转发到通道 c

参数 c 是一个只能发送 (send-only) 的信号通道

被 Stop 的通道可以再次调用 Notify 方法重新监听

下面的例子演示了一个 channel 调用了 Stop 方法停止监听后再重新监听的过程：

Copy
package main

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

func main() {
c := make(chan os.Signal, 1)

signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)

// 启动一个 goroutine 来处理信号
go func() {
for sig := range c {
fmt.Printf("接收到信号: %s\n", sig)
}
}()

// 模拟程序运行一段时间
fmt.Println("正在监听信号...")
time.Sleep(5 * time.Second)

// 停止信号通知
signal.Stop(c)
fmt.Println("信号通知已停止")

// 模拟程序运行一段时间，期间不会收到信号
time.Sleep(5 * time.Second)

// 重新注册信号通知
signal.Notify(c, syscall.SIGINT, syscall.SIGTERM)
fmt.Println("重新开始监听信号...")

// 再次模拟程序运行一段时间
time.Sleep(10 * time.Second)
}

另外，为了避免数据竞争和确保信号处理的正确性， signal.Stop 方法在内部维护一个 map，将信号类型映射到通道列表，当信号来的时候会将信号发送到所有的对应通道，当删除时，需要从这个 map 中删除对应的通道，但是直接删除可能出现 数据竞争 的情况，特别是在信号处理和通道删除存在并发操作的时候。

为了解决这个问题，它做了以下几件事：

临时存储即将停止的信号：当 Stop 方法被调用时，信号处理机制会将即将停止的信号通道存储在一个临时的列表（[] stopping）中，而不是立即从 map 中删除。

等待信号发送完毕：信号处理机制会保证所有正在进行的信号发送操作完成。

最终移除信号通道：一旦所有信号发生操作完成，信号处理机制会从 map 中正式移除通信号通道，确保不会再有信号发送到已经停止的通道中。