信号量处理总结

背景

最近在做一个实时日志监控系统,系统架构是filebeat+logstash+twisted, 其中filebeat用来监控日志文件的新增变动,logstash格式化日志,twisted作为server,接收logstash的输入, 实时计算ctr, server的统计数据要每小时持久化一次,也就是要写进mysql数据库中。但是在写入Mysql的过程中不影响server接收请求处理。因此想到的方案是在进入下一个小时的这一时刻fork一个进程,然后再子进程中 进行写入mysql操作。这种方案和redis写入快照的方案一样,因为twisted和redis都是基于事件的单进程单线程服务器模型, 利用fork的copy on write,保证在子进程中数据和父进程不会混乱。这种方案是work的。 但是twisted中用信号量有一点小问题,就是不能用SIGCHLD这个信号量来通知父进程子进程退出了, 最终无奈让子进程在退出前向父进程发送SIGUSR1自定义信号量, 父进程在收到这个信号量时改变状态参数。 之前对信号量处理上有些模糊的地方,想通过本篇博客总结一下。

什么是信号量

Unix信号是Unix系统的一种软件形式异常,一个信号就是一条消息,它通知进程系统中发生了一个某种类型的事件。在linux下输入”man 7 signal”就能得到Linux系统上支持的30中不同类型的信号。

Signal Value Action Comment
SIGHUP 1 Term Hangup detected on controlling terminal or death of controlling process
SIGINT 2 Term Interrupt from keyboard
SIGQUIT 3 Core Quit from keyboard
SIGILL 1 Term Hangup detected on controlling terminal or death of controlling process
SIGINT 4 Core Illegal Instruction
SIGABRT 6 Core Abort signal from abort(3)
SIGFPE 8 Core Floating point exception
SIGKILL 9 Term Kill signal
SIGSEGV 11 Core Invalid memory reference
SIGPIPE 13 Term Broken pipe: write to pipe with noreaders
SIGALRM 14 Term Timer signal from alarm(2)
SIGTERM 15 Term Termination signal
SIGUSR1 30,10,16 Term User-defined signal 1
SIGUSR2 31,12,17 Term User-defined signal 2
SIGCHLD 20,17,18 Ign Child stopped or terminated
SIGCONT 19,18,25 Cont Continue if stopped
SIGSTOP 17,19,23 Stop Stop process
SIGTSTP 18,20,24 Stop Stop typed at terminal
SIGTTIN 21,21,26 Stop Terminal input for background process
SIGTTOU 22,22,27 Stop Terminal output for background process

还有其他的没有列出来,可以自行查阅。信号提供了一种机制,通知用户进程发生了这些异常。 比如一个进程试图除以0,那么内核就发送给它一个SIGFPE信号。如果进程进行非法存储器引用,内核就发送一条 SIGSEGV信号, 当一个子进程终止或停止时,内核发送一个SIGCHLD信号给父进程。

信号相关的操作函数

发送信号
  1. kill和raise函数

kill函数将信号发送给进程或进程组。raise函数则允许进程向自身发送信号。

#include <signal.h>
int kill(pid_t pid, int signo);
int raise(int signo);

两个函数返回值:成功0,失败-1

kill 的pid参数有4种不同的情况: - pid > 0 将改信号发送给进程ID为pid的进程 - pid == 0 将该信号发送给与发送进程同属一个进程组的所有进程, 不包括系统进程集. - pid < 0 将信号发送给其进程组ID等于pid的绝对值, 而且发送进程具有向其发送信号的权限. - pid == -1 将信号发送给有权限发送信号的所有进程。

编号为0的信号定义为空信号, 如果参数是0, kill不发送信号。如果pid不存在,则kill返回-1。

  1. alarm和pause函数
#include <signal.h>
unsigned int alarm(unsigned int seconds);

返回值:0或以前设置的闹钟时间的余留秒数

使用alarm函数可以设置一个计时器,在将来某个指定的时间该计时器会超时。当计时器超时,产生SIGALRM信号。 每个进程只能有一个闹钟时钟。如果在调用alarm时,以前已为改进程设置过闹钟时钟,而且他还没超时,则将该 闹钟时钟的余留值作为本次alarm函数调用的值返回。以前登记的闹钟时钟则被新替换。

#include <signal.h>
int pause(void);

返回值:-1, 并将errno设置为EINTR

只有执行了一个信号处理程序并返回时,pause才返回。在这种情况,pause返回-1, 并将errno设置EINTR. 通过pause和 alarm函数可以简单的实现sleep函数,但是有些点需要注意,具体请阅读《Unix环境高级编程》第10.10章节。

接收信号
#include <signal.h>

typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

进程可以通过使用signal函数修改和信号相关联的默认行为。唯一例外是SIGSTOP和SIGKILL,它们的默认行为不能被修改的。 signal函数可以通过下列三种方法之一来改变和信号signum相关联的行为: - 如果handler是SIG_IGN, 那么忽略类型为signum的信号。 - 如果handler是sig_DEF, 那么类型为signum的信号恢复默认行为. - 否则,handler就是用户定义的函数的地址,这个函数称为信号处理程序。

信号处理问题

