gsignal,信号大师

注:该文章基于Go 1.13

signal包提供了一些允许Go程序与信号量交互的方法。在深入前,我们先从这段监听器代码开始。

信号订阅

我们使用通道(channel)来订阅信号。以下代码监听中断信号终端缩放

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
package main

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

func main() {
done := make(chan bool, 1)

s1 := make(chan os.Signal, 1)
signal.Notify(s1, syscall.SIGINT, syscall.SIGTERM)

go func(){
<-s1
fmt.Println(`/!\ The program is going to exit...`)
done <- true
}()

s2 := make(chan os.Signal, 1)
signal.Notify(s2, syscall.SIGWINCH)

go func(){
for {
<- s2
fmt.Println(`/!\ The terminal has been resized.`)
}
}()

<- done
}

每个通道都有自己的事件逻辑,如图:

Go也提供了停止向通道发送通知的方法Stop(os.Signal),或者忽略信号的方法Ignore(...os.Signal)。举例:

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
package main

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

func main() {
done := make(chan bool, 1)

s2 := make(chan os.Signal, 1)
signal.Notify(s2, syscall.SIGWINCH)
signal.Ignore(syscall.SIGINT)

go func(){
<- s2
fmt.Println(`/!\ The terminal has been resized.`)
signal.Stop(s2)

// will block forever since we stop listening
<- s2
done <- true
}()

<- done
}

这段程序无法通过CTL+C终止,而且在首次接收到终端缩放信号时,停止了该通道,导致其此后再接收不到任何信号。那么接下来,我们看看信号是如何被监听和处理的。

gsignal

在初始化阶段,signal派生了一个在循环中处理信号量的消费者协程。该协程处在休眠状态,直到接收到通知。过程如图:

当信号发到达程序时,信号句柄将该信号量代理给一个特殊的协程gsignal。这个协程初始化时使用了一个很大的栈空间(32k,为满足不同操作系统的要求),该栈大小固定不会再增长。每一个线程(图中标以M)都有一个gsignal协程来处理信号量。如图:

gsignal分析信号量并判断若能够被处理,则唤醒休眠的协程同时将信号量发送到队列。

同步信号量,如SIGBUSSIGFPE,无法被处理并会导致panic。

然后,looping协程能够处理该信号。它找到订阅了该信号的第一个通道并将信号推送给它。

处理信号的looping协程可以通过go tool trace可视化的观察。

gsignal加锁或阻塞会导致处理信号异常。同时因其有固定大小的栈空间,无法重分配内存。这就是为何在处理信号量的链路中需要两个独立的协程:一个协程将到达的信号尽快存储到队列,另一个协程循环的处理这个队列。

此时,我们可以对文中第一张图最终修改如下: