信号的概念和机制

从"进程之间怎么打招呼"讲到"SIGCHLD 回收子进程",一条线串完 Linux 信号机制。

0. 信号的概念和机制

一句话理解:信号是内核或进程发给另一个进程的异步通知,收到后进程有三种选择:执行默认动作、忽略、或执行你注册的处理函数。

完整案例:按 Ctrl+C 验证 SIGINT 的默认动作。

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

int main(void) {
    printf("PID = %d,按 Ctrl+C 测试默认动作\n", getpid());
    while (1) pause();   // pause() 挂起,直到收到一个信号
    return 0;
}

运行后按 Ctrl+C,进程终止。pause() 本身返回 -1 并设置 errno = EINTR,但这里进程已经被 SIGINT 的默认动作终止了,走不到后面。

注意:信号不排队。常规信号在未决期间产生多次,最终只记一次。不能拿信号来"数数"。

1. 与信号相关的概念

四个关键状态要分清:

产生:信号被发出。来源包括终端按键(Ctrl+C)、硬件异常、kill 等系统调用、内核事件(如子进程退出)。

未决(pending):信号产生了,但还没有被递达给进程。内核在进程的未决信号集里把对应位置 1。

递达(delivery):信号被实际处理了,未决位清 0。

阻塞 / 屏蔽(block):进程告诉内核"这类信号先别递达"。被阻塞的信号产生后停在未决状态,不会丢失,解除阻塞后会补递达。

忽略(ignore):和阻塞不同。忽略是信号已经递达了,但你选择不做反应(SIG_IGN);阻塞是递达这一步被推迟了。

小图:

[产生] ──► [未决集置位] ──► [被阻塞?]
                                │
                 ┌──────────────┴──────────────┐
                 │ 是                          │ 否
                 ▼                             ▼
        [停在未决集等待] ──解除阻塞──►       [递达]
                                               │
                                               ▼
                             [默认动作 / 忽略 / 处理函数]

2. 信号屏蔽字和未决信号集

进程的 task_struct 里有两张位图,各 64 位(对应 1~64 号信号):

阻塞信号集(mask / 屏蔽字):
位1  位2  位3  ...  位64
 0    1    0         0     <- 第 2 位为 1,表示屏蔽 2 号信号(SIGINT)

未决信号集(pending):
位1  位2  位3  ...  位64
 0    1    0         0     <- 2 号信号产生了,但被阻塞,卡在这里

完整案例:屏蔽 SIGINT,按 Ctrl+C 验证进程不退,再查未决集。

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

int main(void) {
    sigset_t block_set, old_set, pending;

    sigemptyset(&block_set);
    sigaddset(&block_set, SIGINT);

    // 屏蔽 SIGINT,保存原来的屏蔽字
    if (sigprocmask(SIG_BLOCK, &block_set, &old_set) == -1) {
        perror("sigprocmask block");
        return 1;
    }

    printf("SIGINT 已屏蔽。现在按 Ctrl+C,进程不会退出。\n");
    sleep(5);

    // 查看当前未决信号集
    if (sigpending(&pending) == -1) {
        perror("sigpending");
        return 1;
    }
    if (sigismember(&pending, SIGINT))
        printf("SIGINT 正在未决集中等待。\n");
    else
        printf("未决集中没有 SIGINT。\n");

    // 恢复原来的屏蔽字,此时 SIGINT 会立刻递达,进程终止
    printf("即将恢复屏蔽字,SIGINT 会马上递达。\n");
    sigprocmask(SIG_SETMASK, &old_set, NULL);

    // 如果 SIGINT 默认动作是终止,下面这行不会执行
    printf("这行通常看不到。\n");
    return 0;
}

重点:SIGKILL(9)和 SIGSTOP(19)不能被阻塞、不能被捕捉、不能被忽略。这是内核防止进程把自己锁死的硬规则。

3. 信号四要素和常规信号一览

一个信号由四样东西标识:编号、名称、默认动作、触发事件。

常用信号速查:

编号名称默认动作典型触发
2SIGINT终止Ctrl+C
3SIGQUIT终止 + coreCtrl + \
9SIGKILL终止kill -9,不可捕捉
14SIGALRM终止alarm / setitimer(ITIMER_REAL)
15SIGTERM终止kill 默认信号
17SIGCHLD忽略子进程退出或停止
19SIGSTOP暂停不可捕捉

1~31 是常规信号,不排队;32~64 是实时信号,支持排队。

4. kill 函数和 kill 命令

函数原型:

#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

参数:

  • pid:目标,四种含义见下表。
  • sig:信号编号,用 SIGxxx 宏;0 表示不发信号,只做检查。
  • 返回 0 成功,-1 失败并设置 errno:EINVAL 信号编号非法,EPERM 无权限,ESRCH 进程不存在。

pid 的四种含义(man page 原文归纳):

pid发给谁
> 0进程 ID 为 pid 的单个进程
= 0调用者所在进程组中的所有进程
= -1调用者有权限发送的所有进程,但排除 init(PID 1)
< -1进程组 ID 为 -pid 中的所有进程

sig = 0 的特殊用法:不发任何信号,只做错误检查(进程是否存在、有无权限)。这是"探活"的标准做法:返回 0 表示进程存在且有权限;返回 -1 且 errno == ESRCH 表示进程不存在,EPERM 表示存在但无权限。

完整案例:探活 + 发信号。

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

int check_alive(pid_t pid) {
    if (kill(pid, 0) == 0) return 1;          // 存在且有权限
    if (errno == EPERM)   return 2;           // 存在但无权限
    return 0;                                 // 不存在或其他错误
}

int main(void) {
    pid_t target = 1;   // init
    int st = check_alive(target);
    printf("pid %d 探活结果: %d\n", target, st);

    // 给自己发 SIGTERM(默认动作终止),正常情况下进程就此结束
    kill(getpid(), SIGTERM);
    printf("这行不会执行\n");
    return 0;
}

易错:kill 名字有误导性,它可以发任何信号,不只是"杀"。普通用户只能给自己名下的进程发信号(或有 CAP_KILL 能力)。

5. alarm 函数

原型:

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

行为:seconds 秒后,内核给当前进程发 SIGALRM(14 号)。默认动作终止进程。一个进程同一时刻只有一个 alarm 闹钟,第二次调用会覆盖第一次,不是叠加。返回上一个闹钟的剩余秒数;之前没设过则返回 0。alarm(0) 取消闹钟,返回旧闹钟剩余时间。

完整案例:验证 alarm 返回旧闹钟剩余时间,以及覆盖行为。

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

void alrm_handler(int sig) {
    (void)sig;
    write(STDOUT_FILENO, "SIGALRM 到了\n", sizeof("SIGALRM 到了\n") - 1);  // write 是异步信号安全的
}

int main(void) {
    signal(SIGALRM, alrm_handler);

    unsigned int r1 = alarm(10);
    printf("第一次 alarm(10),返回: %u\n", r1);   // 之前没有闹钟,应为 0

    sleep(2);

    unsigned int r2 = alarm(3);   // 覆盖:旧的 10 秒闹钟还剩约 8 秒
    printf("第二次 alarm(3),返回旧闹钟剩余: %u\n", r2);  // 约 8

    pause();   // 等待新的 3 秒闹钟触发
    return 0;
}

重点:alarm 精度只到秒,且是单次触发。需要周期触发或微秒精度,用 setitimer。

6. setitimer 函数

原型:

#include <sys/time.h>
int setitimer(int which, const struct itimerval *new_value,
              struct itimerval *old_value);

参数:

  • which:用哪个计时器,三种取值见下。
  • new_value:新的定时设置,不能传 NULL(Linux 上返回 EFAULT)。
  • old_value:输出参数,带回原来的定时设置,可以传 NULL。
  • 返回 0 成功,-1 失败并设置 errno。

timeval 的两个字段:tv_sec 秒,tv_usec 微秒(0~999999)。
timeval 表示一个时间长度(或时间点),由"秒 + 微秒"两部分拼成:
timeval 总时间 = tv_sec 秒 + tv_usec 微秒

结构体:

struct itimerval {
    struct timeval it_interval;   // 周期:每隔多久触发一次
    struct timeval it_value;      // 首次:多久后第一次触发
};

which 三种类型:

  • ITIMER_REAL:真实时间,发 SIGALRM
  • ITIMER_VIRTUAL:用户态 CPU 时间,发 SIGVTALRM
  • ITIMER_PROF:用户态 + 内核态 CPU 时间,发 SIGPROF

关键规则:it_value 为 0 → 定时器停止;it_value 非 0 但 it_interval 为 0 → 只触发一次;两者都非 0 → 周期触发。

完整案例:每 1 秒触发一次 SIGALRM,触发 5 次后自己停掉。

#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <sys/time.h>
#include <unistd.h>

static volatile sig_atomic_t count = 0;

void tick(int sig) {
    (void)sig;
    count++;
}

int main(void) {
    // 第一步:设置拦截
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = tick;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGALRM, &sa, NULL);

    // 第二部分:设置时间
    struct itimerval tm;
    tm.it_value.tv_sec = 1;       // 1 秒后第一次
    tm.it_value.tv_usec = 0;
    tm.it_interval.tv_sec = 1;    // 之后每 1 秒一次
    tm.it_interval.tv_usec = 0;

    // 为当前进程设置一个“间隔定时器”
    if (setitimer(ITIMER_REAL, &tm, NULL) == -1) {
        perror("setitimer");
        return 1;
    }

    while (count < 5) pause();

    // 关掉定时器:it_value 置 0
    struct itimerval off;
    memset(&off, 0, sizeof(off));
    setitimer(ITIMER_REAL, &off, NULL);

    printf("共收到 %d 次 SIGALRM\n", count);
    return 0;
}

易错:alarm 本质上就是低精度、非周期的 ITIMER_REAL 定时器,同一个进程里 alarm 和 setitimer(ITIMER_REAL) 不能"各管各的"同时生效,它们操作的是同一个底层定时器。

7. 午后回顾

这一节原课是中途串讲。回头看,需要牢牢钉住的三个点:

  1. 产生 ≠ 递达。中间隔着"未决集 + 阻塞集"这道闸门。
  2. kill 的 pid 有正、零、-1、小于 -1 四种含义,sig = 0 用来探活。
  3. alarm 单次、秒级、覆盖式;setitimer 可周期、微秒级、三种计时方式。

8. 信号集操作函数

sigset_t 是一个不透明类型(可能是数组或结构体),不能直接赋值或比较,必须通过函数操作。

原型(以 man page 为准):

#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,出错 -1

参数:
set 是待操作的信号集指针;
signum 是信号编号,必须是 1~64 之间的合法信号,否则返回 -1 并置 errno = EINVAL(sigismember 遇到非法编号也返回 -1)。

返回值:前四个成功返回 0,失败返回 -1 并设置 errno;sigismember 返回 1/0/-1。

完整案例:构造一个包含 SIGINT 和 SIGQUIT 的集合,验证增删查。

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

int main(void) {
    sigset_t set;

    sigemptyset(&set);
    printf("清空后,SIGINT 在集合中吗? %d\n", sigismember(&set, SIGINT));  // 0

    sigaddset(&set, SIGINT);
    sigaddset(&set, SIGQUIT);
    printf("加入后,SIGINT 在集合中吗? %d\n", sigismember(&set, SIGINT));  // 1
    printf("加入后,SIGQUIT 在集合中吗? %d\n", sigismember(&set, SIGQUIT));// 1

    sigdelset(&set, SIGINT);
    printf("删除后,SIGINT 在集合中吗? %d\n", sigismember(&set, SIGINT));  // 0

    return 0;
}

重点:sigset_t 变量在传给 sigaddset / sigdelset / sigismember 之前,必须先用 sigemptyset 或 sigfillset 初始化。否则内容是随机的,结果未定义。

9. 信号操作函数使用原理分析

核心函数:

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

参数:

  • how:修改方式,三种取值见下。
  • set:新的屏蔽字;传 NULL 表示不改,只查询当前值。
  • oldset:输出修改前的屏蔽字,可以传 NULL。
  • 返回 0 成功,-1 失败并设置 errno。

注意它改的是"调用线程"的屏蔽字。多线程程序里 POSIX 要求用 pthread_sigmask,两者在 Linux 上的行为是一致的。

how 三种取值(这是最容易混的地方):

how效果
SIG_BLOCK把 set 里的信号加入当前屏蔽字;原来屏蔽的仍屏蔽,只加不减。
SIG_UNBLOCK从当前屏蔽字中移除 set 里的信号,只减不增
SIG_SETMASK当前屏蔽字直接替换为 set,不在 set 里的全部解除屏蔽

关键区别:SIG_BLOCK 和 SIG_UNBLOCK 不碰 set 之外的信号;SIG_SETMASK 会整体覆盖,set 之外的一律解除屏蔽。

原理:sigprocmask 改的是进程 PCB 里的阻塞信号集。内核在每次从内核态返回用户态之前,检查"未决集 ∩ 非阻塞集",有交集就先递达信号,再回到用户代码。

oldset 的典型用途:保存修改前的屏蔽字,干活结束后恢复原样,避免污染进程的信号环境。

10. 信号集操作函数练习

练习目标:屏蔽 SIGINT,按 Ctrl+C 后查未决集,再解除屏蔽观察立即递达。

这个练习在第 2 节已经给过完整代码,这里补充一个更接近真实调试习惯的版本:在阻塞期间循环打印未决集,直观看到"信号卡在 pending 里"。

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

static void show_pending(const char *tag) {
    sigset_t p;
    // sigpending 函数用于检索当前进程的未决信号集,即那些已经产生但尚未被处理的信号。
    sigpending(&p);
    printf("[%s] 未决集中的常规信号: ", tag);
    int found = 0;
    for (int i = 1; i <= 31; i++) {
        if (sigismember(&p, i)) {
            printf("%d ", i);
            found = 1;
        }
    }
    if (!found) printf("(空)");
    printf("\n");
}

int main(void) {
    sigset_t block, old;

    sigemptyset(&block);
    sigaddset(&block, SIGINT);
    sigprocmask(SIG_BLOCK, &block, &old);

    printf("SIGINT 已阻塞。5 秒内按 Ctrl+C。\n");
    for (int i = 0; i < 5; i++) {
        show_pending("阻塞中");
        sleep(1);
    }

    printf("解除阻塞...\n");
    sigprocmask(SIG_SETMASK, &old, NULL);

    // SIGINT 在这里递达。如果没注册处理函数,默认终止,下面看不到
    printf("如果 SIGINT 默认终止,这行不会执行。\n");
    return 0;
}

运行后在 5 秒内按一次 Ctrl+C,会看到未决集里出现 2,解除阻塞后进程终止(或执行你注册的处理函数)。

11. signal 实现信号捕捉

原型:

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);

参数:signum 是信号编号,不能是 SIGKILL / SIGSTOP;handler 有三种特殊值——SIG_IGN(忽略)、SIG_DFL(恢复默认)、函数地址(自定义处理)。成功返回旧的处理方式,失败返回 SIG_ERR 并设置 errno。

完整案例:捕获 SIGINT,Ctrl+C 不再终止,打印信息后继续运行。

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

void on_int(int sig) {
    (void)sig;
    write(STDOUT_FILENO, "捕获到 SIGINT,进程继续运行\n", sizeof("捕获到 SIGINT,进程继续运行\n") - 1);
}

int main(void) {
    if (signal(SIGINT, on_int) == SIG_ERR) {
        perror("signal");
        return 1;
    }

    printf("PID = %d,按 Ctrl+C 测试;Ctrl+\\ 退出\n", getpid());
    while (1) pause();
    return 0;
}

为什么实际项目优先用 sigaction:signal 在不同 Unix 和历史版本里行为不一致(比如处理函数执行后是否自动复位),而 sigaction 行为确定、功能完整(能控制 sa_mask、SA_RESTART 等)。下面的 sigaction 案例才是推荐写法。

12. sigaction 实现信号捕捉

原型:

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

参数:

  • signum:信号编号,不能是 SIGKILL / SIGSTOP(返回 EINVAL)。
  • act:新的处理方式,传 NULL 表示只查询。
  • oldact:输出原来的处理方式,可以传 NULL。
  • 返回 0 成功,-1 失败并设置 errno。

结构体关键成员(字段顺序在不同架构上可能不一样,写代码时只用成员名就行):

struct sigaction {
    union {
        void (*sa_handler)(int);                        // 简单处理函数
        void (*sa_sigaction)(int, siginfo_t *, void *); // 带信息的处理函数,配合 SA_SIGINFO
    };
    sigset_t sa_mask;            // 处理函数执行期间额外屏蔽的信号
    int sa_flags;                // 行为标志,见下表
    void (*sa_restorer)(void);   // glibc 恢复现场用的 trampoline,应用层不要设置
};

sa_handler 和 sa_sigaction 是同一个 union 的两个名字,只能用一个。设置 sa_sigaction 却没加 SA_SIGINFO,内核会按单参数函数去调,等于把 siginfo_t* 当 int 传进去,行为未定义,通常直接崩。

sa_flags 逐项说明(结构体必须先零初始化,struct sigaction sa{};,否则 sa_flags 是栈上的随机位):

标志作用备注
SA_RESTART被打断的慢速系统调用自动重启最常用;对 sleep / nanosleep 无效
SA_SIGINFO改用 sa_sigaction 三参数版本要拿 siginfo_t 时必选
SA_NOCLDSTOP子进程暂停/继续时不产生 SIGCHLD只对 SIGCHLD 有意义
SA_NOCLDWAIT子进程退出不产生僵尸,内核自动回收只对 SIGCHLD;之后 wait 会返回 ECHILD
SA_NODEFER处理函数执行期间不自动屏蔽当前信号默认是屏蔽的,加上才能同种信号嵌套,危险
SA_RESETHAND进入处理函数前把该信号复位成 SIG_DFL只能生效一次的处理函数
SA_ONSTACK在 sigaltstack 注册的备用栈上执行处理函数栈溢出导致的 SIGSEGV 必须用它
SA_INTERRUPT历史标志,Linux 上无实际效果不用管
SA_RESTORER供 glibc 从处理函数返回时恢复现场应用层绝对不能设
SA_NOMASK / SA_ONESHOTSA_NODEFER / SA_RESETHAND 的旧名字需要定义 _GNU_SOURCE

几种实际会用到的组合:

  • SA_RESTART | SA_NOCLDSTOP:回收子进程的标准组合。
  • SA_SIGINFO | SA_RESTART:既要发送者信息,又不想到处写 EINTR 判断。
  • SA_RESETHAND:只想让第一次信号走自定义逻辑,之后交回默认动作。
  • SA_ONSTACK:单独写没意义,必须先用 sigaltstack 注册过备用栈。

sa_mask 的核心作用:处理函数执行期间,OS 会把 sa_mask 里的信号临时加入进程的阻塞集,处理函数返回后自动恢复原来的阻塞集。默认情况下,正在处理的信号本身会自动屏蔽,sa_mask 用来额外堵住其他信号,防止嵌套打断。

完整案例:捕获 SIGINT,处理期间额外屏蔽 SIGQUIT。

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

void on_int(int sig) {
    (void)sig;
    write(STDOUT_FILENO, "[SIGINT 处理开始,持续 3 秒]\n", sizeof("[SIGINT 处理开始,持续 3 秒]\n") - 1);
    sleep(3);   // 故意耗时,测试 sa_mask 效果
    write(STDOUT_FILENO, "[SIGINT 处理结束]\n", sizeof("[SIGINT 处理结束]\n") - 1);
}

int main(void) {
    struct sigaction act, old;

    memset(&act, 0, sizeof(act));
    act.sa_handler = on_int;
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, SIGQUIT);   // 处理 SIGINT 期间也屏蔽 SIGQUIT
    act.sa_flags = 0;

    if (sigaction(SIGINT, &act, &old) == -1) {
        perror("sigaction");
        return 1;
    }

    printf("PID = %d\n", getpid());
    printf("按 Ctrl+C 进入处理;处理期间按 Ctrl+\\ 会被压住,处理完才递达。\n");

    while (1) pause();
    return 0;
}

测试:按 Ctrl+C 后 3 秒内按 Ctrl + \,Ctrl + \ 不会打断处理函数;3 秒后 SIGQUIT 递达,执行默认动作(终止 + core)。

注意:sa_mask 的屏蔽是临时的,处理函数返回后进程原有的屏蔽字恢复原样。

13. 信号捕捉的特性

特性一:同种信号自动屏蔽。 处理 SIGINT 期间又来 SIGINT,不会嵌套调用处理函数,而是挂起,等当前处理函数返回后再处理。

特性二:常规信号不排队。 处理期间来了 5 个同种常规信号,最终只记 1 个,处理函数返回后只再执行一次。

特性三:处理函数是并发流。 它和主程序共享全局变量、堆、文件描述符。最安全的做法是:处理函数里只设置一个 volatile sig_atomic_t 标志,主循环检查标志后再做实际工作。

特性四(易错):处理函数里不能随便调用非异步信号安全的函数。 printf、malloc、exit 都不安全;write、_exit、signal、sigprocmask 等是异步信号安全的。调试时用 write 代替 printf,虽然不方便,但不会把程序搞崩。

完整案例:用 volatile sig_atomic_t 标志配合主循环,而不是在处理函数里直接干活。

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

static volatile sig_atomic_t got_int = 0;

void on_int(int sig) {
    (void)sig;
    got_int = 1;          // 只做一个赋值,安全
}

int main(void) {
    struct sigaction sa;
    sa.sa_handler = on_int;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGINT, &sa, NULL);

    while (1) {
        if (got_int) {
            got_int = 0;
            printf("主循环检测到 SIGINT 标志,在这里做实际工作\n");
        }
        pause();          // 等待信号
    }
    return 0;
}

14. 内核实现信号捕捉简析

简化流程:

用户进程正在用户态执行
        │
        ▼
信号产生,内核在"返回用户态之前"检查
未决集 ∩ 非阻塞集
        │
        ▼
发现待处理信号,且用户注册了处理函数
        │
        ▼
内核在用户栈上"搭临时框架",
把返回地址指向处理函数
        │
        ▼
从内核态回到用户态,
开始执行你的 handler(仍在用户态)
        │
        ▼
handler 返回时执行 sigreturn 系统调用
        │
        ▼
内核恢复被打断的上下文
(寄存器、栈、原屏蔽字)
        │
        ▼
继续执行原程序

关键点:处理函数在用户态执行,不是内核态。内核只负责"安排跳转"和"恢复现场"。所以处理函数里可以调用普通函数,但必须遵守异步信号安全规则。

15. 借助信号捕捉回收子进程

问题:子进程退出后,父进程不 wait,子进程变成僵尸(zombie),占着进程表项不释放。

核心做法:捕获 SIGCHLD,在处理函数里循环 waitpid(-1, ..., WNOHANG)。

为什么必须用 while:SIGCHLD 是常规信号,不排队。多个子进程几乎同时退出,父进程可能只收到一个 SIGCHLD。如果只 wait 一次,剩下的僵尸就漏掉了。while + WNOHANG 保证把所有已经退出的子进程一次回收干净,没有可回收的立即返回 0,循环结束。

sigsuspend的作用

sigsuspend 解决的是 sigprocmask + pause 之间的竞态窗口问题:解屏蔽和挂起是两步,信号若在这个间隙到达就会被消费掉,导致 pause 永久睡死;sigsuspend 把“换屏蔽字 + 挂起等待”合成一个原子操作,保证信号不会在这个窗口里丢失,唤醒后自动恢复原屏蔽字。

第 1 步:把进程的屏蔽字临时替换成 empty(= 解除所有屏蔽)
         → 如果此刻已有 pending 的 SIGCHLD,它立刻被投递 → handler 跑起来
         → handler 跑完返回

第 2 步:进程挂起睡觉(sleep)
         → 如果第 1 步没有 pending 信号,就睡在这里等
         → 等到某个信号到来 → 投递 → handler 跑
         → handler 返回后,sigsuspend 从睡中醒来

第 3 步:把屏蔽字恢复成调用前的样子(= SIGCHLD 又被屏蔽了)
         → sigsuspend 返回 -1,errno = EINTR

完整案例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <errno.h>
#include <sys/wait.h>
#include <unistd.h>

static volatile sig_atomic_t reaped = 0;

void on_chld(int sig) {
    (void)sig;
    int saved_errno = errno;      // 处理函数可能改变 errno,先保存
    pid_t pid;
    int status;
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        reaped++;
        // 处理函数里避免 printf/malloc,这里为了演示简单用 write
        // 理论上这里用snprintf也不是异步信号安全函数。
        // 因为这里是信号接收到处理的部分。建议把下面三行移动到main内输出。
        char buf[64];
        int n = snprintf(buf, sizeof(buf), "回收子进程 pid=%d\n", (int)pid);
        write(STDOUT_FILENO, buf, n);
    }
    errno = saved_errno;           // 恢复 errno
}

int main(void) {
    // 先屏蔽 SIGCHLD,注册处理函数,再 fork
    // 避免 fork 之后、sigaction 之前子进程就退出导致竞态
    sigset_t mask, old;
    sigemptyset(&mask);
    sigaddset(&mask, SIGCHLD);
    sigprocmask(SIG_BLOCK, &mask, &old);

    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = on_chld;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;   // SA_NOCLDSTOP:只关心退出,不关心暂停
    sigaction(SIGCHLD, &sa, NULL);

    // 创建 5 个子进程,让它们在不同时间退出
    for (int i = 0; i < 5; i++) {
        pid_t pid = fork();
        if (pid == 0) {
            // 子进程
            sleep(1 + i);          // 错开退出时间
            _exit(0);
        }
    }

    // 注册完毕,解除对 SIGCHLD 的阻塞
    sigset_t empty;
    sigemptyset(&empty);
    while (reaped < 5) {
        sigsuspend(&empty);   // 原子解屏蔽 + 睡到被信号唤醒
    }

    // 等足够久,让所有子进程退出并被回收
    sleep(8);

    printf("总共回收 %d 个子进程\n", (int)reaped);
    return 0;
}

为什么先屏蔽再 sigaction 再 fork:如果先 fork,子进程可能在父进程还没 sigaction 时就已经退出,SIGCHLD 先到但处理函数还没注册,这次通知就"错过"了(不会排队)。先屏蔽 SIGCHLD,注册好处理函数,再 fork,最后解除阻塞,就能保证不丢通知。这是一个典型的信号竞态防范手法。

另一个易错:处理函数里 waitpid 循环结束后,记得恢复 errno,因为 waitpid 成功时也可能改动 errno,而处理函数返回后主程序可能依赖原来的 errno。

16. 慢速系统调用中断

什么是慢速系统调用:可能无限期阻塞的调用,典型如读终端/管道/套接字的 read、write、accept、wait、pause。本地磁盘 I/O 不算慢速,通常不会被信号中断。

现象:进程阻塞在 read 上,来了信号,处理函数执行完后,read 会怎样?

两种结果,取决于 sa_flags 是否包含 SA_RESTART:

条件read 的行为
有 SA_RESTART自动重启,继续阻塞等待,对用户代码"透明"
没有 SA_RESTART返回 -1,errno = EINTR,用户代码需要自己判断

但 sleep 是例外:无论有没有 SA_RESTART,sleep 都不会被自动重启。它被信号中断后,返回剩余未睡眠的秒数(成功返回,不是 -1)。

完整案例:对比有/无 SA_RESTART 时 read 的行为。

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

void on_int(int sig) {
    (void)sig;
    write(STDOUT_FILENO, "\n[SIGINT handler 执行]\n", sizeof("\n[SIGINT handler 执行]\n") - 1);
}

int main(int argc, char *argv[]) {
    int use_restart = (argc > 1);   // 带参数运行 = 使用 SA_RESTART

    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = on_int;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = use_restart ? SA_RESTART : 0;
    sigaction(SIGINT, &sa, NULL);

    printf("SA_RESTART = %s\n", use_restart ? "开" : "关");
    printf("现在阻塞在 read 上。按 Ctrl+C,观察 read 的返回。\n");

    char buf[128];
    ssize_t n = read(STDIN_FILENO, buf, sizeof(buf) - 1);

    if (n == -1) {
        if (errno == EINTR)
            printf("read 返回 -1,errno = EINTR(被中断,未重启)\n");
        else
            perror("read");
    } else {
        buf[n] = '\0';
        printf("read 返回 %zd,内容: %s\n", n, buf);
    }

    return 0;
}

运行 ./a.out(无 SA_RESTART)然后按 Ctrl+C,会看到 read 返回 -1,errno = EINTR。运行 ./a.out restart(有 SA_RESTART)按 Ctrl+C,处理函数打印后 read 继续等输入,你输入字符后它正常返回。

易错:不检查 EINTR 的代码在某些信号环境下会意外失败。想省事就加 SA_RESTART;想精确控制超时或退出,就不加,在循环里判断 errno == EINTR 决定是否重试。

17. 总结

把这一章串成一条线:

  1. 信号是异步通知。产生后先进入未决集,阻塞集决定"放不放行",放行后递达,执行默认动作、忽略或处理函数。
  2. 屏蔽字用 sigprocmask 改,未决集用 sigpending 查。SIG_BLOCK 只增不减,SIG_UNBLOCK 只减不增,SIG_SETMASK 整体覆盖。
  3. 发信号用 kill(或 alarm / setitimer)。kill 的 pid 有四种含义,sig = 0 探活。
  4. 捕捉优先用 sigaction。sa_mask 实现处理期间的临时额外屏蔽,SA_RESTART 控制慢速系统调用是否自动重启。
  5. 回收子进程靠 SIGCHLD + while 循环 waitpid(-1, ..., WNOHANG)。先屏蔽再注册再 fork,避免竞态;循环回收,防漏僵尸。
  6. 慢速系统调用可能被信号打断。SA_RESTART 自动重启大多数慢速调用,但 sleep 永不重启,返回剩余秒数。

18. 复习:子进程借助信号回收

自测清单(每一条都能对应到上面的案例):

  • [ ] 能画出"产生 → 未决 → 阻塞/递达"的关系图吗?
  • [ ] kill(0, SIGINT) 和 kill(-1, SIGINT) 分别发给谁?
  • [ ] alarm(10) 后 alarm(3) 的返回值大约是多少?旧闹钟还有效吗?
  • [ ] SIG_BLOCK 和 SIG_SETMASK 最关键的区别是什么?
  • [ ] sa_mask 的屏蔽是永久的还是临时的?
  • [ ] SIGCHLD 处理函数里为什么必须用 while 循环 waitpid?
  • [ ] 处理函数里为什么用 write 而不是 printf?
  • [ ] SA_RESTART 对 read 和对 sleep 的效果有什么不同?

如果这八条都能说清楚,这一章的核心就抓住了。剩下的细节在写代码时翻 man 手册确认原型和标志位即可。

补充篇

上面是正文,从这一节开始是补充:参数细节、几个正文没提到的函数、以及 C++17 下的写法。
示例统一用 g++ -std=c++17 -Wall 编译。

19. 其他常用短函数

#include <signal.h>
int raise(int sig);          // 给自己发信号,等价 kill(getpid(), sig)
void abort(void);            // 发 SIGABRT,并且保证进程最终终止

#include <unistd.h>
int pause(void);             // 挂起直到收到信号,永远返回 -1,errno = EINTR
unsigned int sleep(unsigned int seconds);

#include <time.h>
int nanosleep(const struct timespec *req, struct timespec *rem);

raise:单线程里就是 kill(getpid(), sig);多线程里 glibc 用 tgkill 发给调用它的那个线程,所以是"发给自己这个线程"。返回 0 成功,-1 失败。

abort:不管 SIGABRT 有没有被捕获、有没有被阻塞,进程最终都会死。它的内部流程是先解除阻塞、把处理方式复位成 SIG_DFL、再 raise,最后还有一层兜底。所以不要指望用 SIGABRT 的处理函数"拦住" abort。

pause:只有 -1 这一种返回值,没有"成功返回"。想等信号又不想丢信号,用 sigsuspend(20 节)。

sleep 和 nanosleep:被信号打断后返回的是剩余时间,不是 -1。sleep 返回剩余秒数,nanosleep 把剩余时间写进 rem(rem 可以传 NULL)。两者都不受 SA_RESTART 影响,永远不会自动重启,这一点最容易记反。

// 睡满给定时间,被打断就接着睡剩下的部分
#include <cerrno>
#include <cstdio>
#include <ctime>

void sleep_full(double seconds) {
    struct timespec req{};
    req.tv_sec  = static_cast<std::time_t>(seconds);
    req.tv_nsec = static_cast<long>((seconds - static_cast<double>(req.tv_sec)) * 1e9);

    struct timespec rem{};
    while (nanosleep(&req, &rem) == -1 && errno == EINTR) {
        req = rem;      // 内核把剩余时间写进了 rem,接着睡
    }
}

int main() {
    sleep_full(0.5);
    std::puts("done");
    return 0;
}

timespec 的两个字段:tv_sec 秒,tv_nsec 纳秒(0~999999999)。

顺带两个容易漏的:

int sigaltstack(const stack_t *ss, stack_t *old_ss);
// stack_t: ss_sp 备用栈起始地址, ss_size 字节数, ss_flags = 0 表示启用、SS_DISABLE 表示关闭

sigaltstack 给线程注册一块备用栈,配合 SA_ONSTACK,处理函数就跑在这块栈上。栈溢出引起的 SIGSEGV 必须这么写,否则处理函数一进去又踩爆栈,直接变成无声的 core。

int  sigsetjmp(sigjmp_buf env, int savesigs);   // savesigs 非 0 才保存屏蔽字
void siglongjmp(sigjmp_buf env, int val);

从处理函数里跳回主流程时,必须用带 sig 的版本。普通 longjmp 不恢复屏蔽字,跳出来以后屏蔽字会停在"处理函数执行期间"的状态,后续信号全被莫名堵住。这类写法现在算老派,新代码更推荐标志位 + 主循环,或者 sigwait / signalfd。

20. sigsuspend 函数

#include <signal.h>
int sigsuspend(const sigset_t *mask);

参数:mask 是临时使用的新屏蔽字,不是"要解除的信号集"。这点最容易写反——想等 SIGINT,就要传"当前屏蔽字去掉 SIGINT"的集合,而不是只含 SIGINT 的集合。

行为:把调用线程的屏蔽字设成 mask 后挂起;收到一个不在 mask 里的信号、且处理函数返回之后,屏蔽字自动恢复成调用前的值,函数返回 -1 且 errno = EINTR。没有别的返回值。

它解决的问题:sigprocmask(SIG_UNBLOCK) 和 pause() 之间存在窗口期,信号正好落在这个缝里,pause 就永远等下去。sigsuspend 把这两步合成一个原子操作,没有窗口。

#include <csignal>
#include <cstdio>
#include <unistd.h>

namespace {
volatile std::sig_atomic_t g_stop = 0;
void on_term(int) { g_stop = 1; }
}

int main() {
    struct sigaction sa{};
    sa.sa_handler = on_term;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;                     // 不加 SA_RESTART,sigsuspend 才会被打断
    sigaction(SIGTERM, &sa, nullptr);

    sigset_t blocked;
    sigemptyset(&blocked);
    sigaddset(&blocked, SIGTERM);
    sigprocmask(SIG_BLOCK, &blocked, nullptr);

    sigset_t wait_mask;
    sigprocmask(SIG_BLOCK, nullptr, &wait_mask);   // 传 NULL 只查询,取出当前屏蔽字
    sigdelset(&wait_mask, SIGTERM);                // 等待期间放开 SIGTERM

    std::puts("等 SIGTERM ...");
    while (!g_stop) {
        sigsuspend(&wait_mask);          // 总是返回 -1
    }
    std::puts("收到,退出");
    return 0;
}

典型用途就是"等一个信号,但等待窗口里不想丢别的信号"。如果要同时等"信号或文件可读",用下一节的 signalfd + poll。

21. sigqueue 与实时信号

kill 只能发编号,不能带数据。要带数据用 sigqueue:

#include <signal.h>
int sigqueue(pid_t pid, int sig, const union sigval value);

union sigval {
    int   sival_int;   // 传一个整数
    void *sival_ptr;   // 传一个指针,只对同一进程内的接收者有意义
};

参数:pid 的语义和 kill 完全一致;sig 建议用实时信号 SIGRTMIN ~ SIGRTMAX;value 是随信号一起送过去的数据。返回 0 成功,-1 失败。

接收端要用三参数版本,并且 sa_flags 必须带 SA_SIGINFO:

void handler(int sig, siginfo_t *info, void *ucontext);
// ucontext 是被打断时的机器上下文,普通场景用不到,忽略即可

siginfo_t 常用字段:

字段含义
si_signo信号编号
si_code来源:SI_USER(kill/raise)、SI_QUEUE(sigqueue)、SI_TKILL、SI_KERNEL 等
si_pid / si_uid发送者的 pid / uid
si_valuesigqueue 带来的那个 union sigval
si_statusSIGCHLD 时表示子进程的退出状态

实时信号和常规信号最实际的区别:实时信号排队,发 5 个收 5 次;常规信号不排队,发 5 个可能只处理 1 次。

#include <csignal>
#include <cstdio>
#include <sys/wait.h>
#include <unistd.h>