对于只捕获一个信号并终止的程序来说,信号处理是简单直接的。然而,当一个程序要捕获多个信号时,一些细微的问题就产生了。 - 待处理信号被阻塞 Unix信号处理程序通常会阻塞当前处理程序正在处理的类型的待处理信号。比如,假设一个进程捕获了 一个SIGINT信号,并且当前正在运行它的SIGINT处理程序。如果另一个SIGINT信号传递到整个进程,那么这个SIGINT将变成待处理的, 但是不会被接收,直到处理程序返回。 - 待处理信号不会排队等待 任意类型至多只有一个待处理信号。因此,如果有两个类型为k的信号传送到一个目的进程,而由于 目前的进程当前正在执行信号k的处理程序,所以信号k是阻塞的,那么第二个信号就被简单地丢弃,它不会排队等待。 - 系统调用可以被中断 像read、wait和accept这样的系统调用。在某些系统中,当处理程序捕获到一个信号时,被中断的慢速系统调用 在信号处理程序返回时不再继续,而是立即返回给用户一个错误条件,并将errno设置为EINTR. - 同步流以避免讨厌的并发错误

看一个csapp书中第8.5.7章节的一个例子:

void handler(int sig) {
    pid_t pid;
    while ((pid = waitpid(-1, NULL, 0)) > 0)
        deletejob(pid);
    if (errno != ECHILD)
        unix_error("waitpid error");
}

int main(int argc, char **argv) {
    int pid;

    Signal(SIGCHLD, handler);

    initjobs(); // initialize the job list

    while (1) {
        // child process
        if ((pid = fork()) == 0) {
            Execve("/bin/date", argv, NULL);
        }

        // parent process
        addjob(pid);
    }
    exit(0);
}

异常情况:

  1. 父进程执行fork函数,内核调度新创建的子进程运行,而不是父进程.
  2. 在父进程能够再次执行运行前,子进程终止,并且变成一个僵死进程,使得内核传递一个SIGCHLD信号给父进程。
  3. 后来,当父进程再次变成可运行但又在它执行前,内核注意到待处理的SIGCHLD信号,并调用信号处理函数处理这个信号。
  4. 处理程序回收终止的子进程,并调用deletejob, 这个函数什么也不做,因为父进程还没有把孩子进程添加到列表。
  5. 在处理程序运行完毕,内核运行父进程,父进程从fork返回,通过调用addjob错误地把(已经不存在)子进程添加到作业列表中。

这是一个竞争(race)的经典同步错误的示例。

怎么解决:

通过在调用fork之前,阻塞SIGCHLD信号,然后再我们调用了addjob之后,就取消阻塞这个信号,我们保证了在子进程被添加到 作业列表中之后回收孩子进程。 注意,子进程继承了他们父进程的被阻塞集合,所以我们必须在调用execve之前,小心地解除子进程 中阻塞的SIGCHLD信号。

void handler(int sig) {
    pid_t pid;
    while ((pid = waitpid(-1, NULL, 0)) > 0)
        deletejob(pid);
    if (errno != ECHILD)
        unix_error("waitpid error");
}

int main(int argc, char **argv) {
    int pid;
    sigset_t mask;

    Signal(SIGCHLD, handler);

    initjobs(); // initialize the job list

    while (1) {
        Sigemptyset(&mask);
        Sigaddset(&mask, SIGCHLD);
        Sigprocmask(SIG_BLOCK, &mask, NULL);
        // child process
        if ((pid = fork()) == 0) {
            Sigprocmask(SIG_UNBLOCK, &mask, NULL);
            Execve("/bin/date", argv, NULL);
        }

        // parent process
        addjob(pid);
        Sigprocmask(SIG_UNBLOCK, &mask, NULL);
    }
    exit(0);
}
fork exec对子进程继承父进程信号处理机制的影响

当一个进程调用fork时,因为子进程在开始时复制父进程的存储映像,信号捕捉函数的地址在子进程中是有意义的,所以子进程继承父进程的信号处理方式。 特殊的是exec,因为exec运行新的程序后会覆盖从父进程继承来的存储映像,那么信号捕捉函数在新程序中已无意义,所以exec会将原先设置为要捕捉的信号都更改为默认动作。

  • fork后子进程会继承父进程的信号屏蔽字,再继续exec后仍会继承这个信号屏蔽字。同样地,直接调用system后子进程也会继承父进程的信号屏蔽字。
  • fork后子进程会继承父进程的信号处理设置,再继续exec后就不会继承这个信号处理设置了。
  • fork后子进程会继承父进程的控制终端,且子进程在父进程的进程组和会话组中;再继续exec后仍会继承这个控制终端,仍在父进程的进程组和会话组中。同样地,调用system后子进程会继承父进程的控制终端,且子进程在父进程的进程组和会话组中。
  • Ctrl+c产生的SIGINT信号会发送给父进程、fork后的子进程以及继续exec的子进程;同样地,也会发给system调用运行的子进程。

当一个进程调用fork时,因为子进程在开始时复制父进程的存储映像,信号捕捉函数的地址在子进程中是有意义的,所以子进程继承父进程的信号处理方式。 特殊的是exec,因为exec运行新的程序后会覆盖从父进程继承来的存储映像,那么信号捕捉函数在新程序中已无意义,所以exec会将原先设置为要捕捉的信号都更改为默认动作。

参考
  1. 《unix环境高级编程》
  2. 《深入理解计算机系统》