signal 信号机制 —— Linux 异步事件的祖宗
在终端按 Ctrl+C,程序就停了;服务收到 SIGTERM 想优雅关闭;段错误时进程崩溃 + 留个 core 文件。这背后都是 信号(signal) 在工作 —— Unix 系统最古老的异步通知机制。这篇把 signal、sigaction、信号屏蔽、异步信号安全、signalfd 等关键点讲清楚。
0. 引言:Ctrl+C 是怎么让程序停的
如果想"按 Ctrl+C 但不退出",注册一个 SIGINT handler 把它吃掉就行。这就是信号最直观的用法。
1. 信号是什么
信号是一种异步通知机制:内核或其他进程往一个进程"投递"一个事件,进程可以选择忽略、捕获、或采用默认行为。
特点:
- 异步
- 不携带数据
- 可丢失
- 每个进程独立维护一个信号 mask 和 handler 表
1.1 常见信号速查
| | | |
|---|
SIGHUP | | | |
SIGINT | | | |
SIGQUIT | | | |
SIGILL | | | |
SIGABRT | | | |
SIGFPE | | | |
SIGKILL | | | 不可捕获、不可屏蔽 |
SIGSEGV | | | |
SIGPIPE | | | |
SIGALRM | | | |
SIGTERM | | | |
SIGUSR1 | | | |
SIGCHLD | | | |
SIGCONT | | | |
SIGSTOP | | | 不可捕获、不可屏蔽 |
SIGTSTP | | | |
⚠️ SIGKILL(9)和 SIGSTOP(19)永远无法被捕获/屏蔽,是给系统管理员的"终极手段"。
2. signal() —— 简单但有坑
#include<signal.h>typedefvoid(*sighandler_t)(int);sighandler_tsignal(int signum, sighandler_t handler);
例子:让 Ctrl+C 不结束程序:
#include<signal.h>#include<stdio.h>#include<unistd.h>voidon_sigint(int sig){ printf("got SIGINT (%d), but I won't die\n", sig);}intmain(void){ signal(SIGINT, on_sigint); while (1) { printf("running...\n"); sleep(1); }}
按 Ctrl+C,程序打印一句话继续跑。
signal() 的两个历史问题
- 不同 Unix 行为不一样:System V 的 signal 在 handler 触发后会自动 reset 到 SIG_DFL;BSD 的不会。Linux 默认行为是 BSD 风格,但这种语义模糊已经够让人头疼了。
- handler 执行期间是否屏蔽同信号
结论:现代代码不要用 signal,用 sigaction。
3. sigaction —— 推荐写法
#include<signal.h>intsigaction(int signum, const struct sigaction *act, struct sigaction *oldact);struct sigaction { void (*sa_handler)(int); /* 简单 handler */ void (*sa_sigaction)(int, siginfo_t *, void *); /* 详细 handler */ sigset_t sa_mask; /* handler 执行期间屏蔽哪些信号 */ int sa_flags; void (*sa_restorer)(void); /* 不要用 */};
最小例子:
voidon_sigint(int sig){ write(STDOUT_FILENO, "got SIGINT\n", 11);}struct sigaction sa = {0};sa.sa_handler = on_sigint;sigemptyset(&sa.sa_mask);sa.sa_flags = SA_RESTART; /* 让被中断的 syscall 自动重启 */sigaction(SIGINT, &sa, NULL);
3.1 sa_flags 常用值
| |
|---|
SA_RESTART | 被信号中断的 syscall(read/write/…)自动重启,避免你到处处理 EINTR |
SA_SIGINFO | 用 sa_sigaction 而非 sa_handler,能拿到详细信息 |
SA_NODEFER | handler 执行期间不屏蔽当前信号(不推荐,容易递归) |
SA_NOCLDSTOP | 子进程 stop/cont 时不发 SIGCHLD(只关心退出) |
SA_NOCLDWAIT | |
SA_RESETHAND | 触发一次后 reset 成 SIG_DFL(一次性 handler) |
3.2 拿到详细信息:sa_sigaction
voidon_segv(int sig, siginfo_t *info, void *ucontext){ fprintf(stderr, "SIGSEGV at addr %p, by signal from pid %d\n", info->si_addr, info->si_pid); _exit(128 + sig);}struct sigaction sa = {0};sa.sa_sigaction = on_segv;sa.sa_flags = SA_SIGINFO;sigemptyset(&sa.sa_mask);sigaction(SIGSEGV, &sa, NULL);
siginfo_t 里能拿到很多东西:
si_signosi_code:来源原因(SI_USER / SI_KERNEL / SEGV_MAPERR / SEGV_ACCERR …)si_addrsi_pid / si_uid:发送者的 pid / uid(如果是 kill 来的)si_status
4. 信号生命周期
信号生命周期分 4 步:
① 产生
- 内核:硬件异常(SIGSEGV/SIGFPE)、软件条件(SIGPIPE/SIGCHLD/SIGALRM)
- 进程:
kill / raise / pthread_kill - 用户:tty 的 Ctrl+C / Ctrl+\ / Ctrl+Z
② 投递(pending)
- 如果该信号被屏蔽(in sigmask)→ 留在 pending 等屏蔽解除
③ 递送(delivery)
- 进程从内核态返回用户态时,检查
pending - mask = 待递送 - 选一个递送(POSIX 不规定顺序,Linux 一般按编号)
④ 处理
SIG_DFL:默认行为(terminate / core / ignore / stop / continue)SIG_IGN- 用户 handler:跳到 handler 函数执行
5. 异步信号安全 —— 最容易踩的雷
5.1 什么是异步信号安全
handler 可以在程序的任何指令之间被插入执行 —— 你正在 malloc,handler 来了,再调 malloc 试试?死锁。
不是所有函数都能在 handler 里调。POSIX 定义了一份 async-signal-safe 函数列表,按类别整理:
| | |
|---|
| read / write / open / close / dup / fcntl | fopen |
| | printf / fprintf / scanf(stdio 全家) |
| | malloc / free / calloc / realloc |
| _exit | exit |
| getpid / getppid / getuid / getgid | |
| kill / raise / sigaction / sigprocmask / pthread_kill / sem_post / pause | |
| | pthread_mutex_lock |
| sleep | |
| strerror_r | syslog |
| | gethostbyname |
⚠️ 不能在 signal handler 里调 printf。看着能跑只是没撞上 race;handler 在 stdio 锁持有期间触发会死锁。
5.2 工程套路:handler 只设标志位
#include<signal.h>#include<stdatomic.h>static volatile sig_atomic_t got_sigint = 0;voidon_sigint(int sig){ got_sigint = 1; /* sig_atomic_t 是异步安全的赋值 */}intmain(void){ struct sigaction sa = { .sa_handler = on_sigint }; sigaction(SIGINT, &sa, NULL); while (!got_sigint) { do_work(); } /* 在主循环里调用复杂逻辑(printf、close fd...)安全 */ cleanup_and_exit();}
handler 只做最少的事 —— 设个标志,主循环检查标志再做复杂工作。
5.3 sig_atomic_t
volatile sig_atomic_t flag;
不要用普通 int —— 多字节赋值理论上可被信号打断(虽然 x86/ARM 上小整数赋值实际原子)。
6. 信号屏蔽:sigprocmask
intsigprocmask(int how, constsigset_t *set, sigset_t *oldset);/* how: * SIG_BLOCK - 添加屏蔽 * SIG_UNBLOCK - 取消屏蔽 * SIG_SETMASK - 完全替换 mask */
进入临界区时屏蔽信号,避免被打断:
sigset_t set, oldset;sigemptyset(&set);sigaddset(&set, SIGINT);sigprocmask(SIG_BLOCK, &set, &oldset);/* 临界区:SIGINT 不会被递送,但会进 pending 队列 */do_critical();sigprocmask(SIG_SETMASK, &oldset, NULL);/* 解除屏蔽,pending 中的 SIGINT 在这一刻递送 */
⚠️ 多线程下不要用 sigprocmask,用 pthread_sigmask(语义相同,但只影响当前线程)。
sigsuspend:原子地"解屏蔽 + 等待"
sigset_t mask;sigemptyset(&mask);sigsuspend(&mask); /* 原子地清空 mask + 阻塞,直到信号到来 */
经典场景:等待信号触发再继续。比"先解 mask 再 pause"更安全(避免 race window)。
7. 多线程下的信号
POSIX 规定:
- 信号处理是进程级的
- 信号 mask 是线程级的:每个线程有自己的 mask(
pthread_sigmask) - kill(pid, sig)
- pthread_kill(tid, sig)
工程套路:有一个专用线程接信号,其他线程全屏蔽:
sigset_t set;sigemptyset(&set);sigaddset(&set, SIGINT);sigaddset(&set, SIGTERM);/* 主线程屏蔽,所有后续 fork 出来的线程继承屏蔽 */pthread_sigmask(SIG_BLOCK, &set, NULL);/* 启动 worker 线程(继承屏蔽) */pthread_create(...);pthread_create(...);/* 主线程同步等信号 */int sig;sigwait(&set, &sig);printf("got signal %d, shutting down\n", sig);
sigwait 是同步等待信号,完全不走 handler 路径,可以放心调任何函数。这是多线程信号处理的最佳实践。
8. 现代写法:signalfd
#include<sys/signalfd.h>intsignalfd(int fd, constsigset_t *mask, int flags);
把信号变成 fd 上的读事件,可以放进 epoll 一起处理:
sigset_t mask;sigemptyset(&mask);sigaddset(&mask, SIGINT);sigaddset(&mask, SIGTERM);/* 屏蔽这些信号,让它们走 signalfd 而不是默认递送 */pthread_sigmask(SIG_BLOCK, &mask, NULL);int sfd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);/* 把 sfd 加到 epoll,跟其他 fd 一起处理 */struct epoll_event ev = { .events = EPOLLIN, .data.fd = sfd };epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);/* 事件循环 */while (1) { epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i = 0; i < n; i++) { if (events[i].data.fd == sfd) { struct signalfd_siginfo si; read(sfd, &si, sizeof(si)); printf("signal %d arrived\n", si.ssi_signo); } /* ... 处理其他 fd ... */ }}
signalfd 的好处:
- 完全没有 async-signal-safe 限制,handler 里能调任何函数
现代事件驱动框架(libuv、libev、Boost.Asio)几乎都内置 signalfd 处理。
9. 优雅退出的标准范式
#include<signal.h>#include<stdatomic.h>static volatile sig_atomic_t shutdown_requested = 0;voidon_term(int sig){ shutdown_requested = 1;}intmain(void){ struct sigaction sa = { .sa_handler = on_term }; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; sigaction(SIGINT, &sa, NULL); sigaction(SIGTERM, &sa, NULL); while (!shutdown_requested) { handle_one_request(); /* 不会被信号永远阻塞,syscall 被打断后自动重试或 EINTR 检查 */ } /* 主循环退出后做清理 */ flush_logs(); close_db(); return 0;}
这是 daemon、服务、守护进程都通用的模板。
10. 几个特别的信号场景
10.1 SIGPIPE:写已关闭的 socket / pipe
write(closed_pipe_fd, buf, len);/* 默认 → SIGPIPE → 进程被杀! */
服务端代码 99% 要忽略 SIGPIPE:
signal(SIGPIPE, SIG_IGN); /* write 改为返回 -1 + errno=EPIPE */
或者每次 send 时加 MSG_NOSIGNAL:
send(fd, buf, len, MSG_NOSIGNAL);
10.2 SIGCHLD:自动 reap 子进程
如果只想让子进程退出后被自动回收:
struct sigaction sa = { .sa_handler = SIG_IGN };sigaction(SIGCHLD, &sa, NULL);/* 子进程退出后内核直接回收,不变僵尸 */
但你拿不到子进程的退出码。
或者注册 handler 自己 wait:
voidon_sigchld(int sig){ while (waitpid(-1, NULL, WNOHANG) > 0) ; /* 循环 reap,避免合并丢失 */}
⚠️ 必须循环 wait,因为 SIGCHLD 不排队 —— 多个子进程同时退出可能只触发一次 handler。
10.3 SIGSEGV:在 handler 里打印堆栈
#include<execinfo.h>voidon_segv(int sig, siginfo_t *info, void *uc){ fprintf(stderr, "SIGSEGV at %p\n", info->si_addr); void *frames[64]; int n = backtrace(frames, 64); backtrace_symbols_fd(frames, n, STDERR_FILENO); /* 重置 handler 让它真崩,生成 core */ signal(sig, SIG_DFL); raise(sig);}
不过 backtrace_symbols_fd 严格说不是异步安全(内部可能 malloc)。生产代码用 libunwind 或 signal-safe-stacktrace 之类的方案。
11. 异步信号安全函数清单(精简)
记忆口诀(按类别 4 大块):
| |
|---|
| read / write / open / close / dup / fcntl |
| kill / raise / _exit / abort / getpid |
| sigaction |
| 所有 stdio、malloc、mutex、exit |
12. 几个最大的坑
12.1 在 handler 里调 printf
stdio 内部有锁,可能死锁。永远用 write(STDERR_FILENO, ...) 代替。
12.2 长 read / write 被信号中断返回 EINTR
ssize_t n = read(fd, buf, sz);if (n < 0 && errno == EINTR) { /* 被信号打断,自己重试 */}
或者 sa_flags |= SA_RESTART 让内核自动重启。注意不是所有 syscall 都支持自动重启(select、accept 在某些 Unix 上不行)。
12.3 SIGCHLD handler 不循环 wait → 漏 reap
多个子进程同时退出时,SIGCHLD 信号会合并,handler 只触发一次。必须用 while (waitpid(-1, NULL, WNOHANG) > 0) ;。
12.4 多线程错误使用 signal/sigaction
sigaction 是进程级的,但 mask 是线程级的。在线程里调 sigprocmask 改的是当前线程的 mask(Linux 实际就是 pthread_sigmask)。
12.5 SIGKILL/SIGSTOP 不能被捕获
写 signal(SIGKILL, my_handler) 会失败返回 SIG_ERR。这是设计如此 —— 总要有终极手段干掉无响应进程。
13. 总结表
| | |
|---|
signal() | | |
sigaction() | | |
kill(pid, sig) | | |
raise(sig) | | |
pthread_kill(tid, sig) | | |
sigprocmask | | |
sigsuspend | | |
sigwait | | |
signalfd | | |
14. 收尾
信号是 Unix 最古老的 IPC 机制(70 年代就有了)—— 缺点很多,但仍是必备技能:
| |
|---|
| 不携带数据 / 可丢失 / 异步打断;async-signal-safe 限制让 handler 几乎写不了真正的逻辑;跟多线程交互复杂 |
| Ctrl+C 与 SIGTERM 优雅退出(几乎所有服务都要);SIGCHLD 处理子进程(fork 用户必学);SIGPIPE 忽略(网络编程必需);SIGSEGV 抓 crash(调试必需) |
| ① 用 sigaction(不用老的 signal) ② handler 只设 sig_atomic_t 标志 ③ 多线程用专用线程 + sigwait ④ 现代事件循环用 signalfd |
金句:handler 不是写逻辑的地方,是"通知主循环有事发生"的地方。