Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

2.10 信号

本章回答

信号从产生到被处理,中间经过哪些状态?为什么信号处理器里连 printf 都不能调用?SA_RESTART 到底重启哪些系统调用、哪些不重启?标准信号为什么会「丢」,实时信号又怎么排队?怎样把信号接进 select/epoll 的事件循环?

读完你会

用 sigaction 而不是 signal 安装处理器;写出只调用 async-signal-safe 函数、正确保存 errno 的处理器;用 self-pipe 与 signalfd 两种方式把信号变成 fd 事件;解释 EINTR、信号合并、sigsuspend 的原子性。

2.9 讲的是「进程是谁」。信号则是内核或其他进程打断这个进程的方式:一个整数,加上一条递送规则。它不携带数据(实时信号除外),不保证排队,也不保证在「合适的时候」到达——这三点构成了信号编程的全部麻烦。

信号的历史比线程早,比 epoll 早得多。它的语义是「异步通知 + 默认动作」:SIGINT 默认终止,SIGSEGV 默认终止并 core dump,SIGCHLD 默认忽略。你可以改变处置(捕获、忽略、阻塞),但改不了它的异步性:处理器在任意两条指令之间插入执行,主流程看到的世界可能已被改了一半。

这一章按「产生 → 未决 → 阻塞/递送 → 处置」这条链展开,重点放在三件事:处理器里能做什么(async-signal-safe 与 volatile sig_atomic_t)、系统调用被打断怎么办(EINTR 与 SA_RESTART)、怎样把信号纳入事件循环(self-pipe 与 signalfd)。这三件事决定了你的服务在收到 SIGTERM 时是优雅退出还是丢数据。

2.10.1 信号编号、默认动作与两类信号

定义 2.10.1 · 信号(signal)

信号是内核向进程递送的一个短消息,用一个整数标识(SIGINT=2、SIGKILL=9、SIGSEGV=11、SIGTERM=15……)。每个信号有一个默认动作:终止(Term)、终止并 core dump(Core)、忽略(Ign)、停止(Stop)、继续(Cont)。

进程可以:捕获(安装处理器,SIGKILL/SIGSTOP 除外)、忽略(SIG_IGN,同样有例外)、阻塞(暂缓递送,进入未决状态)。

常用信号与默认动作:

信号编号默认动作典型来源能捕获/阻塞
SIGHUP1Term终端关闭、nohup 要忽略它是
SIGINT2TermCtrl-C是
SIGABRT6Coreabort()是
SIGKILL9Termkill -9否
SIGUSR110Term用户自定义是
SIGSEGV11Core非法内存访问是(同上)
SIGPIPE13Term写已关闭的管道/socket是
SIGALRM14Termalarm()/setitimer()是
SIGTERM15Termkill、服务管理器是
SIGCHLD17Ign子进程状态变化是
SIGCONT18Contfg/bg、kill -CONT是
SIGSTOP19Stop作业控制否
SIGTSTP20StopCtrl-Z是
SIGTTIN21Stop后台进程读终端是
SIGTTOU22Stop后台进程写终端是

SIGQUIT(3,Ctrl-\)、SIGILL(4)、SIGBUS(7)、SIGFPE(8)默认都带 core dump,属于「硬件/指令异常」一类。SIGKILL 与 SIGSTOP 不能被捕获、阻塞或忽略——这是内核的硬约束,保证管理员总能杀死或暂停一个失控进程。SIGSTOP 与 SIGTSTP 的区别就在这:前者不可拦截,后者可以被程序自己处理(比如编辑器保存现场)。

标准信号与实时信号

  • 标准信号(1–31):不排队。同一信号在未决期间再来一次,只记一次;递送后未决位清零。
  • 实时信号(SIGRTMIN–SIGRTMAX,Linux 上通常是 34–64):排队,按编号与到达顺序递送,可以携带一个 union sigval(整数或指针)。

「不排队」是很多 bug 的根源:父进程在 SIGCHLD 处理器里只 waitpid 一次,三个子进程同时退出时可能只回收一个(2.7)。需要「每次事件都要计数」的场景,必须用实时信号或 self-pipe/signalfd。

信号号 ↔ 描述:strsignal、psignal 与 killpg

日志里不要只打印数字,读者记不住 11 是 SIGSEGV:

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

printf("signal %d: %s\n", sig, strsignal(sig));  /* 不可重入,多线程用 psignal 或自己查表 */
psignal(sig, "child");                            /* 等价 fprintf(stderr, "child: %s\n", strsignal(sig)) */

strsignal 返回静态字符串,多线程/信号处理函数里不安全;psignal 直接写 stderr,也不保证 async-signal-safe。信号处理函数里只应 write 一个预构造的字符串。

killpg(pgrp, sig) 等价 kill(-pgrp, sig):给整个进程组发信号。交互式 shell 的 Ctrl-C 就是给前台进程组发 SIGINT;kill(-1, sig) 是「发给所有有权限的进程」,极其危险,不要在生产代码里用。

2.10.2 产生、未决、递送:一条信号的完整路径

  产生(谁发的)                 未决(pending)                 递送(delivery)
┌──────────────────┐        ┌──────────────────┐          ┌────────────────────┐
│ kill(pid, sig)    │        │ 进程的 pending 位 │          │ 若被阻塞:留在 pending│
│ raise(sig)        │───────►│ 标准信号:1 位    │─────────►│ 若未阻塞:           │
│ pthread_kill      │        │ 实时信号:队列    │          │  ├─ 有处理器 → 执行   │
│ alarm/setitimer   │        └──────────────────┘          │  ├─ SIG_IGN → 丢弃   │
│ 终端 Ctrl-C       │                 ▲                     │  └─ 默认动作 → 终止/停│
│ 硬件异常(SIGSEGV)│                 │                     └────────────────────┘
└──────────────────┘        sigprocmask 阻塞/解除

读图:信号产生后先进入未决集合。若该信号当前被阻塞(sigprocmask),它就停在那里,直到解除阻塞;若未阻塞,内核立刻按处置动作处理:有处理器就调用(处理器执行期间,该信号默认被阻塞,除非 SA_NODEFER),SIG_IGN 就丢弃,否则执行默认动作。「未决」不等于「排队」:标准信号的未决集合是一个位图,一个信号只有一位。

三个容易混淆的状态:

状态含义观测方式
已产生、未决已发送但尚未递送/proc/{pid}/status 的 SigPnd/ShdPnd
被阻塞该信号被掩码挡住,不能递送SigBlk
被忽略递送后直接丢弃SigIgn
被捕获递送后执行处理器SigCgt

/proc/{pid}/status 里 SigPnd、ShdPnd、SigBlk、SigIgn、SigCgt 都是 64 位十六进制掩码。排查「为什么进程不响应 SIGTERM」时,先看 SigBlk 有没有把 15 置位,再看 SigCgt 是否装了一个不干活的处理器。

2.10.3 sigaction:比 signal 多出来的控制力

signal() 的问题是语义随实现变化:System V 会在递送后把处置复位为默认,BSD 不会;glibc 的 signal() 包装成 BSD 语义(带 SA_RESTART),但其他 libc 不一定。新代码一律用 sigaction。

定义 2.10.2 · `struct sigaction` 的关键字段
struct sigaction {
    void (*sa_handler)(int);                  /* 简单处理器 */
    void (*sa_sigaction)(int, siginfo_t *, void *); /* SA_SIGINFO 时使用 */
    sigset_t sa_mask;                         /* 处理器执行期间额外阻塞的信号 */
    int sa_flags;                             /* SA_RESTART、SA_SIGINFO、SA_NODEFER... */
    void (*sa_restorer)(void);                /* 内部使用,不要碰 */
};

sa_handler 与 sa_sigaction 是联合体语义:只能设一个;设了 SA_SIGINFO 就用三参数版本。sa_mask 在处理器执行期间自动加到信号掩码上;被递送的那个信号默认也会被阻塞(除非 SA_NODEFER)。

常用 sa_flags:

flag作用什么时候用
SA_RESTART自动重启被中断的慢系统调用大多数「读循环」场景
SA_SIGINFO使用三参数处理器,拿到 siginfo_t需要知道谁发的、附带值
SA_NODEFER处理器执行期间不自动阻塞该信号极少用,容易递归
SA_RESETHAND进入处理器时把处置复位为默认一次性处理器
SA_NOCLDSTOP子进程停止/继续时不发 SIGCHLD只关心退出的父进程
SA_NOCLDWAIT子进程退出不产生僵尸不关心退出状态
SA_ONSTACK在 sigaltstack 的栈上执行栈溢出类信号(SIGSEGV)

安装一个处理器:

struct sigaction sa = {0};
sa.sa_sigaction = on_signal;       /* 配合 SA_SIGINFO */
sa.sa_flags = SA_SIGINFO | SA_RESTART;
sigemptyset(&sa.sa_mask);
sigaddset(&sa.sa_mask, SIGUSR1);   /* 处理器里再屏蔽 SIGUSR1,避免嵌套 */
if (sigaction(SIGTERM, &sa, NULL) == -1) {
    perror("sigaction");
    exit(EXIT_FAILURE);
}

