Skip to content

Linux 信号机制:产生、阻塞与捕捉 ​

信号是 Linux 里最古老的进程间通信方式:事件发生时内核对进程的异步通知,也称为软件中断。Ctrl+C 结束进程、kill -9 强杀、段错误时的 core dump,背后都是信号。本文写给需要正确处理信号的多进程/长驻程序开发者,覆盖信号的产生、默认动作、常用接口、signal 与 sigaction 两种捕捉方式、阻塞与未决的关系,以及用 SIGCHLD 回收僵尸进程的完整实践。

信号是什么 ​

信号是软件层次上对中断机制的模拟,一种异步通信方式:进程在任何时刻都可能被打断,转去执行信号处理函数,处理完再回到断点。

信号通常源于内核,产生信号的典型事件:

  • 用户在终端输入特殊字符(Ctrl+C 发送 SIGINT、Ctrl+\ 发送 SIGQUIT)
  • 硬件异常(除零、非法内存访问触发 SIGFPE、SIGSEGV)
  • 系统状态变化(alarm 定时器到期的 SIGALRM、子进程结束的 SIGCHLD)
  • 其他进程调用 kill() 或 kill 命令

信号的特点:简单、不能携带大量信息、优先级高。kill -l 查看全部信号,前 31 个是常规信号,其余是实时信号。

常见信号 ​

编号 信号 触发条件 默认动作
2 SIGINT 用户按下 Ctrl+C 终止进程
3 SIGQUIT 用户按下 Ctrl+\ 终止进程
9 SIGKILL 无条件终止 终止进程(不可捕获/忽略/阻塞)
11 SIGSEGV 非法内存访问 终止进程并产生 core 文件
13 SIGPIPE 向没有读端的管道写数据 终止进程
17 SIGCHLD 子进程终止/停止/继续 忽略
18 SIGCONT 使停止的进程继续 继续
19 SIGSTOP 停止进程 停止(不可捕获/忽略/阻塞)

默认处理动作共五种:Term(终止)、Ign(忽略)、Core(终止并转储核心)、Stop(暂停)、Cont(继续)。SIGKILL 和 SIGSTOP 不能被捕捉、阻塞或忽略,这是系统保留的最后控制手段。

产生与定时接口 ​

c
#include <signal.h>
int kill(pid_t pid, int sig);
// pid > 0 发给指定进程;= 0 发给本进程组;= -1 发给所有有权限的进程;
// < -1 发给组 id 为 -pid 的进程组。sig 为 0 时不发信号,可用于探测进程是否存在

int raise(int sig);      // C 库函数,给当前进程发信号,等价于 kill(getpid(), sig)
void abort(void);        // 给当前进程发 SIGABRT

#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// seconds 秒后给本进程发 SIGALRM;返回上一个 alarm 的剩余秒数(无则 0);
// alarm(0) 取消定时。每个进程同一时刻只有一个 alarm 在计时。
// 计时用的是自然时间,与进程状态无关(睡眠中也计时),函数本身不阻塞

#include <sys/time.h>
int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);
// 更高精度(微秒)且支持周期性定时的替代方案:
//   ITIMER_REAL   按真实时间计时,到期发 SIGALRM
//   ITIMER_VIRTUAL 按用户态 CPU 时间计时,到期发 SIGVTALRM
//   ITIMER_PROF   按用户态+内核态时间计时,到期发 SIGPROF

setitimer 的参数结构:

c
struct itimerval {
    struct timeval it_interval;  // 周期间隔,非零则周期性触发
    struct timeval it_value;     // 首次到期时间
};
struct timeval {
    time_t      tv_sec;          // 秒
    suseconds_t tv_usec;         // 微秒
};

捕捉信号 ​

signal:简单但少用 ​

c
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
// handler 取值:SIG_IGN(忽略)、SIG_DFL(默认动作)、或回调函数 void f(int)
// 返回值:成功返回该信号先前的处置(上一次的 handler 或 SIG_IGN/SIG_DFL,
// 之前未设置过时通常是 SIG_DFL);失败返回 SIG_ERR

回调函数由内核调用:信号递达时,内核保存被打断的上下文,转到处理函数执行,结束后恢复断点继续运行。

sigaction:推荐的方式 ​

signal 的语义在不同 UNIX 平台有历史差异(信号递达后处置是否自动重置等),可移植的写法是用 sigaction:

c
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

struct sigaction {
    void     (*sa_handler)(int);                  // 处理函数(配合 sa_flags = 0)
    void     (*sa_sigaction)(int, siginfo_t *, void *);  // 三参版本(配合 SA_SIGINFO)
    sigset_t   sa_mask;                           // 处理函数执行期间额外阻塞的信号集
    int        sa_flags;                          // 0 用 sa_handler;SA_SIGINFO 用三参版本
    void     (*sa_restorer)(void);                // 已废弃,不使用
};

一个重要保证:处理函数执行期间,内核自动阻塞当前正在处理的这个信号(防止处理函数被自己递归打断),函数返回后自动解除;sa_mask 里还可以追加其他要临时阻塞的信号。

内核处理信号的过程 ​

信号检查发生在内核态返回用户态的边界上,所以一个正在长时间系统调用(如 read 阻塞)中的进程,信号递达会先中断系统调用(通常返回 EINTR),处理完信号后再由调用方决定是否重启。

阻塞与未决 ​

