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 信号编号、默认动作与两类信号
信号是内核向进程递送的一个短消息,用一个整数标识(SIGINT=2、SIGKILL=9、SIGSEGV=11、SIGTERM=15……)。每个信号有一个默认动作:终止(Term)、终止并 core dump(Core)、忽略(Ign)、停止(Stop)、继续(Cont)。
进程可以:捕获(安装处理器,SIGKILL/SIGSTOP 除外)、忽略(SIG_IGN,同样有例外)、阻塞(暂缓递送,进入未决状态)。
常用信号与默认动作:
| 信号 | 编号 | 默认动作 | 典型来源 | 能捕获/阻塞 |
|---|---|---|---|---|
SIGHUP | 1 | Term | 终端关闭、nohup 要忽略它 | 是 |
SIGINT | 2 | Term | Ctrl-C | 是 |
SIGABRT | 6 | Core | abort() | 是 |
SIGKILL | 9 | Term | kill -9 | 否 |
SIGUSR1 | 10 | Term | 用户自定义 | 是 |
SIGSEGV | 11 | Core | 非法内存访问 | 是(同上) |
SIGPIPE | 13 | Term | 写已关闭的管道/socket | 是 |
SIGALRM | 14 | Term | alarm()/setitimer() | 是 |
SIGTERM | 15 | Term | kill、服务管理器 | 是 |
SIGCHLD | 17 | Ign | 子进程状态变化 | 是 |
SIGCONT | 18 | Cont | fg/bg、kill -CONT | 是 |
SIGSTOP | 19 | Stop | 作业控制 | 否 |
SIGTSTP | 20 | Stop | Ctrl-Z | 是 |
SIGTTIN | 21 | Stop | 后台进程读终端 | 是 |
SIGTTOU | 22 | Stop | 后台进程写终端 | 是 |
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。
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/waitpid | semop/semtimedop |
accept | connect |
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就不用处理EINTR」——poll/select/epoll_wait/nanosleep/semop不会重启;已经传输了部分数据的read/write会返回短计数。 - 「
signal()默认不重启」——glibc 的signal()用的是 BSD 语义,默认带SA_RESTART。这正是它不可移植的原因:同一个程序在 musl 或旧 SysV 上行为不同。 - 「
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)。
处理器与主流程共享的变量必须满足两个条件:
- 类型是
volatile sig_atomic_t(保证单次读写原子,且编译器不会把它优化进寄存器); - 主流程对它的读写不依赖「读-改-写」的原子性;需要计数时,要么用
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 多一个超时参数,适合「等一会儿,没有就继续」。
- 「阻塞等于忽略」——阻塞是「暂缓递送」,信号保持未决,解除阻塞后仍会处理;忽略是「递送后丢弃」。
- 「
SIGKILL也能阻塞」——不能。sigprocmask对SIGKILL/SIGSTOP的请求会被静默忽略。 - 「
pause()不会丢信号」——会。信号在pause之前到达就会永久睡下去,必须用sigsuspend。 - 「多线程里
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_value | sigqueue 携带的 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。
两个限制:
- 必须先阻塞信号。信号未被阻塞时,内核会先按默认动作处理,
signalfd读不到。 - 不能用于同步信号。
SIGSEGV、SIGFPE这类由指令触发的信号是同步递送给出错线程的,不会进入进程的未决队列,signalfd收不到。
signalfd 与 self-pipe 的选择:
| 维度 | self-pipe | signalfd |
|---|---|---|
| 可移植性 | 任何 POSIX | Linux 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_mask | sigset_t | 调用者 | 处理器执行期间额外阻塞的信号 |
sa_flags | int | 调用者 | 行为开关,见下表 |
sa_restorer | 函数指针 | glibc/内核 | 处理器返回后的恢复代码,x86-64 上必须有效 |
sa_handler 与 sa_sigaction 共享存储:设置 SA_SIGINFO 时内核按三参数函数调用,否则按单参数调用。用 {0} 初始化后只赋一个,另一个自然是 NULL。
sa_flags 位表
| flag | 值(Linux) | 作用 | 代价/边界 |
|---|---|---|---|
SA_NOCLDSTOP | 0x00000001 | 子进程停止/继续时不发 SIGCHLD | 只对 SIGCHLD 有效 |
SA_NOCLDWAIT | 0x00000002 | 子进程退出自动回收,不产生僵尸 | waitpid 返回 ECHILD,拿不到退出码 |
SA_SIGINFO | 0x00000004 | 三参数处理器,带 siginfo_t/ucontext_t | 处理器原型必须匹配 |
SA_ONSTACK | 0x08000000 | 在 sigaltstack 提供的栈上执行 | 必须先 sigaltstack,否则退回主栈 |
SA_RESTART | 0x10000000 | 重启部分被中断的系统调用 | 不是所有调用都能重启 |
SA_NODEFER | 0x40000000 | 处理器执行期间不自动阻塞当前信号 | 同一信号可递归递送,极易栈溢出 |
SA_RESETHAND | 0x80000000 | 进入处理器前把处置复位为 SIG_DFL | 一次性;第二次信号执行默认动作 |
SA_RESTORER | 0x04000000 | 使用 sa_restorer 作为返回跳板 | glibc 内部设置;裸 syscall 必须自己提供 |
SA_UNSUPPORTED | 0x00000400 | 探测内核是否支持某 flag(5.11+) | 内核会清掉不认识的位 |
SA_EXPOSE_TAGBITS | 0x00000800 | 在 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 恢复寄存器与信号掩码。这解释了三件事:
sa_mask与自动阻塞在返回时恢复,不是永久修改;- 处理器里改的
errno会带回主流程,因为errno是线程局部变量,不是寄存器现场的一部分; SA_RESTORER缺失会导致返回时崩溃(在 x86-64 上),所以裸syscall(SYS_rt_sigaction)必须自己填 restorer,或者用 glibc 的sigaction()。
内核对信号的表示
| 结构 | 字段 | 作用 |
|---|---|---|
task_struct | pending | 线程私有的未决信号(sigpending 位图 + 实时队列) |
task_struct | blocked | 线程私有的信号掩码 |
signal_struct | shared_pending | 进程共享的未决信号(kill 发给整个线程组时用) |
sighand_struct | action[_NSIG] | 进程共享的处置表(所有线程看到同一份) |
task_struct | sas_ss_sp/sas_ss_size | 备选信号栈 |
task_struct | sighand | 指向 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() 每次先看线程私有队列,再看共享队列。
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 |
三者都要求信号已被阻塞,否则行为未定义(信号可能按默认动作处理)。它们不调用处理器、不执行默认动作,直接从队列取走信号。多线程程序的标准模式是:
- 主线程在创建其它线程之前阻塞要处理的信号;
- 新线程继承掩码,自然都阻塞这些信号;
- 一个专用线程循环
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 的主题。
- 「
sigpending能看到所有未递送的信号」——它只返回被阻塞的未决信号;未阻塞的信号会立刻递送,不会留在集合里。 - 「
sigsuspend返回 0 表示成功」——它总是返回 -1、errno=EINTR;返回 0 的实现在 POSIX 里不存在。 - 「
sigwaitinfo会调用处理器」——不会。它取走信号,处理器与默认动作都不执行;如果同一信号还有处理器,处理器只对未被 sigwait 取走的递送生效。 - 「
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,前五行会只剩一行——这就是「标准信号会丢」的可复现证据。
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/O | open、close、read、write、lseek、dup、dup2、fcntl、stat、fstat、unlink、rename |
| 目录 | opendir、readdir、closedir(POSIX 列为安全) |
| socket | socket、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");
要点:
- 写端非阻塞:处理器不能因为管道满而睡眠;
EAGAIN是正常路径。 - 一个字节足够:多个信号合并成一个字节也没关系,主循环醒来后用标志/计数器判断发生了什么。
- 读空:
while (read(fd, buf, sizeof buf) > 0),直到EAGAIN;只读一次会留下字节,导致poll立刻再次可读(忙循环)。 - 信号标志用
volatile sig_atomic_t,且只保存「最后一个信号」;要精确计数用实时信号或signalfd。 - fork 之后:子进程继承管道 fd;如果子进程 exec,
O_CLOEXEC会关闭它;不 exec 的子进程应主动关闭写端,否则主循环永远等不到 EOF(如果依赖 EOF)。
signalfd 的语义细节
signalfd(fd, &mask, flags) 把信号集合绑定到 fd。读出的 struct signalfd_siginfo 字段与 siginfo_t 对应:
| 字段 | 对应 | 说明 |
|---|---|---|
ssi_signo | si_signo | 信号号 |
ssi_code | si_code | SI_USER/SI_QUEUE/SI_TIMER |
ssi_pid/ssi_uid | si_pid/si_uid | 发送者 |
ssi_value | si_value | sigqueue 的值(ssi_ptr/ssi_int 联合) |
ssi_overrun | si_overrun | 定时器溢出计数 |
ssi_status | si_status | SIGCHLD 的子进程状态 |
ssi_fd | si_fd | SIGIO 的 fd |
关键限制:
- 必须先阻塞:信号未阻塞时会先按默认动作处理,
signalfd收不到;SFD_NONBLOCK只影响read,不影响递送。 - 同步信号收不到:
SIGSEGV/SIGFPE由指令触发,直接递送给出错线程,不进未决队列。 - 多线程:
signalfd读的是进程/线程的未决队列;多个线程同时读同一个 fd,信号会被其中一个取走,不能假设「每个线程都能看到」。 SIGCHLD:signalfd只负责通知,仍需waitpid回收;SA_NOCLDWAIT下signalfd收不到(子进程被自动回收)。
三种方案的选型
| 维度 | self-pipe | signalfd | 专用 sigwaitinfo 线程 |
|---|---|---|---|
| 可移植性 | 任意 POSIX | Linux 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」——标准信号会合并;实时信号才会逐个排队。用SIGCHLD计数子进程数必然出错。 - 「
SIG_IGN与SA_NOCLDWAIT只是不产生僵尸」——它们还让waitpid返回ECHILD,父进程彻底失去退出状态。 - 「
SIGCHLD处理器里waitpid(-1)阻塞等待也可以」——会阻塞在处理器里,且可能等不到(合并后没有新信号)。必须用WNOHANG循环。 - 「子进程 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 |
| 4 | read 循环偶发少读 | 短读被当成 EOF,或 EINTR 未处理 | 循环读 + 处理 EINTR/短读 |
| 5 | 设了 SA_RESTART 仍返回 EINTR | poll/select/nanosleep 不会重启 | 重算超时/用 rem 续睡 |
| 6 | sigsuspend 之前信号丢失 | 用 pause() 而不是 sigsuspend | 原子换掩码 + 挂起 |
| 7 | 三个子进程只回收一个 | SIGCHLD 合并,处理器只 waitpid 一次 | while (waitpid(-1,...,WNOHANG)>0) |
| 8 | waitpid 返回 ECHILD 但子进程刚死 | SIGCHLD 设为 SIG_IGN/SA_NOCLDWAIT,或处理器已回收 | 统一回收点,别混用 |
| 9 | 实时信号只收到一个 | 误用了标准信号,或队列满 EAGAIN | 用 SIGRTMIN + 检查 sigqueue 返回值 |
| 10 | SIGRTMIN 编译报错 | 它是函数调用,不是常量 | 运行时取值,别放 case/静态初始化 |
| 11 | signalfd 读不到信号 | 没先阻塞,或信号是同步 SIGSEGV | 先 sigprocmask;同步信号用处理器 |
| 12 | 多线程程序信号被随机线程处理 | 进程级信号递送给任一未阻塞线程 | 主线程阻塞 + 专用 sigwaitinfo 线程 |
| 13 | 处理器返回后崩溃 | 裸 rt_sigaction 没设 SA_RESTORER | 用 glibc sigaction() |
| 14 | SIGSEGV 处理器递归触发 | 处理器 return 或备选栈太小 | 不 return;sigaltstack 足够大 |
| 15 | fork 后子进程继承信号处理器 | 处置是进程级、跨 fork 保留 | 子进程 exec 后自动复位;不 exec 要自己重设 |
2.10.16 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出信号从产生到递送的四个状态 | 产生、未决、阻塞、处置 | 2.10.2 |
解释 signal 与 sigaction 的差异 | 至少说出 SA_RESTART 与可移植性 | 2.10.3 |
| 列出处理器里不能调用的三个函数 | printf/malloc/strerror | 2.10.4 |
用 self-pipe 接入 poll | 代码能编译并响应 SIGTERM | 2.10.4 |
解释 sigsuspend 的原子性 | 能说出 pause 的丢信号窗口 | 2.10.5 |
用 signalfd 接收信号 | 知道必须先阻塞 | 2.10.7 |
| 解释标准信号为何会丢 | 未决集合是位图,不排队 | 2.10.1 |
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集成、单调时钟与实时时钟该选哪个。