namespace {
volatile std::sig_atomic_t g_count = 0;
volatile std::sig_atomic_t g_last  = 0;

void on_rt(int, siginfo_t* info, void*) {
    g_count++;
    g_last = info->si_value.sival_int;
}
}

int main() {
    const int signo = SIGRTMIN;

    struct sigaction sa{};
    sa.sa_sigaction = on_rt;
    sa.sa_flags = SA_SIGINFO;
    sigemptyset(&sa.sa_mask);
    if (sigaction(signo, &sa, nullptr) == -1) {
        std::perror("sigaction");
        return 1;
    }

    sigset_t set, old;
    sigemptyset(&set);
    sigaddset(&set, signo);
    sigprocmask(SIG_BLOCK, &set, &old);      // 先堵住,免得子进程发得太快

    pid_t pid = fork();
    if (pid == 0) {
        for (int i = 0; i < 5; ++i) {
            union sigval v{};
            v.sival_int = i;
            if (sigqueue(getppid(), signo, v) == -1) std::perror("sigqueue");
        }
        _exit(0);                            // 子进程不要用 exit,避免刷新父进程的缓冲
    }

    waitpid(pid, nullptr, 0);
    sigprocmask(SIG_SETMASK, &old, nullptr); // 放开,5 个信号依次递达
    sleep(1);

    std::printf("收到 %d 次,最后一次 value = %d\n", static_cast<int>(g_count), static_cast<int>(g_last));
    return 0;
}

把 SIGRTMIN 换成 SIGUSR1 再跑一遍,g_count 基本是 1。这就是"常规信号不排队"的实证。

处理函数里只做了一个赋值,没调 printf,是因为 printf 不是异步信号安全的(见 26 节)。

22. sigwait 与 signalfd

处理函数是异步的,栈回溯不好看。另一条路是先阻塞,再同步地把信号取出来。

#include <signal.h>
int sigwait(const sigset_t *set, int *sig);
int sigwaitinfo(const sigset_t *set, siginfo_t *info);
int sigtimedwait(const sigset_t *set, siginfo_t *info,
                 const struct timespec *timeout);

参数:set 是"我要接收的信号集合",里面的信号必须事先被阻塞,否则会先走默认动作或处理函数,轮不到 sigwait;sig 输出收到的编号;timeout 传 NULL 表示一直等。成功返回 0,失败返回错误码本身(不是设置 errno,这一点和别的函数不一样)。

sigwait 适合"专门开一个线程收信号"的模型。已经用 poll/epoll 的事件循环更适合 signalfd——信号变成一个可读的 fd,和其他事件一起等。

#include <cerrno>
#include <csignal>
#include <cstdio>
#include <poll.h>
#include <sys/signalfd.h>
#include <unistd.h>

int main() {
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);
    sigaddset(&mask, SIGTERM);

    // 必须先阻塞,否则信号走默认动作,signalfd 读不到
    if (sigprocmask(SIG_BLOCK, &mask, nullptr) == -1) {
        std::perror("sigprocmask");
        return 1;
    }

    int fd = signalfd(-1, &mask, SFD_CLOEXEC);   // 第一个参数传 -1 表示新建
    if (fd == -1) {
        std::perror("signalfd");
        return 1;
    }

    std::puts("signalfd 就绪,按 Ctrl+C 测试");
    for (;;) {
        pollfd pfd{fd, POLLIN, 0};
        if (poll(&pfd, 1, -1) == -1) {
            if (errno == EINTR) continue;
            std::perror("poll");
            break;
        }

        signalfd_siginfo si{};
        ssize_t n = read(fd, &si, sizeof si);
        if (n != static_cast<ssize_t>(sizeof si)) continue;

        std::printf("收到信号 %u,来自 pid %u\n", si.ssi_signo, si.ssi_pid);
        if (si.ssi_signo == SIGTERM) break;
    }

    close(fd);
    return 0;
}

signalfd 返回的是一个只读 fd。一次 read 有可能取回多个 signalfd_siginfo,严谨写法要按 n / sizeof(signalfd_siginfo) 循环取,上面为简洁只取了一条。

sigwait 的多线程用法,骨架是这样:

#include <pthread.h>

sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGTERM);
pthread_sigmask(SIG_BLOCK, &set, nullptr);   // 每个线程都要做,包括 worker

int signo = 0;
while (sigwait(&set, &signo) == 0 && signo != SIGTERM) {
    // 处理 SIGINT
}

23. 补充:多线程下的信号

  • 信号是发给进程的:kill(pid, sig)、终端按键都是进程级。内核会从这个进程里挑一个没阻塞该信号的线程递达,挑谁不确定。
  • 如果所有线程都阻塞了这个信号,它就 pending 挂着,等某个线程解除阻塞或用 sigwait 取走。
  • 每个线程有自己的信号屏蔽字。pthread_sigmask 只改当前调用线程,不影响别的线程。
  • 多线程程序按 POSIX 应该用 pthread_sigmask。Linux 上 sigprocmask 和它实现一致,实际也只影响当前线程,但别混着理解。
  • 新线程继承创建它的线程的屏蔽字。所以主线程要在创建业务线程之前先把关心的信号阻塞掉,晚了就继承不到。
  • 阻塞 ≠ 忽略:阻塞是先挂着 pending;忽略是 SIG_IGN 直接丢掉。
  • handler 在哪个线程执行不确定,因为进程级信号可能被任意未阻塞线程收走。所以 handler 里:

    • 不要碰线程局部存储 TLS(Thread Local Storage,线程局部存储);
    • 不要加锁;
    • 不要 printf、malloc 这类非 async-signal-safe 函数;
    • 只调 async-signal-safe 函数。
  • 常规套路:主线程启动时先阻塞关心的信号 → 创建专门线程用 sigwait 收信号,或用 signalfd → 业务线程继承阻塞状态,全程不被打扰。
  • pthread_kill(tid, sig) 是唯一能定向到指定线程的接口。kill(pid, sig) 不能指定线程;raise(sig) 本质是发给当前线程。
  • 我容易错的点:

    • 以为 sigprocmask 会改所有线程:不会,只改当前线程。
    • 以为 kill 能发给某个线程:不能,它是进程级。
    • 以为 handler 总在主线程跑:不一定。
    • 以为"阻塞信号"就是"忽略信号":不是。
    • 主线程先创建了业务线程再阻塞信号:已经晚了,业务线程继承了旧屏蔽字。