内核为每个进程维护两个位图信号集:

  • 未决信号集:信号已产生但尚未递达。信号产生时对应位置 1,递达时清 0。常规信号不排队:同一信号产生多次未处理,未决位也只保持 1,不计数
  • 阻塞信号集:被阻塞的信号停留在未决状态不递达,直到解除阻塞。阻塞是「暂不处理」,不是「丢弃」,也不是「不产生」

流程:信号产生 → 未决位置 1 → 内核检查阻塞位 → 未阻塞则递达处理,阻塞则保持未决。

用户不能直接操作内核里的两个集合,需要先构造自定义信号集,再通过接口设置:

c
#include <signal.h>
int sigemptyset(sigset_t *set);              // 全部清 0
int sigfillset(sigset_t *set);               // 全部置 1
int sigaddset(sigset_t *set, int signum);    // 添加一个信号
int sigdelset(sigset_t *set, int signum);    // 删除一个信号
int sigismember(const sigset_t *set, int signum);  // 查询:在返回 1,不在返回 0

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
// 把自定义集合作用到内核阻塞信号集:
//   SIG_BLOCK   并集:mask |= set
//   SIG_UNBLOCK 解除:mask &= ~set
//   SIG_SETMASK 替换:mask = set

int sigpending(sigset_t *set);               // 读取当前未决信号集(只读)

SIGCHLD 与僵尸进程 ​

子进程退出后,PCB 并不立即释放,要等父进程 wait 回收;父进程不回收,子进程就成为僵尸进程(zombie),积累多了耗尽进程号。子进程终止、停止、继续三种变化都会给父进程发 SIGCHLD(默认动作是忽略)。

标准做法:捕捉 SIGCHLD,在处理函数里循环 waitpid,一次信号把积压的僵尸全部收掉:

c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <signal.h>
#include <sys/wait.h>

void myFun(int num) {
    // 循环回收:一次 SIGCHLD 可能对应多个已退出的子进程
    while (1) {
        int ret = waitpid(-1, NULL, WNOHANG);
        if (ret > 0) {
            printf("child died, pid = %d\n", ret);
        } else {
            break;    // 返回 0:还有活着的子进程;返回 -1:没有子进程了
        }
    }
}

int main() {
    // 先阻塞 SIGCHLD,防止 fork 后子进程立刻退出、
    // 而父进程还没注册完处理函数,信号按默认动作(忽略)丢掉
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGCHLD);
    sigprocmask(SIG_BLOCK, &set, NULL);

    pid_t pid;
    for (int i = 0; i < 20; i++) {
        pid = fork();
        if (pid == 0) {
            break;    // 子进程不参与 fork 循环
        }
    }

    if (pid > 0) {
        struct sigaction act;
        act.sa_flags = 0;
        act.sa_handler = myFun;
        sigemptyset(&act.sa_mask);
        sigaction(SIGCHLD, &act, NULL);     // 注册完捕捉

        sigprocmask(SIG_UNBLOCK, &set, NULL);  // 再解除阻塞,补收挂起的信号

        while (1) {
            printf("parent pid: %d\n", getpid());
            sleep(2);
        }
    } else {
        printf("child pid: %d\n", getpid());
    }
    return 0;
}

四个常见疑问 ​

1. 为什么处理函数里不能随便用 printf?

printf 维护 stdio 内部缓冲区和数据结构,属于不可重入函数。若主流程执行 printf 到一半被信号打断,处理函数里再调 printf,两次执行交叉修改同一份内部状态,输出混乱甚至崩溃。信号处理函数里只能使用异步信号安全函数(man 7 signal-safety 有完整列表);示例代码用 printf 仅为演示。

2. 为什么循环 waitpid 能回收「被忽略过」的僵尸?

waitpid 与 SIGCHLD 并不绑定:它只认僵尸,不关心信号。信号不排队、一次 SIGCHLD 只触发一次处理函数,但循环里的 waitpid 见一个收一个,直到没有僵尸为止,所以能一次清完积压。

3. 阻塞中的信号会丢吗?

不会。阻塞只是暂不递达,信号保留在未决集中(位保持 1);解除阻塞后照常递达。因此示例里「先阻塞 → 注册处理函数 → 解除阻塞」的顺序能保证注册前产生的 SIGCHLD 不丢,注册后立刻被处理。

4. 为什么有时进了处理函数,却一个僵尸都没回收到?

处理函数执行期间同类型信号被自动阻塞。期间其余子进程退出产生的 SIGCHLD 只置未决位;而循环里的 waitpid 已经把这些子进程连同此前的僵尸全部回收了。处理函数返回、阻塞解除后,未决的 SIGCHLD 再次递达,又进一次处理函数,此时没有僵尸,waitpid 返回 0 或 -1 直接退出。这是正常现象,不是逻辑错误。

另一个工程要点:《Linux/UNIX 系统编程手册》建议为保证可移植性,应在创建任何子进程之前就设置好 SIGCHLD 的处理方式——上述「先阻塞再 fork」是等价的实现手段。

要点 ​

  • 信号是异步的软件中断;SIGKILL/SIGSTOP 不可捕获、阻塞、忽略
  • 产生 → 未决 → (查阻塞集)→ 递达;常规信号不排队,阻塞不等于丢弃
  • 捕捉优先用 sigaction,不用语义有历史差异的 signal;处理函数执行期间本信号自动阻塞
  • 处理函数里只用异步信号安全函数,printf/malloc 都不安全
  • 回收僵尸:捕捉 SIGCHLD + 循环 waitpid(WNOHANG);注册前先阻塞信号防丢失

相关文章 ​

最近更新

基于 VitePress 构建