信号处理的机制
在 Linux 中,每个进程都拥有两个位向量,这两个位向量共同决定了进程将如何处理信号:
- 一个是
pending
位向量,它包含了那些内核发送给进程,但还没有被进程处理掉的信号。 - 另一个是
blocked
位向量,它包含了那些被进程屏蔽掉的信号。
当内核发送一个信号给进程时,它将会修改进程的pending
位向量,譬如说,当内核发送一个SIGINT
信号给进程,那么它会将进程的pending[SIGINT]
的值设置成 1:
同样地,当进程屏蔽掉一个信号时,那么它会修改
blocked
位向量。那么信号屏蔽是什么意思呢?当进程屏蔽掉一个信号之后,内核仍然可以发送这个信号给进程(保存在进程的pending
位向量中),但进程不会接收并处理这个信号。只有当进程解除了对这个信号的屏蔽之后,进程才会接收并处理这个信号。让我们从内核的角度看,大概是这样的:当内核执行 context switch 切换到某个进程的时候,它会检查进程的
pending
和blocked
位向量。如果发现进程还有信号未处理,同时这个信号没有被进程屏蔽,那么内核就会让进程接收并处理这个信号。用伪代码可以这样表示:
|
|
譬如说,下面的程序一开始就屏蔽了SIGINT
信号,所以即使内核发送SIGINT
信号给这个程序,这个信号也不会得到处理。而当程序解除了对SIGINT
的屏蔽之后,这个SIGINT
信号才会得到处理:
安全地处理信号
通常来说,信号中断有两种情况:
- 当进程接收到某个信号时,会调用这个信号的 handler,这会中断主程序的执行。
- 当进程在执行某个信号 handler 的过程中,可能会被另一个信号 handler 中断。
上面这两种情况都会带来并发安全的问题,因此在编写信号 handler 时,需要考虑到并发安全的问题。譬如说,由于信号 handler 会中断主程序的执行,如果信号 handler 与主程序共享全局变量,就可能带来并发安全的问题。
信号 handler 与主程序共享全局变量是很常见的。譬如说,当进程在接收到SIGINT
时,为了优雅地退出程序,这时可以使用一个全局变量记录是否接收到SIGINT
信号。主程序每次进入循环时都会检查这个变量,如果发现进程接收到SIGINT
信号,就释放好资源并退出程序:
上面的代码并不是并发安全的,可能导致两个问题:
- 现代编译器通常会优化程序对变量的访问。主程序可能会将
quit
的副本存储在寄存器中,每次访问quit
时就从寄存器中访问。那么即使信号 handler 修改了这个quit
在内存中的值,主程序也可能不知道。 - 主程序会读取
quit
的值,信号 handler 会改变quit
的值,而这两个操作都不保证是原子的。
我们可以这样解决这两个问题:
- 首先将
quit
声明为volatile
变量。volatile
可以阻止编译器所做的优化,这样信号 handler 和主程序访问quit
时都会从主内存中访问。 - 其次将
quit
的类型改成sig_atomic_t
,因为 Linux 保证对sig_atomic_t
变量的读写操作都是原子的(不会被信号中断)。
也就是说,只需要改变一行代码:
前面我们说到,当进程在执行某个信号 handler 的过程中,可能会被另一个信号 handler 中断。这也会导致并发安全的问题。为了保证并发安全,在信号 handler 中,我们只能调用异步信号安全的函数,这类函数要不就是可重入的,要不就是不会被信号 handler 中断的。
可以使用man 7 signal
命令查看哪些系统调用是异步信号安全的。常见的函数,譬如printf()
和exit()
就不是异步信号安全的,所以在信号 handler 可以使用write()
来替代printf()
,使用_exit()
来替代exit()
。具体可以这样做:
I/O 多路复用与信号
在 Linux 中处理信号是极为麻烦的事情,正如 Linux 标准指出的,当select()
、poll()
和epoll_wait()
被信号中断之后,它们是决不会重启的,所以说如果这些函数被信号中断,我们只好手动重启它们:
所幸的是 Linux 提供了signalfd()
函数,signalfd()
可以将接收到的信号,转化为文件描述符的可读事件,所以signalfd()
可以和 select/poll/epoll 配合使用,大大简化信号处理的难度。
下面的例子将signalfd()
与 epoll 配合使用,signalfd()
负责将接收到的SIGINT
和SIGHUP
转换为文件描述符的可读事件:
跨平台代码
通常来说,我们可以使用signal()
为信号注册一个 handler,然而在编写跨平台代码时,则不应该使用signal()
,因为signal()
在不同平台会表现出不一致的行为。譬如说,在某些 Uninx 系统中,signal()
会出现这样的行为:
- 每次调用信号 handler 之后,
signal()
会自动将信号的 handler 重置成SIG_DFL
。 - 类似于
read()
和write()
这类调用,如果它们被信号 handler 中断,是不会自动重启的。
如果考虑编写跨平台代码,所以我们的程序中应该使用sigaction()
来代替signal()
。当然,sigaction()
的使用比较复杂,所以我们提供了一个Signal()
,可以用来代替signal()
函数: