Ubuntu中signal()函数可靠性研究

作者: lesca 分类: Concept,Kernel,Ubuntu 发布时间: 2011-03-13 17:40

signal()函数由ISO C定义,而ISO C不涉及多进程、进程组以及终端I/O等,所以它对信号的定义非常模糊。因此在很多类UNXI系统中其实现是否具有不可靠性是值得探讨的问题。本文将以Ubuntu系统为例(当前内核版本Linux version 2.6.32-29-generic),针对各种不可靠性以及缺陷,设计程序,以验证是否存在该种不可靠性或缺陷。

原创文章,转载请注明来自http://lesca.me/
原型:

void (*signal(int signo, void (*func)(int)))(int);

原型简化:

typedef void (Sigfunc)(int);
Sigfunc *signal(int , Sigfunc *);

说明:
Sigfunc是信号处理函数,Sigfunc*为指向该函数的指针类型。
signal函数输入两个参数:int和Sigfunc*
返回一个指针:Sigfunc*,这个指针指向上一次传入的Sigfunc*
自然语言表述之,就是捕获一个信号,需要信号的名字(int型),以及捕获后怎么处理(Sigfunc*),处理完毕后返回上一次处理这个信号的函数地址;如果信号处理函数注册过程中遇到错误将返回SIG_ERR

三种信号动作:

  • 默认动作
  • 忽略信号
  • 捕获信号
  • 注意:SIGKILL和SIGSTOP不可捕获

什么是信号:

简而言之,信号是一种软件中断,提供了一种处理异步异步的方法。
所谓异步事件,是指相对软件的运行来说,其发生是随机的。例如键盘输入中断按键(^C),它的发生在程序执行过程中是不可预测的。
硬件异常也能产生信号,例如被零除、无效内存引用等。这些条件通常先由内核硬件检测到,然后通知内核。内核将决定产生什么样的信号。

signal()函数的不可靠性及缺陷:
所谓不可靠,是指信号可能会丢失,这主要是由于signal()实现时存在的缺陷造成的:

  1. 每次接到信号后,该信号复位成默认动作
  2. 不改变信号的处理方式就无法确定当前的信号处理方式
  3. 无法避免地导致系统调用的中断
  4. 例如进程正在读一个低速设备,当没有读完时被一个信号中断,当信号处理函数返回时,进程不会继续刚才的系统调用,而是执行下一条语句。

  5. 进程不能关闭某些不想捕获的信号
  6. 进程只能忽略它们,但有时候这并不是我们希望的样子;另外如果不忽略它们,可能会导致进程在执行临介区代码时,被意外中断,从而引发其他问题。

我们将先探讨这些缺陷,并提供一些解决的方法。这些解决方法可能仍然不可靠,这只是为了说明问题。

缺陷1:自动复位成默认动作

我们观察下面的代码:
[cpp]
#include "apue.h" // for err_sys()
#include <signal.h>

static void
sig_int(int signo)
{
/* Uncomment this area to solve this issue
if(signal(SIGINT, sig_int) == SIG_ERR)
err_sys("sig_int: can’t catch SIGINT");
*/
printf("SIGINT caught!\n");
}

int main()
{
if(signal(SIGINT, sig_int) == SIG_ERR)
err_sys("main: can’t catch SIGINT");

while(1)
pause(); // wait for signal happen

}
[/cpp]
运行结果

$ ./a.out
^CSIGINT caught!
^C

结论:
可见,signal函数默认将复位信号动作。我们可以通过在运行信号处理函数的时候再次注册来解决这个问题。
但是这会引发新的问题:从调用信号处理函数到信号处理函数执行signal()之间存在时间窗口,另一个SIGINT信号可能在此之间产生并导致程序退出。下面我们来验证这一点:
[cpp]
static void
sig_int(int signo)
{
volatile unsigned long i, j;
printf("SIGINT caught!\n");
for(i = 0; i < 10000; i++)
for(j = 0; j < 10000; j++)
;
if(signal(SIGINT, sig_int) == SIG_ERR)
err_sys("sig_int: can’t catch SIGINT");

}
[/cpp]
我们只修改了信号处理函数,使信号在注册前延时一段时间,以便我们有时间再发送一个SIGINT信号。这里的volatile属性用于避免编译器对延时循环的优化处理,这种优化会导致这个循环无效。运行结果如下:

$ ./Chap10
^CSIGINT caught!
^C

结论:
将时间窗口放大后,我们看到进程不幸被终止了。在实际运行过程中,我们很难保证从信号处理函数到再次注册之间不被这个信号再次终止。即使这种情况非常罕见,我们也不应该忽略这个问题。

缺陷2:不改变信号的处理方式就无法确定当前的信号处理方式

假设我们坚持使用具有缺陷的signal()函数来写,以确定当前的信号动作,那么必须包含以下代码:
[cpp]
if (signal(SIGINT, SIG_IGN) != SIG_IGN))
signal(SIGINT, sig_int);
[/cpp]
这段代码将判断当前信号是否被忽略,如果没有才注册这个信号。问题是这两个signal()函数之间存在时间窗口,如果在检测到当前信号不被忽略,但在这个信号注册前,发生了一个SIGINT信号,而且这个信号只发生一次,那么我们将丢失这个信号。

缺陷3:无法避免地导致系统调用的中断

进程正在读一个低速设备,且没有读完时被一个信号中断,当信号处理函数返回时,进程不会继续刚才的系统调用,而是执行下一条语句。
这个缺陷也许不会导致信号丢失,但是它使得内核不能完整地完成一个系统调用。我们来看一个例子:
[cpp]
#include "apue.h" // for err_sys()
#include <fcntl.h> // for open(), read()
#include <errno.h> // for errno
#include <signal.h>

static void
sig_int(int signo)
{
if(signal(SIGINT, SIG_IGN) == SIG_IGN)
signal(SIGINT, sig_int);
printf("SIGINT caught!\n");
}

#define BUFSIZE 4096

int main()
{
int fd;
char buf[BUFSIZE];

if(signal(SIGINT, sig_int) == SIG_ERR)
err_sys("main: can’t catch SIGINT");

if((fd = open("/dev/random", O_RDONLY)) < 0) // without O_NONBLOCK
err_sys("main: can’t open device");

errno = 0;
if(read(fd, buf, BUFSIZE) < 0)
{
if(errno == EINTR)
fprintf(stderr, "Read Interrupted by Signal\n");
else
perror("read");
exit(0);
}
printf("Read finished\n");
exit(0);
}
[/cpp]
这个程序将以阻塞方式打开/dev/random设备,这个设备收集并产生随机数,这个过程需要一定时间,因此被视为一个低速设备。
我们对它进行读操作,并在此过程中引发一个中断,以观察系统调用read()被打断之后,是继续下面的语句,还是自动恢复read()调用。
如果自动恢复,将会输出Read finished,否则将输出read()调用失败的原因。
运行:

$ ./a.out
Read finished
$ ./a.out
^CSIGINT caught!
Read Interrupted by Signal

分析及结论:
我们需要执行a.out程序两次,第一次将消耗random设备缓存里的随机数,然后立即运行第二次,这时候read()发生了阻塞。
我们立即发送SIGINT信号,输出结果为Read Interrupted by Signal
这表明:signal()确实中断了系统调用。能够被中断的系统调用还有:ioctl, read, readv, write, writev, wait, waitpid。

不可靠性:进程不能关闭某些不想捕获的信号

进程不希望某种信号发生时,它不能关闭信号,只能忽略该信号。而有时我们希望通知系统“阻止信号的发生,如果它确实发生了,则记住它们”。以下是一个经典的例子:
[cpp]
#include "apue.h" // for err_sys()
#include <signal.h>

int sig_int_flag = 0;

static void
sig_int(int signo)
{
sig_int_flag = 1;
signal(SIGINT, sig_int);
printf("SIGINT caught!\n");
}

int main()
{
if(signal(SIGINT, sig_int) == SIG_ERR)
err_sys("main: can’t catch SIGINT");

while(sig_int_flag == 0)
pause(); // wait for signal happen

}
[/cpp]
当信号发生后,信号处理函数将标志位置位,以便通知进程。在这里,进程继续运行。遗憾的是判断标志位与pause()之间存在一个时间窗口,信号可能在判断之后、pause()之前发生。假如这个信号仅发生一次,那么该进程将一直阻塞下去,并且丢失这个信号。

总结:

从这些缺陷的例子中我们可以看到,使用已经过时的、存在语义不完整性的signal()函数将会导致一些不易察觉的问题。
这仅仅是Ubuntu(Linux 2.6.32)下signal存在的问题,而不同unix系统之间又存在一些差异,因此,使用signal()也会降低程序的可移植性。
为了避免这些问题,请使用POSIX.1规范的sigaction()等函数。具体请参阅可靠signal()函数的实现一文。

版权声明

本文出自 Lesca 技术宅,转载时请注明出处及相应链接。

本文永久链接: https://www.lesca.cn/archives/reliability-of-signal-function-on-ubuntu.html

如果觉得我的文章对您有用,请随意赞赏。您的支持将鼓励我继续创作!

2 Comments
  • faicker

    2011-12-07 at 17:34

    自动复位成默认动作
    有问题啊,man signal

  • manuscola

    2014-01-02 at 16:15

    自动复位有问题。早已经解决了这个问题了。
    Linux signal系统调用有这个问题,但是glibc的 signal函数没有这个问题。