信号用户态编程 · 十大关键坑——async-signal-safe、EINTR、竞态与多线程
这是 signals-user(用户态编程总纲)的拆分篇之一,专门讲十个最常见、最容易踩爆的信号编程坑:handler 里用错函数、EINTR 没处理、共享变量不是原子的、
SA_NODEFER栈爆炸、SA_RESTART不万能、多线程选错模式、fork+ 信号、SIGCHLD 竞态、栈溢出、errno 被篡改。配套拆分篇:API 速查 · 实战 Demo。
一句话(来自总纲):handler 只做"通知",不处理业务。 真正的处理放到主循环(flag / pipe / signalfd / sigwait)。下面十个坑,几乎都是违反这一句话的下场。
破题:十个坑分三类
十个坑看着多,其实可以归成三组,记起来容易得多:
- 坑 1/2/3/10 —— handler 自身的安全:你在 handler 里能调什么函数(async-signal-safe)、被打断的系统调用怎么办(EINTR)、共享变量怎么保证原子(
sig_atomic_t)、errno 怎么保护。这组是"handler 内"的纪律。 - 坑 4/5/9 —— handler 的栈与重启:
SA_NODEFER自嵌套栈爆炸、SA_RESTART不覆盖所有 syscall、主栈满了 handler 无处安身(备用栈)。这组是"handler 运行环境"的边界。 - 坑 6/7/8 —— 多线程与子进程:四种信号处理模式怎么选、
fork+ 信号的三连坑、SIGCHLD 注册竞态。这组是"进程结构"上的坑。
一、handler 自身的纪律
坑一:在 handler 里调用非 async-signal-safe 函数(最常见)
// WRONG —— 可能死锁/崩溃!
void bad_handler(int sig)
{
printf("Got signal %d\n", sig); // ★ printf 不是 async-signal-safe!
malloc(1024); // ★ malloc 不是 async-signal-safe!
pthread_mutex_lock(&g_lock); // ★ 死锁! 如果被中断的线程正持有 g_lock
// 想象: 主线程 print("hello") → 拿到 stdout 内部锁 → 信号来了
// → handler → printf("signal") → 试图拿 stdout 锁 → 死锁!
}
// CORRECT —— handler 只做"通知"
volatile sig_atomic_t g_got_signal = 0;
int sig_pipefd[2];
void good_handler(int sig)
{
g_got_signal = sig; // OK: sig_atomic_t 的写入是原子的
char c = sig;
write(sig_pipefd[1], &c, 1); // OK: write() 是 async-signal-safe
}async-signal-safe 的正式定义
POSIX.1 (IEEE Std 1003.1-2001, §2.4.3 "Signal Actions") 给出了严格的原文定义:
"The behavior is undefined if the signal handler refers to any object other than errno with static storage duration other than by assigning a value to an object declared as volatile sig_atomic_t, or if the signal handler calls any function defined in this standard other than one of the functions listed in the following table."
译:信号 handler 中,如果访问了除 errno 以外的任何静态存储期对象(除非只对 volatile sig_atomic_t 类型的变量做赋值),或者调用了标准规定的函数列表之外的任何函数 → 行为未定义。
这条规则的本质——"为什么某些函数安全、某些不安全"
| 不安全的原因 | 根本机制 |
|---|---|
| ① 不可重入(non-reentrant) | 函数内部使用了全局/静态数据。如 malloc 操作全局 free list——主线程 malloc 到一半被中断 → handler 再 malloc → free list 处于不一致状态 → 堆损坏/UB。 |
| ② 持锁死锁 | 函数内部持有互斥锁。如 printf 持有 stdout 的 FILE* 内部锁——主线程 printf 获得锁 → 信号中断 → handler printf 试图获得同一把锁 → 死锁。 |
| ③ 同信号自嵌套(self-deadlock) | handler 执行中同种信号再次到达(除非设 SA_NODEFER)→ handler 嵌套 → 重入同一函数 → 内部状态破坏。 |
| ④ errno 被覆盖 | handler 调用会设置 errno 的函数 → 主线程正在检查的 errno 被篡改。所以 handler 入口应 save/restore errno(见坑十)。 |
async-signal-safe 函数通过以下方式规避这些风险:
- 无状态的纯计算(
getpid、getuid……) - 直接系统调用,内核保证原子性(
write、read、close……) - 内部不依赖用户态锁、不维护跨调用状态(
open、stat……)
一句话记忆:凡是"内部有锁、有
malloc缓冲、有跨调用全局状态"的函数,都不是 async-signal-safe。handler 里只能用"内核直接代理"的系统调用包装函数。
白名单(POSIX.1-2001 §2.4.3 完整列表,常用子集)
_exit() abort() accept() access() alarm()
bind() chdir() chmod() chown() close()
connect() creat() dup() dup2() execve()
fchmod() fchown() fcntl() fdatasync() fork()
fstat() ftruncate() getegid() geteuid() getgid()
getgroups() getpeername() getpgrp() getpid() getppid()
getsockname() getsockopt() getuid() kill() link()
listen() lseek() lstat() mkdir() open()
pipe() poll() pselect() _Exit() read()
recv() recvfrom() recvmsg() rename() rmdir()
select() sem_post() send() sendmsg() sendto()
setgid() setuid() shutdown() sigaction() sigaddset()
sigdelset() sigemptyset() sigfillset() sigismember()
signal() sigpending() sigprocmask() sigsuspend()
sleep() socket() socketpair() stat() symlink()
time() times() umask() uname() unlink()
utime() wait() waitpid() write()★ 不是 async-signal-safe 但大家常用的(会出问题!)
printf / fprintf / sprintf、malloc / free / realloc、pthread_mutex_lock、pthread_cond_signal、std::string、std::vector、new / delete、syslog()(内部有 malloc)。
坑二:EINTR —— 系统调用被信号中断
// WRONG —— 信号导致的 EINTR, 程序以为 IO 出错
ssize_t bad_read(int fd, void *buf, size_t count)
{
ssize_t n = read(fd, buf, count);
if (n < 0) {
perror("read failed"); // 可能打印 "read failed: Interrupted system call"
return -1; // 但这不是真正的错误!
}
return n;
}
// CORRECT: 循环重试
ssize_t safe_read(int fd, void *buf, size_t count)
{
ssize_t n;
do {
n = read(fd, buf, count);
} while (n == -1 && errno == EINTR);
return n;
}
// 或者用 glibc 提供的宏:
// ssize_t n = TEMP_FAILURE_RETRY(read(fd, buf, count));受 EINTR 影响的系统调用(常见,不全):
read / write / recv / send(slow devices:终端/socket/pipe)、select / poll / epoll_wait、sleep / nanosleep / usleep、wait / waitpid / waitid、accept / connect、flock / fcntl(F_SETLKW)、sem_wait / sem_timedwait、sigwait / sigsuspend / pause、msgrcv / msgsnd / semop。
不受 EINTR 影响的:磁盘 IO 的 read/write(块设备,非 slow device),以及设 SA_RESTART 后的大部分 syscall(但不完全!见坑五)。
坑三:handler 与主程序共享变量 —— volatile sig_atomic_t 不是万能药
volatile sig_atomic_t 只保证单次读写不被信号打断。任何涉及"读-改-写"的操作(如 g_counter++ 对应 load + inc + store 三条指令)仍然不是原子的——信号可能在任意两条指令之间到达,导致计数丢失。
// WRONG —— g_counter++ 不是原子操作!
volatile sig_atomic_t g_counter = 0;
void counter_handler(int sig) { g_counter++; } // load+inc+store, 可能丢计数
// CORRECT 方案 1: sig_atomic_t 只用于"设标志" (单次赋值)
volatile sig_atomic_t g_flag = 0;
void flag_handler(int sig) { g_flag = 1; } // OK: 单次赋值是原子的方案 2:阻塞信号保护临界区。如果主线程需要安全地读写共享变量,可以在读取前临时屏蔽信号——这时甚至不需要 volatile:
int g_count = 0; // 不需要 volatile sig_atomic_t
int get_count_safe(void)
{
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGUSR1);
pthread_sigmask(SIG_BLOCK, &block_set, &old_set); // 信号不会在此时打断
int ret = g_count;
pthread_sigmask(SIG_SETMASK, &old_set, NULL);
return ret;
}sig_atomic_t 的类型限制:它保证是"单次读写原子"的整数类型,通常是 int(sizeof(int) 以内)。在 32-bit 平台上 long long 的 load/store 不是原子的——不能用作 sig_atomic_t。struct/packed 类型也不行。
坑十:信号 handler 中 errno 的保存与恢复
handler 中调用的函数(如 waitpid)可能修改 errno。如果被中断的主线程正在检查 errno(例如刚执行过 read 返回 -1 后读 errno),handler 会悄无声息地篡改它——这是非常隐蔽的 bug。
// WRONG —— waitpid 可能把 errno 改成 ECHILD, 破坏被中断代码的 errno
void bad_sigchld(int sig)
{
pid_t pid;
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0);
}
// CORRECT: 进入 handler 时保存 errno, 退出前恢复
void good_sigchld(int sig)
{
int saved_errno = errno; // ★ 第一时间保存
pid_t pid;
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0);
errno = saved_errno; // ★ 退出前恢复
}经验法则:所有信号 handler 第一行 = 保存
errno,最后一行 = 恢复errno。
二、handler 运行环境的边界
坑四:SA_NODEFER 与 handler 重入的栈爆炸
设了 SA_NODEFER 后,handler 执行期间不会自动屏蔽当前信号——同种信号可以再次进入 handler。如果 handler 执行时间超过信号的到达间隔,会导致无限嵌套:
信号到达 → handler 开始 → 同一信号又到达 → 又一次进入 handler
→ 栈上又多压一层 sigframe → 又触发 → 又进入 → ... → 栈溢出 → SIGSEGV// WRONG —— SA_NODEFER + 慢 handler + 高频信号 = 栈爆炸
sa.sa_flags = SA_NODEFER;
sigaction(SIGALRM, &sa, NULL);
void alarm_handler(int sig)
{
alarm(1); // 1秒后又触发
do_slow_work(); // 超过1秒 → handler 重新进入!
}默认行为(不设 SA_NODEFER)更安全:同种信号在 handler 期间自动被屏蔽,排队等待,handler 返回后才投递下一个。
坑五:SA_RESTART 不是所有系统调用都生效
设了 SA_RESTART 不代表可以忽略 EINTR。它只能自动重启部分系统调用:
SA_RESTART 能自动重启 | SA_RESTART 不能重启(仍返回 EINTR!) |
|---|---|
read()/write()(slow device) | poll()、ppoll()、select()、pselect() |
wait() 系列 | epoll_wait()、epoll_pwait() |
ioctl() 等 | sleep()、nanosleep() |
connect()(已发起连接的) | |
recvfrom()、recvmsg()、sendto()、sendmsg() |
结论:网络编程里尤其注意——epoll_wait 即使设了 SA_RESTART 也会返回 EINTR。
while (running) {
int n = epoll_wait(epoll_fd, events, MAX_EVENTS, timeout);
if (n == -1 && errno == EINTR)
continue; // 即使有 SA_RESTART, epoll_wait 也会返回 EINTR!
// ... 处理事件
}坑九:信号栈溢出与 SA_ONSTACK
场景:程序因 bug 导致栈无限递归 → 栈撞到 guard page → SIGSEGV。问题:此时正常栈已满,内核尝试在栈上再压一个 sigframe → 失败 → 进程直接被杀,没有任何机会记录"栈溢出了"的诊断信息。
解决:设置备用栈 + SA_ONSTACK。handler 在备用栈上执行,即使主栈已满也能安全记录诊断信息再退出(_exit,不要 return——栈已毁)。完整可运行示例见 实战 Demo 篇 §3.5。
三、多线程与子进程的坑
坑六:多线程信号处理模式选择
多线程下有四种处理信号的模式,按推荐度从高到低排列:
模式 1(★★★★★ 最推荐):sigwait + 专用线程
完全同步——没有 handler、没有 async-signal-safe 限制、没有 EINTR。专用线程阻塞在 sigwait(),信号到达后正常返回,可以调用任何函数。关键点:必须在创建其他线程之前屏蔽目标信号(子线程自动继承屏蔽字),且所有线程都要屏蔽,否则信号会走 handler 而非 sigwait。
#include <signal.h>
#include <pthread.h>
static void *signal_thread(void *arg)
{
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGTERM);
sigaddset(&set, SIGINT);
int sig;
while (1) {
if (sigwait(&set, &sig) != 0) continue;
switch (sig) {
case SIGTERM: case SIGINT:
printf("Shutting down...\n"); // 可以 printf!
cleanup_and_exit();
break;
}
}
return NULL;
}
void setup_signal_thread(void)
{
// 关键: 在主线程创建其他线程之前屏蔽目标信号——子线程自动继承屏蔽字
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGTERM);
sigaddset(&set, SIGINT);
pthread_sigmask(SIG_BLOCK, &set, NULL);
pthread_t tid;
pthread_create(&tid, NULL, signal_thread, NULL);
// 然后启动业务线程...
}模式 2(★★★★★ 适合已有事件循环):signalfd + epoll
信号变为 fd 可读事件,和业务 IO 统一调度。同样无 handler、无 async-signal-safe 限制。
void setup_signalfd_epoll(void)
{
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGCHLD);
pthread_sigmask(SIG_BLOCK, &mask, NULL); // 必须 block
int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
struct epoll_event ev = { .events = EPOLLIN, .data.fd = sfd };
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sfd, &ev);
// 主循环中 epoll_wait 返回 sfd 可读 → read(sfd, ...) → 同步处理
}模式 3(★★★ 简单场景):handler 设 flag
最简单,但局限大——只能通知、不能传数据,只能在主循环中周期性轮询检查。
volatile sig_atomic_t g_shutdown = 0;
void shutdown_handler(int sig) { g_shutdown = 1; }
// 主循环:
while (!g_shutdown) {
// ... 业务逻辑,周期性检查 g_shutdown ...
}模式 4(★★☆ 需要快速响应):handler 写 pipe
handler 中 write() 一个字节到 pipe,主循环把 pipe 读端加入 epoll——比轮询 flag 更快响应。
int sig_pipe[2];
void pipe_handler(int sig)
{
char c = (char)sig;
write(sig_pipe[1], &c, 1); // write 是 async-signal-safe
}
// 主循环把 sig_pipe[0] 加入 epoll → 收到信号立即唤醒 → 同步处理坑七:fork() + 信号 = 灾难合集
多线程程序中使用 fork() + 信号有三个经典问题:
问题 1 —— handler 遗传但上下文丢失:子进程继承了父进程的所有 sigaction handler,但子进程只有一条线程(调用 fork() 的那条)。如果父进程是 sigwait 模式,子进程没有 signal_thread——信号可能走到不被期望的路径上。
解决:
execve后 handler 全部重置为SIG_DFL(已加载新程序),这就是为什么fork + exec相对安全。非 exec 路径需要在fork()后手动重置关键 handler。
问题 2 —— 多线程 fork 锁死(async-signal-safe 灾难):另一线程 fork() → 子进程只克隆了调用 fork() 的那条线程 → 如果被蒸发的那条线程正持有一把锁 L → 子进程中 L 永远处于 locked 状态但无人解锁 → 子进程任何 lock(&L) 操作永久死锁。
★ 绝对不要在信号 handler 中
fork()!
问题 3 —— SIGCHLD 竞态:fork() 后立即注册 SIGCHLD handler 时,子进程可能已经退出了。
解决:
fork()前block SIGCHLD→fork()→ 注册 handler → 再unblock(见坑八的完整正确写法)。
坑八:SIGCHLD + waitpid 的竞态条件
经典 race condition:如果先 fork() 再注册 SIGCHLD handler,子进程可能在 handler 注册完成前就退出了——SIGCHLD 丢失。
// WRONG 1: 顺序反了
signal(SIGCHLD, sigchld_handler);
pid_t pid = fork(); // 如果子进程在 signal() 和 fork() 之间退出了?
// WRONG 2: 另一种竞态
pid_t pid = fork();
if (pid == 0) { exit(0); }
signal(SIGCHLD, sigchld_handler); // 子进程可能已经退出了!正确做法:先 block SIGCHLD → fork → 注册 handler → 再 unblock。在屏蔽期间子进程退出也不会丢失信号——SIGCHLD 留在 pending 中,解除屏蔽后立即投递。
void safe_fork_and_collect(void)
{
sigset_t block_mask, old_mask;
sigemptyset(&block_mask);
sigaddset(&block_mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &block_mask, &old_mask); // ① 先屏蔽
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL); // ② 注册 handler
pid_t pid = fork(); // ③ fork
if (pid == 0) {
sigprocmask(SIG_SETMASK, &old_mask, NULL);
/* child logic */
_exit(0);
}
sigprocmask(SIG_SETMASK, &old_mask, NULL); // ④ unblock (此时 handler 已就位)
}
// sigchld_handler 中安全回收所有退出的子进程:
void sigchld_handler(int sig)
{
int saved_errno = errno; // ★ 保存 errno! (见坑十)
pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
// 记录 pid 的退出状态, 或通过其他机制通知主循环
// 不能在 handler 中调用 malloc/printf!
}
errno = saved_errno; // ★ 恢复 errno
}四、一句话总结
十个坑按三组记:① handler 自身纪律——只用 async-signal-safe 函数(内部有锁/malloc/全局状态的都不行,printf/malloc/pthread_mutex_lock 全禁)、被打断的 syscall 循环重试 EINTR(或 TEMP_FAILURE_RETRY)、共享变量只做单次赋值(volatile sig_atomic_t 不是原子计数器)、handler 首尾保存/恢复 errno;② handler 环境边界——SA_NODEFER 会自嵌套栈爆炸(默认不设更安全)、SA_RESTART 不覆盖 poll/select/epoll_wait/sleep、主栈可能溢出所以要 sigaltstack+SA_ONSTACK;③ 多线程与子进程——优先 sigwait 专用线程或 signalfd+epoll(同步、无限制),fork 的三大坑(handler 遗传上下文丢失、多线程 fork 锁死、SIGCHLD 竞态)用"先 block→fork→注册→unblock"化解。所有坑的终极解药是同一句话:handler 只做通知,处理放主循环。