Linux 信号系统

阅读数:3800 2016 年 4 月 12 日

本文主要介绍 Linux 信号系统和如何使用 POSIX API 来响应信号。本文中的示例适用于 Linux 系统和大部分 POSIX 兼容系统。

Linux 系统中的信号

在下列情况下,我们的应用进程可能会收到系统信号:

  • 用户空间的其他进程调用了类似 kill(2) 函数
  • 进程自身调用了类似 about(3) 函数
  • 当子进程退出时,内核会向父进程发送 SIGCHLD 信号
  • 当父进程退出时,所有子进程会收到 SIGHUP 信号
  • 当用户通过键盘终端进程(ctrl+c)时,进程会收到 SIGINT 信号
  • 当进程运行出现问题时,可能会收到 SIGILL、SIGFPE、SIGSEGV 等信号
  • 当进程在调用 mmap(2) 的时候失败(可能是因为映射的文件被其他进程截短),会收到 SIGBUS 信号
  • 当使用性能调优工具时,进程可能会收到 SIGPROF。这一般是程序未能正确处理中断系统函数(如 read(2) )。
  • 当使用 write(2) 或类似数据发送函数时,如果对方已经断开连接,进程会收到 SIGPIPE 信号。

如需了解所有系统信号,参见 signal(7) 手册。

信号的默认行为

每个信号都关联一个默认的行为,当进程没有捕获并处理信号时,进程会按照默认的行为处理信号。

这些默认行为包括:

  • 结束进程。这是最通用默认行为,包括 SIGTERM、SIGQUIT、SIGPIPE、SIGUSR1、SIGUSR2 等信号。
  • 结束并执行核心转储。包括 SIGSEGV、SIGILL、SIGABRT 等信号,这一般都是因为代码中存在错误。
  • 一些信号默认会被忽略,例如 SIGCHLD。
  • 挂起进程。SIGSTOP 信号会引起进程挂起,而 SIGCOND 能够将挂起的进程继续运行。该过程常见于在控制台使用 ctrl+z 组合键。

信号处理

最传统的信号处理方式是使用 signal(2) 函数装载一个信号处理函数。但是这种方式已经被废弃,主要原因是在 UNIX 实现中,收到信号之后,会重置回默认的信号处理行为。同时,该行为是不跨平台的。因此,建议的信号处理方式是使用 sigaction(2) 函数。

sigaction(2) 函数的原型为:

int sigaction (int signum, const struct sigaction *act, struct sigaction *oldact);

值得注意的是,sigaction(2) 函数不直接接受信号处理函数,而需要使用struct sigaction结构体,其定义为:

struct sigaction {
        void     (*sa_handler)(int);
        void     (*sa_sigaction)(int, siginfo_t *, void *);
        sigset_t   sa_mask;
        int        sa_flags;
        void     (*sa_restorer)(void);
};

其中一些关键字段:

  • sa_handler:信号处理函数的函数指针,其函数原型和 signal(2) 接受的信号处理函数相同。
  • sa_sigaction:另一种信号处理函数指针,它能在处理信号时获取更多信号相关的信息。
  • sa_mask:允许设置信号处理函数执行时需要阻塞的信号。
  • sa_flags:修改信号处理函数执行时的默认行为,具体可选值请参照手册。

sigaction 使用示例:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>

static void hdl (int sig, siginfo_t *siginfo, void *context)
{
    printf ("Sending PID: %ld, UID: %ld\n",
            (long)siginfo->si_pid, (long)siginfo->si_uid);
}

int main (int argc, char *argv[])
{
    struct sigaction act;

    memset (&act, '\0', sizeof(act));

    /* 这里使用 sa_sigaction 字段,因为该字段提供了两个额外的参数,
    可以获取关于接收信号的更多信息。 */
    act.sa_sigaction = &hdl;

    /* SA_SIGINFO 标识告诉 sigaction 函数使用 sa_sigaction 字段,而非 sa_handler 字段 */
    act.sa_flags = SA_SIGINFO;

    if (sigaction(SIGTERM, &act, NULL) < 0) {
        perror ("sigaction");
        return 1;
    }

    while (1)
        sleep (10);

    return 0;
}

该示例中使用了三个参数版本的信号处理函数来响应 SIGTERM 信号,编译(假设源文件名为 sig.c)并执行程序,可以有以下输出:

gcc -o sig sig.c
./sig &
kill $!

Sending PID: 16200, UID: 1000

注意,使用三参数版本信号处理函数时,必须将 sa_flags 字段设置为 SA_SIGINFO,否则信号处理函数将无法获取到正确的siginfo_t对象。

对于siginfo_t结构体, sigaction(2) 的手册中有详细介绍,其中的几个字段非常有用:

  • si_code:用于标识信号的来源,例如 kill(2) raise(3) 等通过程序调用产生的信号,该值为 SI_USER;而由内核发送的信号,该值为 SI_KERNEL。
  • 对于 SIGCHLD 信号,可以从 si_status 字段(进程退出码)、si_utime 字段(进程消耗的用户态时间)和 si_stime 字段(进程消耗的内核态时间)获取更多信息。
  • 对于 SIGILL、SIGFPE、SIGSEGV、SIGBUS 等信号,可以从 si_addr 字段获取发生错误的内存地址。

常见问题

由于信号处理函数是异步执行且无法预知执行时间,因此编码时需要特别注意异步执行产生的问题,尤其是主函数和信号处理函数之间共享的数据。

首先是编译器优化。如果一个变量在主函数中循环读取,信号处理函数中修改(例如一个退出标识),这时编译器优化可能导致信号处理函数中的修改无法让主函数感知到。例如如下代码:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <string.h>

static int exit_flag = 0;

static void hdl (int sig)
{
    exit_flag = 1;
}

int main (int argc, char *argv[])
{
    struct sigaction act;

    memset (&act, '\0', sizeof(act));
    act.sa_handler = &hdl;
    if (sigaction(SIGTERM, &act, NULL) < 0) {
        perror ("sigaction");
        return 1;
    }

    while (!exit_flag)
        ;

    return 0;
}

如果使用 gcc O2 级别的优化,该程序会按照预期,在接收到 SIGTERM 信号时退出。但是,如果优化级别调整到 O3,向进程发送 SIGTERM 信号之后,进程还会继续运行(假设文件名为 test_sig.c):

gcc -o test -O3 test_sig.c
./test &
killall test

这时控制台不会提示后台进程退出,使用jobs命令查看后,test 进程仍然存在:

jinlingjie@localhost ~/data/Downloads $ ./test &
[1] 2532
jinlingjie@localhost ~/data/Downloads $ killall test
jinlingjie@localhost ~/data/Downloads $ jobs
[1]+  运行中               ./test &

这是因为在 O3 级别的优化中,编译器发现while循环会不停读取exit_flag变量,为了加快读取速度,编译器会把该变量值直接加载到寄存器中,而不再每次从内存读取。此时信号处理函数再修改exit_flag变量,不会被更新到寄存器中,因此进程无法退出。对于这种场景,需要给共享变量增加volatile关键字,以确保进程每次读取变量时,都去内存重新获取最新的值。

上面的示例中的场景,还需要考虑对共享变量修改的原子性。在一些平台上int类型的读取或者写入可能不是原子的。信号系统提供 sig_atomic_t 对象,以确保原子的读写。

除此以外,编写信号处理函数还需要注意信号安全。因为信号处理函数调用的其他函数也有可能被信号中断, signal(7) 手册的 Async-signal-safe functions(异步信号安全函数)章节详细列举了所有在信号处理函数中可以安全调用的函数。

特殊信号处理

SIGCHLD 信号

如果父进程不需要获取子进程的退出状态码,也不需要等待子进程的退出,唯一的目的是清理僵尸进程。那么,父进程只需要处理 SIGCHLD 信号,并进行清理即可:

static void sigchld_hdl (int sig)
{
    /* 等待所有已经退出的子进程。
     * 这里使用非阻塞的调用以防止子进程在代码其他地方被清理。 */
    while (waitpid(-1, NULL, WNOHANG) > 0) {
    }
}

这是一个简单的信号处理函数,如果需要做更多的工作,请特别注意不要使用非异步信号安全的函数。

SIGBUS 信号

前面提到过 SIGBUS 信号通常是访问被映射( mmap(2) )的内存时,无法映射到对应文件(通常是文件被截断了)。这种非正常情况下,进程的一般行为是直接退出,但是如果一定要处理 SIGBUS 信号还是可行的。这时可以通过 sigsetjmp(3) siglongjmp(3) 来跳过发生错误的地方,从而让程序继续运行。

需要特别注意的是,信号处理函数执行了 siglongjmp(3) 调用之后,代码没有继续运行下去,而是直接跳转到 sigsetjmp(3) 位置重新开始执行。如果此时代码仍然持有锁等资源,将不会释放,如果后续代码继续去竞争锁,可能会导致死锁的发生。

SIGSEGV 信号

处理 SIGSEGV(段错误)信号是可能的,但这一般是没有意义的,因为即使代码重新运行了,运行到同样的地方仍然可能发生段错误。其中一种重启程序有效的情况是通过 mmap(2) 获取到的内存有写保护,由此产生的 SIGSEGV 信号(可以通过信号处理函数中的 siginfo_t 参数获取发生原因),可能可以通过 mprotect(2) 函数来去除写保护。

如果段错误是因为栈空间不足导致的,那么这时将无法通过信号处理函数来处理 SIGSEGV 信号。因为信号处理函数同样需要分配栈空间来执行。这种情况下,可以通过 sigaltstack(2) 函数为信号处理函数定义独立的栈空间。

SIGABRT 信号

试图处理 SIGABRT 信号时,需要了解 abort(3) 函数的运行原理:该函数会先发送 SIGABRT 信号,如果该信号被忽略,或者对应的信号处理函数正常返回(没有通过 longjmp(3) 跳转),它会将信号处理函数重置为默认方式,并且重新发送 SIGABRT 信号信号,这将导致进程退出。因此,处理 SIGABRT 信号的作用可能是在进程结束前做一些最后的操作,或者使用 longjmp(3) 从新的地方开始执行。

信号和 fork()

当父进程调用 fork(2) 函数创建子进程时,子进程不会复制父进程的信号队列,即使此时父进程的信号队列非空,也会单独创建一个空的信号队列。但是,子进程会继承父进程的所有信号处理函数和信号阻塞状态。因此如果父进程已经完成对信号的设置,没有特殊情况子进程无须重新设置。

信号和线程

由于 POSIX 规范中,所有的一个进程的所有线程都有相同的进程 ID(PID),向多线程进程发送信号有两种情况:

  • 向进程发送信号(使用类似 kill(2) 这样的函数直接向进程发送信号):线程可以通过 pthread_sigmask(2) 单独设置需要阻塞的信号。因此如果有线程没有阻塞当前发送的信号,进程中的一个线程会收到该信号(但是没有特殊说明具体哪个线程会收到);如果所有的线程都阻塞了当前发送的信号,该信号会被加入进程的信号队列;如果进程没有设置当前信号的信号处理函数,并且该信号的默认行为是终止进程,那么整个进程都将被终止。
  • 向特性线程发送信号(使用 pthread_kill(2) ):线程可以通过 pthread_kill(2) 向进程中的其他线程(或者自身)发送信号,此时信号会发送到对应线程的信号队列中。同时操作系统也可能会向特性线程发送诸如 SIGSEGV 信号。如果接收信号的线程没有处理对应的信号,且该信号的默认行为是终止进程,那么该线程所在的进程都将被终止。

信号发送

向进程发送信号的方式可以有:

  • 通过键盘交互:一些键盘的组合键,可以向控制台正在执行的进程发送信号。
    • CTRL+C:发送 SIGINT 信号,该信号默认行为是终止进程。
    • CTRL+\:发送 SIGQUIT 信号,该信好默认行为是终止进程并核心转储。
    • CTRL+Z:发送 SIGSTOP 信号,该信号默认行为是挂起进程。
  • kill(2) kill(2) 函数接受两个参数,一个是信号发送的进程 ID,一个是需要发送的信号。其中的进程 ID 有一些特殊的约定。
    • 0:如果 PID 为 0, 信号发送的目标是当前进程组的所有进程。
    • -1:如果 PID 为 -1,信号发送的目标是所有(有权限发送信号)的进程。
    • < -1:如果 PID 小于 -1, 信号发送的目标是进程 ID 为 -PID 的进程组。
  • 向进程自身发送信号:进程可以通过调用 raise(3)、abort(3) 等函数向自身发送信号。
    • raise(3):可以向进程发送指定信号,需要注意的是,在多线程环境中,只会向当前线程发送信号。
    • abort(3):向当前进程发送 SIGABRT 信号,前文已经提到过,该函数会重置信号处理函数,因此无需关心进程是否已经处理了 SIGABRT 信号。
  • sigqueue(2) :该函数和 kill(2) 函数类似,但是多了一个sigval参数。因此调用者可以向信号处理函数传递一个整数或者一个指针。信号处理函数可以通过siginfo_t参数获取该参数。

信号阻塞

有些时候,我们需要阻塞信号,防止信号打断当前程序的执行,而不是捕获和处理信号。传统的 signal(2) 函数可以通过将信号处理函数设置为SIG_IGN来实现阻塞的功能。但是该方式已经废弃,建议使用 sigprocmask(2) 函数来实现信号阻塞功能,因为它提供了更多的参数,可以适用于复杂场景。

一个简单的示例:

#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

static int got_signal = 0;

static void hdl (int sig)
{
    got_signal = 1;
}

int main (int argc, char *argv[])
{
    sigset_t mask;
    sigset_t orig_mask;
    struct sigaction act;

    memset (&act, 0, sizeof(act));
    act.sa_handler = hdl;

    if (sigaction(SIGTERM, &act, 0)) {
        perror ("sigaction");
        return 1;
    }

    sigemptyset (&mask);
    sigaddset (&mask, SIGTERM);

    if (sigprocmask(SIG_BLOCK, &mask, &orig_mask) < 0) {
        perror ("sigprocmask");
        return 1;
    }

    sleep (10);

    if (sigprocmask(SIG_SETMASK, &orig_mask, NULL) < 0) {
        perror ("sigprocmask");
        return 1;
    }

    sleep (1);

    if (got_signal)
        puts ("Got signal");

    return 0;
}

上述示例展示了通过 sigprocmask(2) 函数来阻塞 SIGTERM 信号 10 秒,此时如果进程接收到了 SIGTERM 信号,会被加入到进程的信号队列中。解除对 SIGTERM 信号的阻塞,此时如果之前的信号队列中有 SIGTERM 信号,或者新收到了 SIGTERM 信号,就会执行对应的信号处理函数。

阻塞信号使用的一个场景就是防止信号的竞争。一些函数(如 select(2) poll(2) )会阻塞当前函数执行,这时在异常的情况下,这些函数会期望通过信号来中断当前的阻塞操作。但是,如果此时程序还设置了其他信号处理函数,这时信号可能会被设置的信号处理函数消费,导致阻塞操作的函数仍然执行,无法中断。

遇到这种情况,就需要使用 sigprocmask(2) 配合支持重置sigmask的阻塞函数(如 pselect(2) poll(2) ),大致的示例代码片段如下:

sigemptyset (&mask);
sigaddset (&mask, SIGTERM);

if (sigprocmask(SIG_BLOCK, &mask, &orig_mask) < 0) {
    perror ("sigprocmask");
    return 1;
}

while (!exit_request) {

    /* 如果在这里接收到信号,信号会被阻塞,
     * 直到取消阻塞(下面 pselect 实现)
     */

    FD_ZERO (&fds);
    FD_SET (lfd, &fds);

    res = pselect (lfd + 1, &fds, NULL, NULL, NULL, &orig_mask);

    /* 下面继续文件描述符操作 */
}

后记

本文对 Linux/UNIX 信号系统、信号的处理、发送、阻塞等做了简单的介绍。但是整个信号系统非常复杂,还有很多没有提到的内容,期待和大家继续交流。


感谢魏星对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们。

收藏

评论

微博

发表评论

注册/登录 InfoQ 发表评论