sigaction 的第三个参数可以取旧处置,用于「临时替换再恢复」:先保存到 struct sigaction old,用完再 sigaction(SIGINT, &old, NULL) 还原。

SA_RESTART 与 EINTR

慢系统调用(可能永久阻塞的调用)在等待期间收到信号,如果处理器没有 SA_RESTART,调用被中断,返回 -1、errno = EINTR。哪些调用会重启、哪些不会:

会重启(设了 SA_RESTART)不会重启
read/write(慢设备、管道、socket)poll/select/epoll_wait
open(阻塞在 FIFO/设备上)nanosleep/clock_nanosleep(返回剩余时间)
wait/waitpidsemop/semtimedop
acceptconnect
flock/fcntl 的阻塞锁read/write 在已经传输了部分数据时(返回短计数)

SA_RESTART 只对「可重启」的调用有效,而且不是所有情况都能重启:如果 read 已经读到了一部分数据,信号到达后它返回已读字节数,而不是 EINTR——这会被误判成「读完了」。所以无论是否设 SA_RESTART,库代码都必须处理 EINTR 与短读。

下面的程序把「不重启」与「重启」的差异做成可观测的计时:

/* sigaction_demo.c:SA_RESTART 对 read 的影响 */
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/time.h>
#include <sys/wait.h>
#include <unistd.h>

static volatile sig_atomic_t alarm_count;
static volatile sig_atomic_t last_sender;

static void on_alarm(int sig, siginfo_t *info, void *ctx)
{
    (void)sig;
    (void)ctx;
    alarm_count++;
    last_sender = (sig_atomic_t)info->si_pid;
    /* 处理器里只能调用 async-signal-safe 函数;write 是安全的 */
    static const char msg[] = "[handler] SIGALRM\n";
    if (write(STDOUT_FILENO, msg, sizeof msg - 1) == -1) {
        /* 忽略:处理器里没有更好的补救办法 */
    }
}

static void install(int restart)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_sigaction = on_alarm;
    sa.sa_flags = SA_SIGINFO | (restart ? SA_RESTART : 0);
    sigemptyset(&sa.sa_mask);
    if (sigaction(SIGALRM, &sa, NULL) == -1) {
        perror("sigaction");
        exit(EXIT_FAILURE);
    }
}

static double now_ms(void)
{
    struct timeval tv;
    if (gettimeofday(&tv, NULL) == -1)
        return 0;
    return (double)tv.tv_sec * 1000.0 + (double)tv.tv_usec / 1000.0;
}

int main(void)
{
    int pipefd[2];
    if (pipe(pipefd) == -1) {
        perror("pipe");
        return EXIT_FAILURE;
    }

    /* 场景一:没有 SA_RESTART,read 被 SIGALRM 打断 */
    install(0);
    alarm(1);
    double t0 = now_ms();
    char c;
    ssize_t n = read(pipefd[0], &c, 1);
    double t1 = now_ms();
    printf("no SA_RESTART : read=%zd errno=%d (%s) after %.0f ms\n",
           n, n == -1 ? errno : 0, n == -1 ? strerror(errno) : "-", t1 - t0);

    /* 场景二:有 SA_RESTART,read 在信号后继续等,直到子进程写入 */
    install(1);
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return EXIT_FAILURE;
    }
    if (pid == 0) {
        close(pipefd[0]);
        sleep(2);
        if (write(pipefd[1], "x", 1) != 1)
            _exit(1);
        _exit(0);
    }

    alarm(1);
    t0 = now_ms();
    n = read(pipefd[0], &c, 1);
    t1 = now_ms();
    printf("with SA_RESTART: read=%zd byte='%c' after %.0f ms, alarms=%d\n",
           n, n == 1 ? c : '?', t1 - t0, (int)alarm_count);

    int status;
    if (waitpid(pid, &status, 0) == -1) {
        perror("waitpid");
        return EXIT_FAILURE;
    }
    close(pipefd[0]);
    close(pipefd[1]);
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g sigaction_demo.c -o sigaction_demo
./sigaction_demo
[handler] SIGALRM
no SA_RESTART : read=-1 errno=4 (Interrupted system call) after 1001 ms
[handler] SIGALRM
with SA_RESTART: read=1 byte='x' after 2001 ms, alarms=2

第一段:read 在 1 秒后被 SIGALRM 打断,返回 EINTR(Linux 上 errno=4)。第二段:同样 1 秒时信号到达,但 SA_RESTART 让内核重新进入等待,直到 2 秒时子进程写入才返回。总耗时 2001 ms 证明它没有在 1 秒时返回。

关于 `SA_RESTART` 的三个误解
  1. 「设了 SA_RESTART 就不用处理 EINTR」——poll/select/epoll_wait/nanosleep/semop 不会重启;已经传输了部分数据的 read/write 会返回短计数。
  2. 「signal() 默认不重启」——glibc 的 signal() 用的是 BSD 语义,默认带 SA_RESTART。这正是它不可移植的原因:同一个程序在 musl 或旧 SysV 上行为不同。
  3. 「EINTR 都是错误」——它只表示「被信号打断」。对可重启调用应重试;对 nanosleep 要用 rem 续睡;对 poll 要重算超时。

2.10.4 处理器里能做什么:async-signal-safe 与 volatile sig_atomic_t

处理器在任意时刻插入主流程执行。它可能打断 malloc 正在改空闲链表、printf 正在填缓冲区、fopen 正在加锁。如果处理器再调用 malloc/printf,就会看到半改完的状态,或者在同一把锁上死锁。

POSIX 定义了一组 async-signal-safe 函数:在信号处理器里调用它们是安全的。常用的有:

_exit, write, read, open, close, dup, dup2, fork, execve, waitpid,
kill, raise, sigaction, sigprocmask, sigpending, sigsuspend,
socket, connect, accept, send, recv, bind, listen,
memcpy?(POSIX 未列入), strlen?(未列入)

不在列表里的常见函数:printf/fprintf、malloc/free、strerror、syslog、pthread_mutex_lock、sem_post(Linux 上 sem_post 是安全的,但 POSIX 未保证)、exit(会 flush 与跑 atexit)、abort(SUSv3 列为 async-signal-safe,但会触发 core)。

定理 2.10.1 · 处理器与主流程的共享变量

处理器与主流程共享的变量必须满足两个条件:

  1. 类型是 volatile sig_atomic_t(保证单次读写原子,且编译器不会把它优化进寄存器);
  2. 主流程对它的读写不依赖「读-改-写」的原子性;需要计数时,要么用 sig_atomic_t 的简单自增(在单处理器上仍是读-改-写,严格说需要 sigprocmask 保护),要么用 C11 的 atomic_int(要求 lock-free)。

实践中,处理器只做两件事最稳:设置一个 volatile sig_atomic_t 标志;向一个 fd 写一个字节(self-pipe),让主循环从 poll/epoll 醒来。

不要试图在处理器里做业务:不要解析配置、不要写日志文件、不要释放资源。把「收到信号」与「处理信号」分开,主流程在安全的环境里做决定。

保存与恢复 errno

处理器可能打断一个正在设置 errno 的库函数。如果处理器调用的函数(如 write 失败)改了 errno,主流程醒来后看到的 errno 就被污染了。规范写法:

static void on_signal(int sig)
{
    int saved_errno = errno;
    /* ... 只调用 async-signal-safe 函数 ... */
    errno = saved_errno;
}

self-pipe:把信号变成 fd 事件

self-pipe 是经典技巧:创建一个管道,处理器往写端写一个字节;主循环把读端放进 poll/epoll。信号于是变成「fd 可读」,与网络事件用同一套逻辑处理。

要点:

  • 管道用 pipe2(O_NONBLOCK | O_CLOEXEC),写端非阻塞,避免处理器在管道满时阻塞;
  • 处理器里 write 失败(EAGAIN)就丢弃——已经有一个字节在管道里,主循环迟早会醒,不需要为每个信号保留一个字节;
  • 主循环醒来后把管道读空(循环 read 到 EAGAIN),再根据标志处理;
  • 信号标志与管道字节配合:字节负责唤醒,标志负责「是什么信号」。
/* selfpipe.c:用 self-pipe 把 SIGINT/SIGTERM 接进 poll 循环 */
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

static int sigpipe[2] = { -1, -1 };
static volatile sig_atomic_t got_signal;

static void on_signal(int sig)
{
    int saved_errno = errno;
    got_signal = sig;
    unsigned char byte = (unsigned char)sig;
    ssize_t n = write(sigpipe[1], &byte, 1);
    (void)n;                     /* EAGAIN 表示管道已满,主循环会醒 */
    errno = saved_errno;
}

static void install(int sig)
{
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_signal;
    sa.sa_flags = SA_RESTART;
    sigemptyset(&sa.sa_mask);
    if (sigaction(sig, &sa, NULL) == -1) {
        perror("sigaction");
        exit(EXIT_FAILURE);
    }
}

int main(void)
{
    if (pipe2(sigpipe, O_NONBLOCK | O_CLOEXEC) == -1) {
        perror("pipe2");
        return EXIT_FAILURE;
    }
    install(SIGINT);
    install(SIGTERM);

    printf("ready (pid=%ld); press Ctrl-C or send SIGTERM\n", (long)getpid());

    for (;;) {
        struct pollfd fds[2] = {
            { .fd = sigpipe[0], .events = POLLIN },
            { .fd = STDIN_FILENO, .events = POLLIN },
        };

        int rc = poll(fds, 2, -1);
        if (rc == -1) {
            if (errno == EINTR)
                continue;
            perror("poll");
            return EXIT_FAILURE;
        }

        if (fds[0].revents & POLLIN) {
            /* 读空管道:可能有多个信号合并成一个字节 */
            unsigned char buf[64];
            while (read(sigpipe[0], buf, sizeof buf) > 0)
                ;
            printf("signal %d received\n", (int)got_signal);
            if (got_signal == SIGINT || got_signal == SIGTERM) {
                printf("shutting down cleanly\n");
                break;
            }
        }

        if (fds[1].revents & (POLLIN | POLLHUP)) {
            char line[128];
            ssize_t n = read(STDIN_FILENO, line, sizeof line - 1);
            if (n <= 0)
                break;             /* stdin EOF:退出演示,避免 POLLHUP 忙循环 */
            line[n] = '\0';
            printf("stdin: %s", line);
        }
    }

    close(sigpipe[0]);
    close(sigpipe[1]);
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g selfpipe.c -o selfpipe
./selfpipe
# 另一个终端:kill -TERM <pid>
ready (pid=52011); press Ctrl-C or send SIGTERM
^Csignal 2 received
shutting down cleanly

self-pipe 的优点是可移植:任何支持 poll/select 的系统都能用。缺点是每个信号都要经过一次 write + read,且信号标志只能保存「最后一个信号」,多个不同信号连续到达时可能丢失中间信息(管道字节也只能帮助唤醒,不能区分)。需要精确计数时用实时信号或 signalfd。

2.10.5 阻塞、等待与 sigsuspend 的原子性

sigprocmask 修改调用线程的信号掩码:

sigset_t set, old;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
sigprocmask(SIG_BLOCK, &set, &old);        /* 进入临界区 */
/* ... SIGUSR1 被阻塞,不会打断 ... */
sigprocmask(SIG_SETMASK, &old, NULL);      /* 恢复原掩码 */

sigprocmask 在多线程程序里的行为 POSIX 未定义,应改用 pthread_sigmask;Linux 上 sigprocmask 只作用于调用线程,新线程继承创建者的掩码。要让某个信号只在特定线程处理,正确做法是在主线程阻塞该信号,再让目标线程 sigwait/signalfd(2.10.7)。

不要用 pause() 等信号

pause() 有一个经典竞态:

/* 错误:信号可能在 sigprocmask 之后、pause 之前到达 */
sigprocmask(SIG_UNBLOCK, &set, NULL);
pause();           /* 如果信号已经递送,这里会永远睡下去 */

sigsuspend 把「设置掩码」与「挂起」合成一个原子操作:

sigset_t wait_mask;
sigemptyset(&wait_mask);              /* 等待期间不阻塞任何信号 */
if (sigsuspend(&wait_mask) == -1 && errno != EINTR)
    perror("sigsuspend");
/* 返回时信号已递送;掩码恢复为调用前的值 */

sigsuspend 总是返回 -1、errno = EINTR(它没有别的返回路径)。它的价值在于不会丢信号:信号要么在调用前到达(此时不会进入挂起),要么在挂起期间到达并唤醒它。

同步等待:sigwaitinfo 与 sigtimedwait

不想用处理器时,可以同步地「收」信号:

sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
sigprocmask(SIG_BLOCK, &set, NULL);        /* 必须先阻塞 */
siginfo_t info;
int sig = sigwaitinfo(&set, &info);        /* 取走一个未决信号 */
if (sig != -1)
    printf("got %d from pid %ld\n", sig, (long)info.si_pid);

sigwaitinfo 会取走一个未决信号并返回信号号;它不调用处理器,也不执行默认动作。这是多线程程序里最干净的信号处理方式:主线程阻塞信号,专用线程 sigwaitinfo 循环。sigtimedwait 多一个超时参数,适合「等一会儿,没有就继续」。

关于信号阻塞的四个误解
  1. 「阻塞等于忽略」——阻塞是「暂缓递送」,信号保持未决,解除阻塞后仍会处理;忽略是「递送后丢弃」。
  2. 「SIGKILL 也能阻塞」——不能。sigprocmask 对 SIGKILL/SIGSTOP 的请求会被静默忽略。
  3. 「pause() 不会丢信号」——会。信号在 pause 之前到达就会永久睡下去,必须用 sigsuspend。
  4. 「多线程里 kill 会发给所有线程」——进程级信号只递送给一个未阻塞它的线程;要发给指定线程用 pthread_kill。

2.10.6 SA_SIGINFO、实时信号与排队

SA_SIGINFO 让处理器拿到三个参数:信号号、siginfo_t *、ucontext_t *。siginfo_t 里最有用的字段:

字段含义
si_code来源:SI_USER(kill)、SI_QUEUE(sigqueue)、SI_TIMER、SEGV_MAPERR 等;SIGCHLD 用 CLD_* 正数编码
si_pid/si_uid发送者(SI_USER/SI_QUEUE 时有效)
si_valuesigqueue 携带的 union sigval
si_addr故障地址(SIGSEGV/SIGBUS)
si_status子进程状态(SIGCHLD)
static void on_usr(int sig, siginfo_t *info, void *ctx)
{
    (void)ctx;
    int saved = errno;
    last_sig = sig;                        /* 只记录,不格式化输出 */
    last_value = info->si_value.sival_int;
    errno = saved;
}

发送带值的信号:

union sigval val;
val.sival_int = 42;
if (sigqueue(pid, SIGUSR1, val) == -1) {
    perror("sigqueue");
}

实时信号为什么会排队

标准信号在未决集合里只占一位,连续两次 kill 只算一次。实时信号有独立的队列,每个都保留(受 RLIMIT_SIGPENDING 限制),并按信号号从小到大、同号按到达顺序递送。

/* 发送 5 个实时信号,每个带不同的值 */
for (int i = 0; i < 5; i++) {
    union sigval v;
    v.sival_int = i;
    if (sigqueue(pid, SIGRTMIN, v) == -1) {
        perror("sigqueue");
    }
}

接收端如果先阻塞 SIGRTMIN,再用 sigwaitinfo 循环 5 次,会依次拿到 0、1、2、3、4。换成标准信号 SIGUSR1,同样的代码只会拿到一次——这就是「信号会丢」的准确含义。

实时信号的编号

SIGRTMIN 在 glibc 里不是常数,而是一个函数调用(__libc_current_sigrtmin()),因为 NPTL 保留了前几个实时信号给内部使用。不要写 SIGRTMIN + 0 当编译期常量,也不要把实时信号号硬编码成 34。

2.10.7 signalfd:信号直接变成 fd

Linux 2.6.22 引入 signalfd:把一个信号集合绑定到一个 fd,信号到达时 fd 可读,读出来是 struct signalfd_siginfo。它比 self-pipe 少一次 write,且能拿到完整的 siginfo_t。

/* signalfd_demo.c:用 signalfd 接收 SIGINT/SIGTERM(Linux) */
#include <errno.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/signalfd.h>
#include <unistd.h>

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

    /* 必须先阻塞:否则信号会按默认动作直接终止进程 */
    if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) {
        perror("sigprocmask");
        return EXIT_FAILURE;
    }

    int sfd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
    if (sfd == -1) {
        perror("signalfd");
        return EXIT_FAILURE;
    }

    printf("waiting for SIGINT/SIGTERM (pid=%ld)\n", (long)getpid());

    for (;;) {
        struct pollfd pfd = { .fd = sfd, .events = POLLIN };
        int rc = poll(&pfd, 1, -1);
        if (rc == -1) {
            if (errno == EINTR)
                continue;
            perror("poll");
            break;
        }

        struct signalfd_siginfo si;
        ssize_t n = read(sfd, &si, sizeof si);
        if (n != (ssize_t)sizeof si) {
            if (n == -1 && (errno == EAGAIN || errno == EINTR))
                continue;
            perror("read signalfd");
            break;
        }

        printf("signal %u from pid %u uid %u, code=%u\n",
               si.ssi_signo, si.ssi_pid, si.ssi_uid, si.ssi_code);

        if (si.ssi_signo == SIGTERM || si.ssi_signo == SIGINT)
            break;
    }

    close(sfd);
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g signalfd_demo.c -o signalfd_demo
./signalfd_demo &
kill -TERM %1
waiting for SIGINT/SIGTERM (pid=52140)
signal 15 from pid 52101 uid 1000, code=0

ssi_code=0 是 SI_USER(kill 发来的)。signalfd 的适用场景是事件循环:一个 epoll 同时管 socket、timerfd、signalfd,信号处理不需要单独的线程,也不需要 self-pipe。

两个限制:

  1. 必须先阻塞信号。信号未被阻塞时,内核会先按默认动作处理,signalfd 读不到。
  2. 不能用于同步信号。SIGSEGV、SIGFPE 这类由指令触发的信号是同步递送给出错线程的,不会进入进程的未决队列,signalfd 收不到。

signalfd 与 self-pipe 的选择:

维度self-pipesignalfd
可移植性任何 POSIXLinux 2.6.22+
信息量只有你写进去的字节完整 siginfo_t
线程写端可被任意线程写通常绑在一个线程
与 epoll 集成天然天然
同步信号可以(处理器里写)不行

2.10.8 SIGCHLD、线程与几个真实陷阱

SIGCHLD 会合并

三个子进程同时退出,标准信号只记一次未决。处理器里必须循环回收:

static void on_sigchld(int sig)
{
    (void)sig;
    int saved = errno, status;
    while (waitpid(-1, &status, WNOHANG) > 0)
        reaped++;                  /* 循环回收;这里不能 printf */
    errno = saved;
}

while 条件用 > 0:返回 0 表示还有子进程但没退出,返回 -1 表示没有子进程(ECHILD)或出错,两种情况都退出循环。

信号与线程

  • 进程级信号(kill)递送给任意一个没有阻塞它的线程;具体哪个线程由内核选择,不可预测。
  • pthread_kill(thread, sig) 递送给指定线程。
  • 每个线程有自己的信号掩码;新线程继承创建者的掩码。主线程阻塞某信号后创建的线程也阻塞它,这是「让专用线程处理信号」的标准做法。
  • 信号处理器是进程级的,所有线程共享同一份处置;sa_mask 只影响执行处理器的那个线程。

SIGPIPE

向已关闭的管道或 socket 写数据,内核发 SIGPIPE,默认终止进程。服务端常见做法是忽略它,让 write 返回 EPIPE:

struct sigaction sa = { .sa_handler = SIG_IGN };
sigemptyset(&sa.sa_mask);
sigaction(SIGPIPE, &sa, NULL);        /* 或对 socket 用 MSG_NOSIGNAL */

或者对 socket 用 send(..., MSG_NOSIGNAL)(2.17),避免改全局处置。

SIGSEGV 不能「恢复」

SIGSEGV 处理器可以记录日志、生成 core,但不能 return:return 会重新执行出错指令,再次触发信号,形成死循环。常见做法是 siglongjmp 跳出,但这在 C 标准里是未定义行为(只能保证跳出 sigsetjmp 保存的、且不涉及被中断函数的资源清理)。生产代码应把 SIGSEGV 当作「进程必须死」的信号,用 core dump + 外部重启处理。

退出时的信号竞争

服务收到 SIGTERM 后开始清理,此时又收到一个 SIGTERM,处理器可能重入清理逻辑。两种防护:处理器第一次进入就把处置改为 SIG_IGN(或 SA_RESETHAND);或用 volatile sig_atomic_t 的 shutting_down 标志,第二次直接 _exit(1)。

2.10.9 sigaction 全字段与信号递送的内核路径

这一节回答:struct sigaction 的每个字段由谁维护、sa_flags 的每一位改变什么、信号从内核队列到用户态处理器之间经过哪些函数、SA_RESTORER 为什么存在。

struct sigaction 的字段与布局

glibc 的 struct sigaction 与内核的 struct sigaction 不是同一个东西:用户态结构里有 128 字节的 sigset_t,内核结构里是 8 字节的 sigset_t;glibc 在 sigaction() 里做转换,最终调用 rt_sigaction。

字段类型谁写含义
sa_handler / sa_sigaction联合体调用者处理器地址,或 SIG_DFL/SIG_IGN
sa_masksigset_t调用者处理器执行期间额外阻塞的信号
sa_flagsint调用者行为开关,见下表
sa_restorer函数指针glibc/内核处理器返回后的恢复代码,x86-64 上必须有效

sa_handler 与 sa_sigaction 共享存储:设置 SA_SIGINFO 时内核按三参数函数调用,否则按单参数调用。用 {0} 初始化后只赋一个,另一个自然是 NULL。

sa_flags 位表

flag值(Linux)作用代价/边界
SA_NOCLDSTOP0x00000001子进程停止/继续时不发 SIGCHLD只对 SIGCHLD 有效
SA_NOCLDWAIT0x00000002子进程退出自动回收,不产生僵尸waitpid 返回 ECHILD,拿不到退出码
SA_SIGINFO0x00000004三参数处理器,带 siginfo_t/ucontext_t处理器原型必须匹配
SA_ONSTACK0x08000000在 sigaltstack 提供的栈上执行必须先 sigaltstack,否则退回主栈
SA_RESTART0x10000000重启部分被中断的系统调用不是所有调用都能重启
SA_NODEFER0x40000000处理器执行期间不自动阻塞当前信号同一信号可递归递送,极易栈溢出
SA_RESETHAND0x80000000进入处理器前把处置复位为 SIG_DFL一次性;第二次信号执行默认动作
SA_RESTORER0x04000000使用 sa_restorer 作为返回跳板glibc 内部设置;裸 syscall 必须自己提供
SA_UNSUPPORTED0x00000400探测内核是否支持某 flag(5.11+)内核会清掉不认识的位
SA_EXPOSE_TAGBITS0x00000800在 si_addr 中暴露地址标签位(5.11+)仅 arm64 等带标签架构

两个容易踩的点:

  • SA_RESETHAND 与 SA_NODEFER 不是一回事。前者在进入处理器前把处置改成默认;后者只是不阻塞当前信号。SA_RESETHAND 的处理器里如果再收到同一信号,进程会被默认动作杀死,而不是递归。
  • SA_ONSTACK 只改变栈,不改变阻塞。栈溢出导致的 SIGSEGV 需要 SA_ONSTACK + 足够大的 sigaltstack,否则处理器没有栈可用,内核只能 force_sig(SIGSEGV) 或直接终止。

处理器入口与返回

内核递送一个信号时,用户态看到的是「处理器被调用」,实际步骤是:

用户态返回路径(exit_to_user_mode)
  └─ do_signal(regs)                    仅当 TIF_SIGPENDING
       ├─ get_signal(&ksig)             取出一个信号
       │    ├─ dequeue_signal()         先实时信号(号小优先),再标准信号
       │    ├─ 处理 SIGKILL / 默认动作
       │    └─ 返回 ksig->ka(处置)与 ksig->info(siginfo)
       └─ handle_signal(&ksig, regs)
            ├─ 若 SA_ONSTACK:切换到 sigaltstack
            ├─ setup_rt_frame()         在用户栈构造 rt_sigframe
            │    ├─ 保存被中断的寄存器、信号掩码
            │    ├─ 写入 siginfo 与 ucontext
            │    └─ 返回地址设为 restorer(SA_RESTORER)
            └─ 修改 regs:RIP=handler,RDI=sig,RSI=&siginfo,RDX=&ucontext

处理器返回时执行 rt_sigreturn 系统调用(由 restorer 触发),内核从 rt_sigframe 恢复寄存器与信号掩码。这解释了三件事:

  1. sa_mask 与自动阻塞在返回时恢复,不是永久修改;
  2. 处理器里改的 errno 会带回主流程,因为 errno 是线程局部变量,不是寄存器现场的一部分;
  3. SA_RESTORER 缺失会导致返回时崩溃(在 x86-64 上),所以裸 syscall(SYS_rt_sigaction) 必须自己填 restorer,或者用 glibc 的 sigaction()。

内核对信号的表示

结构字段作用
task_structpending线程私有的未决信号(sigpending 位图 + 实时队列)
task_structblocked线程私有的信号掩码
signal_structshared_pending进程共享的未决信号(kill 发给整个线程组时用)
sighand_structaction[_NSIG]进程共享的处置表(所有线程看到同一份)
task_structsas_ss_sp/sas_ss_size备选信号栈
task_structsighand指向 sighand_struct,CLONE_SIGHAND 时共享

kill(pid, sig) 走 group_send_sig_info(..., PIDTYPE_TGID),信号进入 shared_pending;tgkill(tgid, tid, sig)/pthread_kill 走 PIDTYPE_PID,进入目标线程的 pending。get_signal() 每次先看线程私有队列,再看共享队列。

`SIGKILL` 为什么不能被捕获

get_signal() 在取出信号后、调用处理器前检查 sig_kernel_only():SIGKILL/SIGSTOP 直接执行默认动作,不进入 handle_signal()。这不是权限问题,而是内核代码里的硬分支;sigaction(SIGKILL, ...) 返回 EINVAL。

sigaltstack() 与栈溢出

/* 为 SIGSEGV 处理器准备备选栈 */
static char altstack[SIGSTKSZ * 4];
stack_t ss = { .ss_sp = altstack, .ss_size = sizeof altstack, .ss_flags = 0 };
if (sigaltstack(&ss, NULL) == -1)
    perror("sigaltstack");

struct sigaction sa = {0};
sa.sa_handler = on_segv;
sa.sa_flags = SA_ONSTACK | SA_SIGINFO;
sigemptyset(&sa.sa_mask);
sigaction(SIGSEGV, &sa, NULL);

SIGSTKSZ 在 glibc 2.34 之后不再是常量(可能依赖 CPU 特性),应使用 sysconf(_SC_SIGSTKSZ) 或直接分配足够大的静态数组。备选栈太小会在处理器入口再次触发 SIGSEGV,内核只能终止进程(SIGSEGV 的 si_code 可能是 SEGV_ACCERR)。

2.10.10 掩码、未决与等待:sigprocmask/sigpending/sigsuspend/sigwaitinfo

这一节回答:阻塞与未决在数据结构上是什么关系,sigpending 看到的是谁的未决,sigsuspend/pselect/sigwaitinfo 各自消除哪个竞态。

四个操作的分工

操作改什么作用域典型用途
sigprocmask信号掩码调用线程(POSIX 未定义多线程行为)临界区保护
pthread_sigmask信号掩码调用线程多线程程序的标准接口
sigpending读出未决集合调用线程 + 进程共享观测「有哪些信号在等」
sigsuspend原子地换掩码并挂起调用线程无丢信号地等待
sigwaitinfo取走一个未决信号调用线程(要求信号被阻塞)同步收取
pselect/ppoll原子地换掩码并等待 fd调用线程事件循环 + 信号

sigpending() 的语义常被误解:它返回「对调用线程或进程处于未决、且当前被阻塞」的信号集合,是 current->pending 与 current->signal->shared_pending 的并集,再与 blocked 相交。它不返回已被递送或未阻塞的信号。

为什么 pause() 会丢信号

/* 错误顺序 */
sigprocmask(SIG_UNBLOCK, &set, NULL);   /* 窗口开始 */
pause();                                 /* 信号若在此前到达,永久睡眠 */

信号在 sigprocmask 返回后、pause 进入前到达时,处理器执行,pause 随后仍然睡眠——因为唤醒事件已经过去。sigsuspend 把「设置掩码」和「挂起」放在同一个内核操作里:

sigset_t mask = old_mask;
sigdelset(&mask, SIGUSR1);              /* 等待期间只放开 SIGUSR1 */
while (!flag) {
    if (sigsuspend(&mask) == -1 && errno != EINTR)
        break;
}
/* 返回时掩码已恢复为调用前的值 */

pselect/ppoll 是同一思想的 fd 版本:pselect(nfds, readfds, writefds, exceptfds, timeout, &sigmask) 在等待 fd 的同时原子地换掩码,避免「检查标志 → 进入 poll → 信号到达」的窗口。

同步等待:sigwait/sigwaitinfo/sigtimedwait

接口超时返回额外信息
sigwait(&set, &sig)无0 或错误码只有信号号
sigwaitinfo(&set, &info)无信号号或 -1完整 siginfo_t
sigtimedwait(&set, &info, &timeout)有信号号或 -1(EAGAIN)完整 siginfo_t

三者都要求信号已被阻塞,否则行为未定义(信号可能按默认动作处理)。它们不调用处理器、不执行默认动作,直接从队列取走信号。多线程程序的标准模式是:

  1. 主线程在创建其它线程之前阻塞要处理的信号;
  2. 新线程继承掩码,自然都阻塞这些信号;
  3. 一个专用线程循环 sigwaitinfo,把信号转成内部事件。

EINTR 与内核的重启码

被信号打断的系统调用,内核返回的错误码不只有 EINTR:

内核码含义用户态看到
ERESTARTSYS可重启;有 SA_RESTART 则重启,否则 EINTR-1/EINTR 或透明重启
ERESTARTNOHAND只有没装处理器时才重启EINTR
ERESTART_RESTARTBLOCK用 restart_block 重启(如 nanosleep)通常 EINTR + 剩余时间
ERESTARTNOINTR总是重启透明重启

nanosleep 用 restart_block 保存剩余时间,即使有 SA_RESTART 也返回 EINTR(并把剩余时间写回 rem)。poll/select/epoll_wait 同理:它们永远返回 EINTR,调用者要重算超时。这是「SA_RESTART 不是万能」的准确边界。

程序:sigsuspend 与 sigwaitinfo 的两阶段收取

/* sigwait_loop.c:先 sigsuspend 等第一个信号,再 sigwaitinfo 收剩余 */
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

static volatile sig_atomic_t usr1_flag;

static void on_usr1(int sig)
{
    (void)sig;
    usr1_flag = 1;
}

static void sender(pid_t target)
{
    for (int i = 0; i < 3; i++)
        kill(target, SIGUSR1);      /* 标准信号:会合并 */
    kill(target, SIGTERM);
    _exit(0);
}

int main(void)
{
    sigset_t block, old, suspend_mask;
    sigemptyset(&block);
    sigaddset(&block, SIGUSR1);
    sigaddset(&block, SIGTERM);

    if (sigprocmask(SIG_BLOCK, &block, &old) == -1) {
        perror("sigprocmask");
        return EXIT_FAILURE;
    }

    struct sigaction sa = {0};
    sa.sa_handler = on_usr1;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = 0;                 /* 故意不用 SA_RESTART */
    if (sigaction(SIGUSR1, &sa, NULL) == -1) {
        perror("sigaction");
        return EXIT_FAILURE;
    }

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return EXIT_FAILURE;
    }
    if (pid == 0)
        sender(getppid());

    /* 等待期间:放开 SIGUSR1,继续阻塞 SIGTERM,避免默认动作杀死进程 */
    suspend_mask = old;
    sigaddset(&suspend_mask, SIGTERM);
    sigdelset(&suspend_mask, SIGUSR1);

    while (!usr1_flag) {
        if (sigsuspend(&suspend_mask) == -1 && errno != EINTR) {
            perror("sigsuspend");
            return EXIT_FAILURE;
        }
    }
    printf("sigsuspend: got SIGUSR1 (3 sends coalesced while pending)\n");

    /* 剩余信号:SIGUSR1 至多一个未决 + SIGTERM,用 sigwaitinfo 同步取 */
    for (int i = 0; i < 2; i++) {
        siginfo_t si;
        memset(&si, 0, sizeof si);
        int sig = sigwaitinfo(&block, &si);
        if (sig == -1) {
            perror("sigwaitinfo");
            break;
        }
        printf("sigwaitinfo: sig=%d code=%d pid=%ld uid=%ld\n",
               sig, si.si_code, (long)si.si_pid, (long)si.si_uid);
    }

    int status;
    if (waitpid(pid, &status, 0) == -1)
        perror("waitpid");
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g sigwait_loop.c -o sigwait_loop
./sigwait_loop
sigsuspend: got SIGUSR1 (3 sends coalesced while pending)
sigwaitinfo: sig=10 code=0 pid=52431 uid=1000
sigwaitinfo: sig=15 code=0 pid=52431 uid=1000

关键在第二行:子进程发了 3 次 SIGUSR1,但标准信号在未决集合里只占一位,所以 sigwaitinfo 只取到一次 SIGUSR1,随后是 SIGTERM。si_code=0 是 SI_USER,si_pid 是发送者(子进程)。如果把 SIGUSR1 换成 SIGRTMIN,第二行会出现三次——这正是 2.10.11 的主题。

关于等待的四个误解
  1. 「sigpending 能看到所有未递送的信号」——它只返回被阻塞的未决信号;未阻塞的信号会立刻递送,不会留在集合里。
  2. 「sigsuspend 返回 0 表示成功」——它总是返回 -1、errno=EINTR;返回 0 的实现在 POSIX 里不存在。
  3. 「sigwaitinfo 会调用处理器」——不会。它取走信号,处理器与默认动作都不执行;如果同一信号还有处理器,处理器只对未被 sigwait 取走的递送生效。
  4. 「pselect 的 sigmask 是永久修改」——只在等待期间生效,返回后恢复原掩码。

2.10.11 实时信号的排队、sigqueue 与队列上限

这一节回答:实时信号与标准信号在队列层面的差别,SIGRTMIN 为什么不能硬编码,sigqueue 的 EAGAIN 从哪来,si_overrun 是什么。

编号与保留位

内核的实时信号从 32 开始编号。glibc/NPTL 保留了前两个(32、33)用于线程内部(SIGCANCEL、SIGSETXID),因此用户可见的 SIGRTMIN 在 glibc 上是 34,SIGRTMAX 是 64(不同 libc/版本可能不同)。SIGRTMIN 是函数调用而不是编译期常量:

/* glibc 内部:__libc_current_sigrtmin() 返回 34 */
int rtmin = SIGRTMIN;      /* 不要在 static 初始化或 case 标签里用 */

不同 libc 的保留数量可能不同(musl 不保留),所以「SIGRTMIN + n 的数值」不可跨平台假设。要表示第 n 个实时信号,用 SIGRTMIN + n,并检查 SIGRTMIN + n <= SIGRTMAX。

标准信号与实时信号的队列差异

维度标准信号(1–31)实时信号(SIGRTMIN–SIGRTMAX)
未决表示位图,一位一个信号每个信号一个 sigqueue 节点
重复发送未决期间合并为一个每个都排队,按到达顺序
递送顺序编号小的优先(Linux)编号小的优先;同号 FIFO
携带数据只有 siginfo 的固定字段sigqueue 的 union sigval
队列上限不适用(只有一位)RLIMIT_SIGPENDING(按真实 UID 统计)
超限行为不会超sigqueue 返回 EAGAIN

内核里的实时队列是 struct sigqueue 链表,节点里保存 siginfo_t 与 user_struct 记账。send_signal_locked() 对实时信号调用 __send_signal_locked() 分配节点并 list_add_tail();标准信号只 sigaddset(&pending->signal, sig)。dequeue_signal() 先扫描实时位图,取编号最小的非空队列,再处理标准信号。

si_code 与 si_value

sigqueue 发送的信号,接收端 siginfo_t 里:

  • si_code = SI_QUEUE(-1);
  • si_value 是发送者给的 union sigval;
  • si_pid/si_uid 是发送者。

kill 发送时 si_code = SI_USER(0),si_value 未定义。定时器到期是 SI_TIMER(-2),si_timerid/si_overrun 有效;SIGCHLD 用 CLD_* 正数编码(CLD_EXITED 等),见 2.8.6。用 SA_SIGINFO 的处理器才能看到 si_value;用单参数处理器时值被丢弃(但信号仍然排队)。

队列上限与 EAGAIN

实时信号按真实 UID 记账,上限是 RLIMIT_SIGPENDING(默认值通常是 0 表示无限制?实际 ulimit -i 常见 127551)。超过上限时:

  • sigqueue() 返回 -1,errno = EAGAIN;
  • kill() 发送实时信号也可能因队列满而失败(EAGAIN);
  • 已经排队的信号不受影响。

排查命令:

ulimit -i                     # RLIMIT_SIGPENDING
cat /proc/sys/kernel/pid_max  # 无关,别混淆
grep -E "SigQ|SigPnd" /proc/<pid>/status

SigQ 行是 当前排队数/上限,例如 SigQ: 0/127551。这个上限是 per-user 的,不是 per-process;同一用户的所有进程共享。

程序:实时信号逐个排队,标准信号被合并

/* rt_queue.c:实时信号排队 vs 标准信号合并 */
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>

static void die(const char *msg)
{
    perror(msg);
    exit(EXIT_FAILURE);
}

int main(void)
{
    sigset_t set;
    sigemptyset(&set);
    sigaddset(&set, SIGRTMIN);
    sigaddset(&set, SIGUSR1);
    if (sigprocmask(SIG_BLOCK, &set, NULL) == -1)
        die("sigprocmask");

    /* 5 个实时信号,带不同值 */
    for (int i = 0; i < 5; i++) {
        union sigval v;
        v.sival_int = 100 + i;
        if (sigqueue(getpid(), SIGRTMIN, v) == -1)
            die("sigqueue rt");
    }

    /* 3 个标准信号:未决期间只会留下一个 */
    for (int i = 0; i < 3; i++)
        if (kill(getpid(), SIGUSR1) == -1)
            die("kill usr1");

    siginfo_t si;
    for (int i = 0; i < 5; i++) {
        memset(&si, 0, sizeof si);
        int sig = sigwaitinfo(&set, &si);
        if (sig == -1)
            die("sigwaitinfo rt");
        printf("rt[%d]: sig=%d value=%d code=%d\n",
               i, sig, si.si_value.sival_int, si.si_code);
    }

    memset(&si, 0, sizeof si);
    int sig = sigwaitinfo(&set, &si);
    if (sig == -1)
        die("sigwaitinfo std");
    printf("std   : sig=%d code=%d (3 sends -> 1 delivery)\n", sig, si.si_code);

    /* 再等一个:标准信号已空,超时返回 EAGAIN */
    struct timespec ts = { 0, 100 * 1000 * 1000 };
    memset(&si, 0, sizeof si);
    errno = 0;
    sig = sigtimedwait(&set, &si, &ts);
    printf("again : ret=%d errno=%d (%s)\n", sig, errno, strerror(errno));
    return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g rt_queue.c -o rt_queue
./rt_queue
rt[0]: sig=34 value=100 code=-1
rt[1]: sig=34 value=101 code=-1
rt[2]: sig=34 value=102 code=-1
rt[3]: sig=34 value=103 code=-1
rt[4]: sig=34 value=104 code=-1
std   : sig=10 code=0 (3 sends -> 1 delivery)
again : ret=-1 errno=11 (Resource temporarily unavailable)

五个实时信号按发送顺序全部到达,si_value 保留;三次 SIGUSR1 只到一次;最后 sigtimedwait 在 100 ms 后返回 EAGAIN。把 SIGRTMIN 换成 SIGUSR1,前五行会只剩一行——这就是「标准信号会丢」的可复现证据。

`si_overrun` 与定时器

POSIX 定时器(2.11.5)到期时如果信号还没被处理,下一次到期会累加 si_overrun,而不是无限排队。timerfd 的 read 返回过期次数,语义类似。实时信号队列不是「无限缓冲」,溢出计数是设计的一部分。

2.10.12 async-signal-safe 的边界与两种 fd 化方案

这一节回答:哪些函数真的能在处理器里调用,errno 与 sig_atomic_t 的保证边界,self-pipe 与 signalfd 在工程上怎么选、怎么实现才不会丢事件。

async-signal-safe 的判定标准

POSIX 的判定不是「函数简单」,而是「函数是否可重入,且不依赖可能被中断的全局状态」。write() 安全,因为它直接进内核;printf() 不安全,因为它操作 FILE 的锁与缓冲区。常用安全集合(POSIX.1-2008 列表的子集):

类别函数
进程控制fork、_exit、execve、wait、waitpid、kill、raise、sigqueue
信号sigaction、sigprocmask、sigpending、sigsuspend、sigwait、sigwaitinfo
文件 I/Oopen、close、read、write、lseek、dup、dup2、fcntl、stat、fstat、unlink、rename
目录opendir、readdir、closedir(POSIX 列为安全)
socketsocket、bind、listen、accept、connect、send、recv、sendto、recvfrom、shutdown
其他pipe、poll、select、time、getpid、getuid、umask、chdir

不安全的典型:printf/fprintf/snprintf、malloc/free、strerror、syslog、pthread_mutex_lock、pthread_cond_wait、exit、abort(SUSv3 列为 async-signal-safe,但会 core)、dlopen、getaddrinfo、getpwnam。

memcpy/strlen 不在 POSIX 的 async-signal-safe 列表里,但它们是纯函数、无全局状态,实践中安全;严格按标准写代码时可以用 volatile 字节循环代替。真正危险的是「可能加锁、可能分配内存、可能改全局状态」的函数。

errno、sig_atomic_t 与原子性

static void handler(int sig)
{
    int saved = errno;
    /* 只做标志与 write */
    errno = saved;
}

errno 在 glibc 里是 __thread int errno,处理器与主流程共享同一个线程的 errno,所以必须保存/恢复。volatile sig_atomic_t 保证「单次读或写是原子的」,但 flag++ 是读-改-写,不是原子操作:

volatile sig_atomic_t count;
count++;                  /* 不是原子操作:信号可能打断读与写之间 */

单线程、处理器不嵌套时,count++ 在实践中不会丢,但严格说需要 sigprocmask 保护,或用 C11 atomic_int(要求 atomic_is_lock_free 为真)。atomic_int 的 fetch_add 在 lock-free 时是单条原子指令,可以安全用于处理器与主流程的计数。

self-pipe 的工程细节

static int sigpipe[2] = { -1, -1 };

static void on_signal(int sig)
{
    int saved = errno;
    unsigned char b = (unsigned char)sig;
    ssize_t n = write(sigpipe[1], &b, 1);
    (void)n;               /* EAGAIN:管道已满,已有字节在,足够唤醒 */
    errno = saved;
}

/* 初始化 */
if (pipe2(sigpipe, O_NONBLOCK | O_CLOEXEC) == -1)
    perror("pipe2");

要点:

  1. 写端非阻塞:处理器不能因为管道满而睡眠;EAGAIN 是正常路径。
  2. 一个字节足够:多个信号合并成一个字节也没关系,主循环醒来后用标志/计数器判断发生了什么。
  3. 读空:while (read(fd, buf, sizeof buf) > 0),直到 EAGAIN;只读一次会留下字节,导致 poll 立刻再次可读(忙循环)。
  4. 信号标志用 volatile sig_atomic_t,且只保存「最后一个信号」;要精确计数用实时信号或 signalfd。
  5. fork 之后:子进程继承管道 fd;如果子进程 exec,O_CLOEXEC 会关闭它;不 exec 的子进程应主动关闭写端,否则主循环永远等不到 EOF(如果依赖 EOF)。

signalfd 的语义细节

signalfd(fd, &mask, flags) 把信号集合绑定到 fd。读出的 struct signalfd_siginfo 字段与 siginfo_t 对应:

字段对应说明
ssi_signosi_signo信号号
ssi_codesi_codeSI_USER/SI_QUEUE/SI_TIMER
ssi_pid/ssi_uidsi_pid/si_uid发送者
ssi_valuesi_valuesigqueue 的值(ssi_ptr/ssi_int 联合)
ssi_overrunsi_overrun定时器溢出计数
ssi_statussi_statusSIGCHLD 的子进程状态
ssi_fdsi_fdSIGIO 的 fd

关键限制:

  • 必须先阻塞:信号未阻塞时会先按默认动作处理,signalfd 收不到;SFD_NONBLOCK 只影响 read,不影响递送。
  • 同步信号收不到:SIGSEGV/SIGFPE 由指令触发,直接递送给出错线程,不进未决队列。
  • 多线程:signalfd 读的是进程/线程的未决队列;多个线程同时读同一个 fd,信号会被其中一个取走,不能假设「每个线程都能看到」。
  • SIGCHLD:signalfd 只负责通知,仍需 waitpid 回收;SA_NOCLDWAIT 下 signalfd 收不到(子进程被自动回收)。

三种方案的选型

维度self-pipesignalfd专用 sigwaitinfo 线程
可移植性任意 POSIXLinux 2.6.22+任意 POSIX
信息量自定义字节完整 signalfd_siginfo完整 siginfo_t
与 epoll 集成直接直接需要额外管道/eventfd 通知
线程模型任意线程可写通常单线程读独立线程
同步信号处理器可捕获后写入不行不行
队列语义字节流每个信号一条记录每个信号一条记录

事件循环优先 signalfd;需要兼容非 Linux 或要处理同步信号时用 self-pipe;已有线程池、不想改事件循环时用 sigwaitinfo 线程 + eventfd 通知。

2.10.13 SIGCHLD 合并、SA_NOCLDWAIT 与回收竞态

这一节回答:内核在子进程退出时到底发几次 SIGCHLD,SA_NOCLDSTOP/SA_NOCLDWAIT 改变了什么,为什么「处理器里 waitpid 一次」会漏回收。

内核的通知路径

子进程退出时,do_exit() → exit_notify() → do_notify_parent(tsk, sig):

  • 如果父进程设置了 SIGCHLD 的 SA_NOCLDWAIT,或把 SIGCHLD 设为 SIG_IGN,内核直接 release_task(),不产生僵尸,也不发信号(Linux 2.6+);
  • 否则把子进程状态置为 EXIT_ZOMBIE,调用 __group_send_sig_info(SIGCHLD, SEND_SIG_NOINFO, parent);
  • 信号进入父进程的 shared_pending,标准信号只占一位。

因此「三个子进程同时退出」在内核里可能只产生一个未决 SIGCHLD 位。处理器被调用一次,必须用循环把所有僵尸回收:

static void on_sigchld(int sig)
{
    (void)sig;
    int saved = errno, status;
    while (waitpid(-1, &status, WNOHANG) > 0)
        ;                     /* 循环到没有更多僵尸 */
    errno = saved;
}

waitpid 返回 0 表示「有子进程但都没退出」,返回 -1 且 errno=ECHILD 表示「没有子进程」。两种情况都退出循环。

SA_NOCLDSTOP 与停止/继续

默认情况下,子进程被 SIGSTOP/SIGTSTP 停止、被 SIGCONT 继续时,父进程也会收到 SIGCHLD。SA_NOCLDSTOP 关闭这两类通知,只保留退出通知。对「只关心子进程死活」的 supervisor,这个标志能减少大量无意义唤醒。

注意:SA_NOCLDSTOP 不影响 waitpid 的 WUNTRACED/WCONTINUED——即使不发信号,父进程主动 waitpid 仍能拿到停止/继续事件。

SA_NOCLDWAIT 的代价

struct sigaction sa = {0};
sa.sa_handler = SIG_IGN;
sa.sa_flags = SA_NOCLDWAIT;
sigemptyset(&sa.sa_mask);
sigaction(SIGCHLD, &sa, NULL);

此后子进程退出不产生僵尸,但父进程也拿不到退出状态:waitpid 返回 ECHILD。只适合 fire-and-forget 的子进程。SIG_IGN 与 SA_NOCLDWAIT 的组合在 Linux 上等价;POSIX 只保证 SA_NOCLDWAIT。

程序:self-pipe + SIGCHLD 回收全部子进程

/* sigchld_pipe.c:标准信号合并下,用 self-pipe 唤醒并循环回收 */
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

#define NCHILD 8

static int chld_pipe[2] = { -1, -1 };
static volatile sig_atomic_t handler_calls;

static void on_sigchld(int sig)
{
    (void)sig;
    int saved = errno;
    handler_calls++;
    unsigned char b = 1;
    ssize_t n = write(chld_pipe[1], &b, 1);
    (void)n;                     /* EAGAIN:已有字节,主循环会醒 */
    errno = saved;
}

int main(void)
{
    if (pipe2(chld_pipe, O_NONBLOCK | O_CLOEXEC) == -1) {
        perror("pipe2");
        return EXIT_FAILURE;
    }

    struct sigaction sa = {0};
    sa.sa_handler = on_sigchld;
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    sigemptyset(&sa.sa_mask);
    if (sigaction(SIGCHLD, &sa, NULL) == -1) {
        perror("sigaction");
        return EXIT_FAILURE;
    }

    for (int i = 0; i < NCHILD; i++) {
        pid_t pid = fork();
        if (pid == -1) {
            perror("fork");
            return EXIT_FAILURE;
        }
        if (pid == 0) {
            struct timespec ts = { 0, (long)i * 2 * 1000 * 1000 };  /* 0–14 ms */
            nanosleep(&ts, NULL);       /* 让退出时间错开,制造合并 */
            _exit(10 + i);
        }
    }

    int reaped = 0;
    int sum = 0;
    while (reaped < NCHILD) {
        struct pollfd pfd = { .fd = chld_pipe[0], .events = POLLIN };
        int rc = poll(&pfd, 1, 5000);
        if (rc == -1) {
            if (errno == EINTR)
                continue;
            perror("poll");
            break;
        }
        if (rc == 0) {
            fprintf(stderr, "timeout: reaped=%d\n", reaped);
            break;
        }

        unsigned char buf[64];
        while (read(chld_pipe[0], buf, sizeof buf) > 0)
            ;                          /* 读空,避免忙循环 */

        int status;
        pid_t pid;
        while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
            if (WIFEXITED(status)) {
                reaped++;
                sum += WEXITSTATUS(status);
            }
        }
    }

    printf("handler_calls=%d reaped=%d sum=%d (expect %d)\n",
           (int)handler_calls, reaped, sum, NCHILD * 10 + NCHILD * (NCHILD - 1) / 2);
    close(chld_pipe[0]);
    close(chld_pipe[1]);
    return reaped == NCHILD ? EXIT_SUCCESS : EXIT_FAILURE;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g sigchld_pipe.c -o sigchld_pipe
./sigchld_pipe
handler_calls=3 reaped=8 sum=92 (expect 92)

handler_calls 远小于 8(具体值随调度变化,常见 1–4),因为标准 SIGCHLD 会合并;但 reaped=8 说明循环回收没有漏。如果处理器里只 waitpid 一次,reaped 会小于 8,循环会一直等到超时——这就是「信号会丢,僵尸不会自己消失」的具体表现。

关于 `SIGCHLD` 的四个误解
  1. 「每个子进程退出都会发一次 SIGCHLD」——标准信号会合并;实时信号才会逐个排队。用 SIGCHLD 计数子进程数必然出错。
  2. 「SIG_IGN 与 SA_NOCLDWAIT 只是不产生僵尸」——它们还让 waitpid 返回 ECHILD,父进程彻底失去退出状态。
  3. 「SIGCHLD 处理器里 waitpid(-1) 阻塞等待也可以」——会阻塞在处理器里,且可能等不到(合并后没有新信号)。必须用 WNOHANG 循环。
  4. 「子进程 stop/continue 不发 SIGCHLD」——默认会发;要关闭用 SA_NOCLDSTOP。

2.10.14 两个观测实验

实验一:strace 看信号系统调用

strace -f -e trace=rt_sigaction,rt_sigprocmask,rt_sigpending,rt_sigsuspend,rt_sigreturn,kill,tgkill \
       -o /tmp/sig.trace ./sigwait_loop
grep -E "rt_sigaction|rt_sigprocmask|kill|rt_sigsuspend" /tmp/sig.trace | head -12

预期输出(地址与 PID 不同):

rt_sigaction(SIGUSR1, {sa_handler=0x4011a6, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7f...}, NULL, 8) = 0
rt_sigprocmask(SIG_BLOCK, [USR1 TERM], NULL, 8) = 0
clone(child_stack=NULL, flags=...|SIGCHLD, ...) = 52431
kill(52430, SIGUSR1)                   = 0
kill(52430, SIGUSR1)                   = 0
kill(52430, SIGUSR1)                   = 0
kill(52430, SIGTERM)                   = 0
rt_sigsuspend([TERM], 8)               = ? ERESTARTNOHAND (To be restarted if no handler)
--- SIGUSR1 {si_signo=SIGUSR1, si_code=SI_USER, si_pid=52431, si_uid=1000} ---
rt_sigreturn()                         = ?

怎么读:

  • rt_sigaction 的第四个参数 8 是内核 sigset_t 的大小;sa_flags=SA_RESTORER 是 glibc 自动加的。
  • rt_sigsuspend([TERM], 8) 表明等待期间只阻塞 SIGTERM,SIGUSR1 被放开。
  • --- SIGUSR1 --- 是 strace 对信号递送的注释;随后 rt_sigreturn 是处理器返回。
  • 三次 kill 只对应一次 --- SIGUSR1 ---,直观展示标准信号合并。

实验二:/proc/<pid>/status 的 Sig 位图

# 终端 A:阻塞 SIGUSR1 后睡眠
python3 - <<'PY' &
import signal, time, os
signal.pthread_sigmask(signal.SIG_BLOCK, {signal.SIGUSR1})
print(os.getpid(), flush=True)
time.sleep(30)
PY
pid=$!
sleep 0.2
kill -USR1 $pid; kill -USR1 $pid; kill -USR1 $pid
grep -E "^Sig(Pnd|Blk|Ign|Cgt)" /proc/$pid/status
kill -TERM $pid

预期输出:

SigPnd: 0000000000000000
ShdPnd: 0000000000000200
SigBlk: 0000000000000200
SigIgn: 0000000000000000
SigCgt: 0000000000000000

怎么读:

  • Linux 的位图用第 sig-1 位表示信号 sig,所以 SIGUSR1(10) 是 1 << 9 = 0x200;SigBlk 与 ShdPnd 都出现这一位,说明信号被阻塞且停在未决状态。
  • 三次 SIGUSR1 在 ShdPnd 里只占一位,证明标准信号不排队。
  • SigCgt 为空说明没有安装处理器;信号被阻塞,所以停在未决状态。

2.10.15 十二个陷阱:现象、原因、修复

#现象原因修复
1按 Ctrl-C 没反应SIGINT 被阻塞或处理器不退出查 SigBlk/SigCgt,处理器设退出标志
2服务收到 SIGTERM 后卡住处理器里调用了非 async-signal-safe 函数(如 printf)死锁只写标志/self-pipe
3信号处理器只执行一次用了 SA_RESETHAND 或 signal() 的 SysV 语义用 sigaction 且不设 SA_RESETHAND
4read 循环偶发少读短读被当成 EOF,或 EINTR 未处理循环读 + 处理 EINTR/短读
5设了 SA_RESTART 仍返回 EINTRpoll/select/nanosleep 不会重启重算超时/用 rem 续睡
6sigsuspend 之前信号丢失用 pause() 而不是 sigsuspend原子换掩码 + 挂起
7三个子进程只回收一个SIGCHLD 合并,处理器只 waitpid 一次while (waitpid(-1,...,WNOHANG)>0)
8waitpid 返回 ECHILD 但子进程刚死SIGCHLD 设为 SIG_IGN/SA_NOCLDWAIT,或处理器已回收统一回收点,别混用
9实时信号只收到一个误用了标准信号,或队列满 EAGAIN用 SIGRTMIN + 检查 sigqueue 返回值
10SIGRTMIN 编译报错它是函数调用,不是常量运行时取值,别放 case/静态初始化
11signalfd 读不到信号没先阻塞,或信号是同步 SIGSEGV先 sigprocmask;同步信号用处理器
12多线程程序信号被随机线程处理进程级信号递送给任一未阻塞线程主线程阻塞 + 专用 sigwaitinfo 线程
13处理器返回后崩溃裸 rt_sigaction 没设 SA_RESTORER用 glibc sigaction()
14SIGSEGV 处理器递归触发处理器 return 或备选栈太小不 return;sigaltstack 足够大
15fork 后子进程继承信号处理器处置是进程级、跨 fork 保留子进程 exec 后自动复位;不 exec 要自己重设

2.10.16 本章出口检查

检查项达标线没达标去哪
说出信号从产生到递送的四个状态产生、未决、阻塞、处置2.10.2
解释 signal 与 sigaction 的差异至少说出 SA_RESTART 与可移植性2.10.3
列出处理器里不能调用的三个函数printf/malloc/strerror2.10.4
用 self-pipe 接入 poll代码能编译并响应 SIGTERM2.10.4
解释 sigsuspend 的原子性能说出 pause 的丢信号窗口2.10.5
用 signalfd 接收信号知道必须先阻塞2.10.7
解释标准信号为何会丢未决集合是位图,不排队2.10.1
即时自测 · 2.10

Q1 进程阻塞了 SIGTERM,此时收到 3 次 kill -TERM。解除阻塞后会执行几次处理器?如果换成 SIGRTMIN 呢?

Q2 一个 read 循环没有处理 EINTR,在什么情况下会「少读数据」而不是报错?举出一种。

Q3 为什么 printf 在信号处理器里不安全?给出两个具体原因。

Q4 signalfd 读不到 SIGSEGV,为什么?self-pipe 能读到吗?

Q5 主线程装了 SIGINT 处理器,又创建了两个线程。按 Ctrl-C 时哪个线程执行处理器?如果想让固定线程处理,怎么写?

答案

A1 标准信号只执行一次:三次 kill 在未决集合里只占一位,解除阻塞后递送一次,处理器执行一次(可能发生在三次都到达之后)。实时信号 SIGRTMIN 排队,执行三次。前提是这三次发送发生在阻塞期间且未被合并——标准信号必然合并,实时信号在 RLIMIT_SIGPENDING 内不合并。

A2 当 read 已经读到了一部分数据后被信号打断,它返回已读字节数(短读),而不是 EINTR。如果循环写成「读满 count 才算成功」,就会把短读当失败;如果循环不检查返回值,就会漏掉剩余数据。另一种情况是 SA_RESTART 让 read 重启,但重启后读到的数据比预期少(对端关闭),返回短计数。

A3 ① printf 会操作 FILE 的锁与缓冲区,处理器可能打断主流程正在进行的 printf,造成死锁或缓冲区状态损坏;② printf 可能调用 malloc(分配缓冲区、扩展 FILE),而 malloc 的锁可能正被主流程持有。两者都不在 async-signal-safe 列表里。

A4 SIGSEGV 是同步信号:由当前线程执行指令触发,内核直接把信号递送给该线程,不进入进程的未决队列,所以 signalfd(读进程未决队列)收不到。self-pipe 也收不到——除非你先用 sigaction 捕获 SIGSEGV,在处理器里往管道写字节;但处理器返回后仍会重新执行出错指令,通常没有意义。

A5 内核从「没有阻塞 SIGINT 的线程」中任选一个,不可预测;处理器是进程级的,哪个线程执行都可能。要让固定线程处理:在主线程(以及所有不处理的线程)用 pthread_sigmask 阻塞 SIGINT,在目标线程里 sigwait/sigwaitinfo 循环;或用 signalfd 绑到该线程的 epoll。创建线程时掩码会被继承,所以阻塞要在 pthread_create 之前做。

母本对应

母本章节本书落点程度
K第 20 章 信号:基本概念2.10.1–2.10.3完整(概念、sigaction、信号集)
K第 21 章 信号:信号处理器函数2.10.4、2.10.6完整(可重入、SA_SIGINFO、EINTR)
K第 22 章 信号:高级特性2.10.5、2.10.7–2.10.8完整(sigsuspend、实时信号、signalfd)
K第 63 章 63.5(self-pipe)2.10.4完整(与 pselect 的配合)
C第 8 章 8.5 信号2.10.1–2.10.4完整(信号术语、处理器、阻塞)
O第 26–28 章(并发)2.10.8背景(异步与竞态)
G第 16 章 程序管理与 SELinux 初探2.10.1背景(kill、trap、作业控制信号)
N第 3 章 内核如何与用户进程协作2.10.2背景(阻塞/唤醒与信号打断)

下一站:2.11 定时器与休眠 把「时间」变成可控的事件:nanosleep 为什么会被信号打断、timerfd 如何与 epoll 集成、单调时钟与实时时钟该选哪个。