24. 补充:fork 和 exec 对信号状态的影响

状态fork 之后exec 之后
处理函数继承复位成 SIG_DFL(原来是 SIG_IGN 的仍是 SIG_IGN)
屏蔽字继承保留
未决集清空保留
备用栈继承关闭(SS_DISABLE)

实践含义:

  • 子进程继承父进程的屏蔽字。父进程为了防竞态先屏蔽 SIGCHLD 再 fork,子进程身上也是屏蔽的,别忘了在子进程里恢复。
  • exec 之后自定义处理函数全部失效,这是好事,不然新程序会跑进旧程序的函数。但被忽略的信号会一路继承,nohup 就是靠忽略 SIGHUP 实现的。
  • 没有"重置全部信号"的 API,只能遍历 1~64 逐个 signal(i, SIG_DFL),SIGKILL / SIGSTOP 会失败,忽略错误即可。

25. C++17 实践:RAII 封装和优雅退出

先明确 handler 的限制:只能是普通函数指针,不能是 std::function,不能捕获 lambda。所以惯用法是"全局原子标志 + 主循环干活"。

用 std::atomic<bool> 比 volatile sig_atomic_t 更清楚。C++ 标准保证 lock-free 的原子类型可以在处理函数里安全使用,而 std::atomic<bool> 在所有主流平台上都是 lock-free,这一点还能用 static_assert 在编译期验证。

#include <atomic>
#include <csignal>
#include <cstdio>
#include <ctime>
#include <unistd.h>

namespace {

std::atomic<bool> g_quit{false};

void on_signal(int) {
    g_quit.store(true, std::memory_order_relaxed);
}

// 构造时安装,析构时恢复原来的处理方式
class SigactionGuard {
public:
    SigactionGuard(int signo, void (*handler)(int), int flags = 0) : signo_(signo) {
        struct sigaction sa{};
        sa.sa_handler = handler;
        sigemptyset(&sa.sa_mask);
        sa.sa_flags = flags;
        ok_ = sigaction(signo_, &sa, &old_) == 0;
    }

    ~SigactionGuard() {
        if (ok_) sigaction(signo_, &old_, nullptr);
    }

    SigactionGuard(const SigactionGuard&)            = delete;
    SigactionGuard& operator=(const SigactionGuard&) = delete;

    bool ok() const { return ok_; }

private:
    int  signo_;
    bool ok_{false};
    struct sigaction old_{};
};

}  // namespace

int main() {
    static_assert(std::atomic<bool>::is_always_lock_free, "处理函数里只能用 lock-free 原子");

    SigactionGuard g1(SIGINT, on_signal);
    SigactionGuard g2(SIGTERM, on_signal);
    if (!g1.ok() || !g2.ok()) {
        std::perror("sigaction");
        return 1;
    }

    int round = 0;
    while (!g_quit.load(std::memory_order_relaxed)) {
        std::printf("工作第 %d 轮\n", ++round);
        struct timespec ts{0, 200 * 1000 * 1000};   // 200ms
        nanosleep(&ts, nullptr);                    // 被打断也无所谓,下一轮再看标志
    }

    std::puts("收到退出信号,做清理后退出");
    return 0;
}

几个要点:

  • 主循环用短睡眠 + 检查标志,不要用 pause()。pause() 遇到无关信号会立刻返回,容易空转。
  • 析构时恢复旧设置,避免"对象没了但信号还指着它的函数"。
  • 如果需要"阻塞等待 + 立刻响应",用 self-pipe:处理函数里只 write(fd, &b, 1),主线程 poll 这个 pipe 的读端。write 是异步信号安全的,这是唯一能在处理函数里安全叫醒主线程的手段。signalfd 本质上是同一思路的更现代版本。

26. 补充:EINTR 重试和常见坑

只要有不带 SA_RESTART 的处理函数,或者用了 sigsuspend / sigwait,就得处理 EINTR。封装成模板最省事:

#include <cerrno>

template <typename F>
auto retry_on_eintr(F&& f) -> decltype(f()) {
    decltype(f()) r;
    do {
        r = f();
    } while (r == -1 && errno == EINTR);
    return r;
}

// 用法
// ssize_t n = retry_on_eintr([&] { return read(fd, buf, sizeof buf); });

只对"可以重来"的调用这么做:read / write / accept / wait 安全;已经消费了一半数据、或者带副作用的调用,重试前要自己判断。

常见坑清单,都是实际会遇到的:

现象原因
进程被无声杀掉,什么都没打印往已关闭的 socket / pipe 写数据触发了 SIGPIPE;忽略它,改判 EPIPE
僵尸进程越积越多SIGCHLD 处理函数里只 wait 一次;要 while + WNOHANG
信号"丢了"常规信号不排队;要么用实时信号,要么用标志位
fork 后子进程行为不对子进程继承了父进程的屏蔽字
sleep 提前醒来还返回正数被信号打断,返回的是剩余秒数,不是错误码
加了 SA_RESTART 但 read 还是返回 EINTR该调用本身不支持重启(poll 的超时、部分 socket 操作),或者是在 sigsuspend 里
处理函数里 printf 偶尔卡死printf 不是异步信号安全的,会死在 stdio 的锁上
行为随机、时好时坏sigset_t 忘了 sigemptyset,sigaction 结构体忘了清零
system() / popen() 之后 SIGCHLD 处理函数像失效了它们内部会改 SIGCHLD 的处理方式,用完不一定恢复

SIGPIPE 的标准处理:

struct sigaction sa{};
sa.sa_handler = SIG_IGN;      // 忽略后 write 返回 -1 / EPIPE,进程不会被杀
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGPIPE, &sa, nullptr);

27. 补充复习清单

  • [ ] sa_flags 里 SA_RESTART、SA_SIGINFO、SA_NODEFER、SA_RESETHAND 各控制什么?
  • [ ] sigsuspend 的参数是"新屏蔽字"还是"要放开的信号"?
  • [ ] 想在处理函数里拿到发送者的 pid,需要哪两个条件?
  • [ ] 实时信号和常规信号在排队上的差别?怎么用几行代码验证?
  • [ ] 多线程程序里信号会递给哪个线程?怎么把信号集中到一个线程处理?
  • [ ] exec 之后哪些信号状态会保留,哪些不会?
  • [ ] 为什么处理函数里能改 std::atomic<bool>,却不能调用 malloc 或 printf?