Go 中的信号处理

1. 什么是信号

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

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

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

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

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

2. Go中的信号处理

在 Go 中提供了对应的信号处理包: 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即可。

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

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 命令向这个进程发送对应的信号。

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

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 方法停止监听后再重新监听的过程:

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 中删除对应的通道,但是直接删除可能出现 数据竞争 的情况,特别是在信号处理和通道删除存在并发操作的时候。

为了解决这个问题,它做了以下几件事:

  1. 临时存储即将停止的信号:当 Stop 方法被调用时,信号处理机制会将即将停止的信号通道存储在一个临时的列表([] stopping)中,而不是立即从 map 中删除。
  2. 等待信号发送完毕:信号处理机制会保证所有正在进行的信号发送操作完成。
  3. 最终移除信号通道:一旦所有信号发生操作完成,信号处理机制会从 map 中正式移除通信号通道,确保不会再有信号发送到已经停止的通道中。

评论区
头像
    头像
    qvycayhkil
      

    案例丰富且贴合主题,论证逻辑环环相扣。

    头像
    fgahxyripv
      

    跨文化对比分析视角值得深入探索。

    头像
    iujpqioaxm
      

    文章中的实用建议和操作指南,让读者受益匪浅,值得珍藏。

    头像
    gorisbslsq
      

    作者以非凡的视角解读平凡,让文字焕发出别样的光彩。