_exit() fstat() read() write()
access() getegid() rename() umask()
alarm() geteuid() rmdir() unlink()
bind() getgid() select() utime()
cfgetispeed() getgroups() setgid() wait()
cfgetospeed() getpeername() setpgid() waitpid()
chdir() getpgrp() setsid() writev()
chmod() getpid() setsockopt()
chown() getppid() shutdown()
clock_gettime() getsockname() sigaction()
close() getsockopt() sigaddset()
connect() getuid() sigdelset()
dup() kill() sigemptyset()
dup2() link() sigfillset()
execve() listen() sigismessage()
fchdir() lseek() sigpending()
fchmod() mkdir() sigprocmask()
fchown() open() sigqueue()
fcntl() pause() siguspend()
fdatasync() pipe() sleep()
fork() poll() sockatmark()
fpathconf() posix_trace_event() socket()
pselect() socketpair()
raise() stat()
readlink() symlink()
注意看哪些不在列表中:
malloc / free / realloc / calloc —— 所有内存分配函数
printf / fprintf / sprintf / snprintf —— 所有标准 I/O 函数
exit()(注意:_exit() 是安全的,exit() 不是)
pthread_mutex_lock / pthread_mutex_unlock —— 所有互斥锁操作
longjmp / siglongjmp(siglongjmp 是信号安全的,但 longjmp 不是)
这个限制非常严格。在信号处理函数中,你几乎不能做任何有用的事情。
四、核心架构模式:从信号处理函数到主循环
正因为信号处理函数中的操作极端受限,现代高性能系统都采用"最小化处理"原则:信号处理函数只做最少的工作,真正的处理逻辑在主事件循环中执行。
模式 1:self-pipe trick
这是经典的 POSIX 模式,也是最通用的方法:
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
static int signal_pipe[2] = {-1, -1};
// 信号处理函数:仅写入一个字节的信号编号
static void signal_handler(int sig) {
int saved_errno = errno; // 保存 errno!
// write() 是 async-signal-safe 的
// 使用 write 而非 printf/puts 等
(void)write(signal_pipe[1], &sig, sizeof(sig));
errno = saved_errno; // 恢复 errno!
}
int setup_signal_pipe(void) {
if (pipe(signal_pipe) < 0)
return -1;
// 设置为非阻塞
int flags = fcntl(signal_pipe[1], F_GETFL);
fcntl(signal_pipe[1], F_SETFL, flags | O_NONBLOCK);
flags = fcntl(signal_pipe[0], F_GETFL);
fcntl(signal_pipe[0], F_SETFL, flags | O_NONBLOCK);
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = signal_handler;
sa.sa_flags = SA_RESTART; // 自动重启被中断的系统调用
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
sigaction(SIGHUP, &sa, NULL);
sigaction(SIGUSR1, &sa, NULL);
sigaction(SIGUSR2, &sa, NULL);
// SIGCHLD 通常不通过此管道处理,因为会合并
sigaction(SIGCHLD, &sa, NULL);
return 0;
}
// 在主事件循环中读取管道并处理
int process_signals(void) {
int sig;
while (read(signal_pipe[0], &sig, sizeof(sig)) == sizeof(sig)) {
switch (sig) {
case SIGTERM:
case SIGINT:
// 优雅退出
return -1;
case SIGHUP:
// 重新加载配置
reload_config();
break;
case SIGUSR1:
// 自定义操作:例如重新打开日志文件
reopen_logfile();
break;
case SIGUSR2:
// 自定义操作:例如开启/关闭调试模式
toggle_debug_mode();
break;
case SIGCHLD:
// 子进程退出,需要 waitpid 收尸
while (waitpid(-1, NULL, WNOHANG) > 0);
break;
}
}
return 0;
}
这个模式的精髓在于:
- 信号处理函数只做一件事 ——
write() 写入一个字节到管道
- 主循环通过
read() 或 poll() 的完整集合来读取 —— 在正常的同步上下文中处理
- 必须保存和恢复
errno —— 因为 write() 可能修改它,破坏被中断代码的错误检查
模式 2:signalfd(Linux 特有)
Linux 提供了更优雅的方式 —— signalfd()。它将信号转换为文件描述符上的可读事件,完全避免了信号处理函数:
#include <sys/signalfd.h>
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
int setup_signalfd(void) {
sigset_t mask;
// 必须先阻塞这些信号,否则默认行为会触发
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGHUP);
sigaddset(&mask, SIGUSR1);
sigaddset(&mask, SIGUSR2);
// 阻塞信号 —— sigprocmask 是 async-signal-safe 的
if (sigprocmask(SIG_BLOCK, &mask, NULL) < 0) {
perror("sigprocmask");
return -1;
}
// 创建 signalfd
int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
if (sfd < 0) {
perror("signalfd");
return -1;
}
return sfd;
}
int process_signalfd(int sfd) {
struct signalfd_siginfo si;
ssize_t n;
// signalfd 提供了比普通信号更丰富的信息
while ((n = read(sfd, &si, sizeof(si))) == sizeof(si)) {
printf("Received signal %d from PID %d (UID %d)\n",
si.ssi_signo, si.ssi_pid, si.ssi_uid);
switch (si.ssi_signo) {
case SIGTERM:
case SIGINT:
return -1; // 触发退出
case SIGHUP:
reload_config();
break;
case SIGUSR1:
reopen_logfile();
break;
}
}
if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
perror("read signalfd");
return -1;
}
return 0;
}
signalfd 的优势:
- 完全绕过信号处理函数 —— 无需担心 async-signal-safe 限制
- 保留信号的丰富信息 —— 包括发送者的 PID、UID、实时信号的附加值
- 天然集成到事件循环 —— 与其他 fd 一起通过 epoll/select/poll 多路复用
模式 3:timerfd + 信号融合
定时器在传统上通过 setitimer() 或 alarm() 触发 SIGALRM,但 Linux 的 timerfd_create() 提供了更优雅的方案:
#include <sys/timerfd.h>
#include <time.h>
#include <unistd.h>
#include <stdio.h>
int setup_periodic_timer(long interval_ms) {
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
if (tfd < 0) return -1;
struct itimerspec its;
its.it_value.tv_sec = interval_ms / 1000;
its.it_value.tv_nsec = (interval_ms % 1000) * 1000000;
its.it_interval = its.it_value; // 周期性
if (timerfd_settime(tfd, 0, &its, NULL) < 0) {
close(tfd);
return -1;
}
return tfd;
}
int process_timer(int tfd) {
uint64_t expirations;
ssize_t n = read(tfd, &expirations, sizeof(expirations));
if (n == sizeof(expirations)) {
// expirations 是在上次读取以来到期的次数
// 如果处理较慢,可能会有多次累积
printf("Timer expired %lu time(s)\n", (unsigned long)expirations);
do_periodic_work();
return 0;
}
return -1;
}
五、多线程环境下的信号地狱
多线程程序的信号处理远比单线程复杂。核心问题在于:
5.1 信号的递送规则
SIGNALS BEHAVIOR:
┌──────────────────────────────────────────────────────┐
│ 信号类型 │ 递送目标 │
├──────────────────────────────────────────────────────┤
│ 硬件异常 │ 导致异常的线程(SIGSEGV/SIGFPE等) │
│ (SIGSEGV, SIGFPE) │ │
├──────────────────────────────────────────────────────┤
│ kill() / raise() │ 整个进程(任意一个阻塞该信号的线程)│
│ (SIGTERM, SIGINT) │ │
├──────────────────────────────────────────────────────┤
│ pthread_kill() │ 指定的线程 │
├──────────────────────────────────────────────────────┤
│ 线程相关信号 │ 特定线程(SIGPOLL等) │
├──────────────────────────────────────────────────────┤
│ 实时信号 │ 未阻塞的任意线程(可排队) │
│ (SIGRTMIN+*) │ │
└──────────────────────────────────────────────────────┘
关键规则:
SIGKILL 和 SIGSTOP 不能被阻塞、捕获或忽略
- 标准信号不排队(同一信号多次发送可能合并为一次)
- 实时信号排队(
SIGRTMIN 到 SIGRTMAX,最多排队 SIGQUEUE_MAX 个)
5.2 多线程信号处理最优实践
#include <signal.h>
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>
/*
* 多线程信号处理的推荐架构:
*
* 1. 主线程创建前,阻塞所有需要处理的信号
* 2. 所有继承主线程信号掩码的线程都会阻塞这些信号
* 3. 创建一个专门的"信号处理线程"通过 sigwait() 同步等待信号
*/
static volatile sig_atomic_t global_shutdown = 0;
// 工作线程中检查退出
void *worker_thread(void *arg) {
int id = *(int *)arg;
while (!global_shutdown) {
// 正常业务逻辑
do_work(id);
}
printf("Worker %d: clean shutdown\n", id);
return NULL;
}
// 专用信号处理线程
void *signal_thread(void *arg) {
sigset_t *wait_set = (sigset_t *)arg;
int sig;
while (1) {
// sigwait 是同步的、可重入的、线程安全的
// 它会在一个线程上下文中"取出"被阻塞的信号
int ret = sigwait(wait_set, &sig);
if (ret != 0) {
perror("sigwait");
continue;
}
printf("Signal thread caught %d\n", sig);
switch (sig) {
case SIGTERM:
case SIGINT:
// 设置原子标志,通知所有线程退出
global_shutdown = 1;
return NULL;
case SIGHUP:
reload_config();
break;
case SIGUSR1:
print_stats();
break;
}
}
}
/*
* 关键的初始化顺序:
*
* main()
* ├── sigemptyset + sigaddset (准备信号集)
* ├── pthread_sigmask(SIG_BLOCK) (在所有线程中阻塞信号!)
* ├── pthread_create(signal_thread) (创建信号处理线程)
* ├── pthread_create(N x worker) (创建工作线程)
* └── join all
*
* 为什么必须在创建线程前阻塞?
* 因为子线程继承父线程的信号掩码。如果之后才阻塞,
* 可能存在竞态窗口:信号在阻塞前到达,被默认处理(通常是终止进程)。
*/
int main(int argc, char **argv) {
sigset_t wait_set;
sigemptyset(&wait_set);
sigaddset(&wait_set, SIGTERM);
sigaddset(&wait_set, SIGINT);
sigaddset(&wait_set, SIGHUP);
sigaddset(&wait_set, SIGUSR1);
// 关键:在所有线程创建前阻塞
pthread_sigmask(SIG_BLOCK, &wait_set, NULL);
pthread_t sig_tid;
pthread_create(&sig_tid, NULL, signal_thread, &wait_set);
// 创建工作线程...
#define N_WORKERS 4
pthread_t workers[N_WORKERS];
int ids[N_WORKERS];
for (int i = 0; i < N_WORKERS; i++) {
ids[i] = i;
pthread_create(&workers[i], NULL, worker_thread, &ids[i]);
}
for (int i = 0; i < N_WORKERS; i++)
pthread_join(workers[i], NULL);
pthread_join(sig_tid, NULL);
return 0;
}
六、errno 陷阱:最隐蔽的 Bug 来源
信号处理函数中 errno 的处理是最容易出错的细节。考虑以下代码:
// BUG 演示:errno 被信号处理函数破坏
void vulnerable_function(void) {
int fd = open("/some/file", O_RDONLY);
if (fd < 0) {
// 在此时,如果信号到达并触发 write(),
// errno 会被覆写!
if (errno == EACCES) {
log("permission denied");
} else if (errno == ENOENT) {
log("file not found");
} else {
// 实际可能执行到这里,虽然 open 失败的原因不是 EIO
log("unexpected error, errno=%d", errno);
}
}
}
而 open() 本身不是 async-signal-safe 的吗?不,open() 是 async-signal-safe 的。但这段代码中的问题是:主程序在检查 errno 的窗口期内被信号打断,信号处理函数执行了 write() 并修改了 errno。
这说明了两个重要问题:
- 信号处理函数必须保存和恢复
errno
SA_RESTART 标志很重要 —— 它让被信号中断的系统调用自动重启,减少主程序的 errno = EINTR 检查负担
// 正确的信号处理函数模板
static void safe_handler(int sig) {
int saved_errno = errno; // 进入时立即保存
// ... 处理逻辑(只能调用 async-signal-safe 函数) ...
errno = saved_errno; // 退出时恢复
}
SA_RESTART 的一个细微点:并非所有系统调用都会被自动重启。以下调用即使设置了 SA_RESTART 也不会重启:
pause() / sigsuspend() —— 信号本来就是唤醒它们的
select() / pselect() / poll() / epoll_wait() —— 到达超时才返回
accept() / connect() —— 部分支持,但超时可能受影响
sleep() / nanosleep() —— 剩余睡眠时间不会自动补偿
七、与 epoll 事件循环的融合
在现代高性能系统中,信号通常通过 event loop 统一分发:
#include <sys/signalfd.h>
#include <sys/epoll.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#define MAX_EVENTS 64
typedef struct {
int epoll_fd;
int signal_fd;
// 其他组件...
} event_loop_t;
int event_loop_init(event_loop_t *loop) {
loop->epoll_fd = epoll_create1(EPOLL_CLOEXEC);
if (loop->epoll_fd < 0) return -1;
// 设置 signalfd
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGHUP);
sigaddset(&mask, SIGUSR1);
sigprocmask(SIG_BLOCK, &mask, NULL);
loop->signal_fd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
if (loop->signal_fd < 0) return -1;
// 将 signal_fd 加入 epoll
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.ptr = NULL; // 用 NULL 标识信号事件
epoll_ctl(loop->epoll_fd, EPOLL_CTL_ADD, loop->signal_fd, &ev);
return 0;
}
typedef enum {
FD_TYPE_SIGNAL,
FD_TYPE_TCP_ACCEPT,
FD_TYPE_TCP_READ,
FD_TYPE_TIMER,
FD_TYPE_PIPE,
} fd_type_t;
typedef struct {
fd_type_t type;
int fd;
void (*callback)(void *ctx); // 简化版,实际通常带更多参数
void *ctx;
} handler_t;
int event_loop_run(event_loop_t *loop) {
struct epoll_event events[MAX_EVENTS];
int running = 1;
while (running) {
int nfds = epoll_wait(loop->epoll_fd, events, MAX_EVENTS, -1);
if (nfds < 0) {
if (errno == EINTR) continue; // 被信号打断,继续
perror("epoll_wait");
return -1;
}
for (int i = 0; i < nfds; i++) {
if (events[i].data.ptr == NULL) {
// signal_fd 就绪
struct signalfd_siginfo si;
while (read(loop->signal_fd, &si, sizeof(si)) == sizeof(si)) {
switch (si.ssi_signo) {
case SIGTERM:
case SIGINT:
running = 0;
break;
case SIGHUP:
reload_config();
break;
case SIGUSR1:
print_stats();
break;
}
}
} else {
handler_t *h = (handler_t *)events[i].data.ptr;
h->callback(h->ctx);
}
}
}
return 0;
}
这个架构的关键设计点:
- signalfd 作为普通 fd 统一纳入 epoll —— 无需区分信号事件和 I/O 事件
- 信号的丰富信息通过
signalfd_siginfo 获取 —— 比传统 sa_handler 的 int signo 参数信息量更大
- 优雅的关闭通过
running 标志实现 —— 收到 SIGTERM/SIGINT 后不再接受新事件,完成当前处理后再退出
八、生产环境实战:信号处理清单
8.1 必须处理的信号
| 信号 |
默认行为 |
推荐处理 |
说明 |
| SIGTERM |
终止 |
优雅退出 |
标准的"请退出"信号 |
| SIGINT |
终止 |
优雅退出 |
Ctrl+C 触发 |
| SIGPIPE |
终止 |
忽略(SIG_IGN) |
写已关闭的管道/ socket |
| SIGHUP |
终止 |
重载配置 |
终端断开,常用于 daemon |
| SIGUSR1 |
终止 |
自定义 |
常用于日志重开 |
| SIGUSR2 |
终止 |
自定义 |
常用于调试模式切换 |
| SIGCHLD |
忽略 |
waitpid 收尸 |
子进程退出 |
8.2 绝不能阻塞的信号
SIGKILL —— 内核用于确保进程终止的最后手段
SIGSTOP —— 用于调试器暂停进程
尝试阻塞这两个信号会导致 sigprocmask / pthread_sigmask 返回 EINVAL。
8.3 生产陷阱与对策
陷阱 1:信号丢失(标准信号不排队)
问题:快速连续发送两次 SIGUSR1,可能只收到一次。
对策:用实时信号(SIGRTMIN+n)替代,或通过计数器协议(信号处理函数原子递增,主循环处理至计数归零)。
// 安全计数法(主循环消抖)
static volatile sig_atomic_t sigusr1_count = 0;
static void handler_sigusr1(int sig) {
(void)sig;
int saved_errno = errno;
sigusr1_count++; // 原子递增(对齐的整数在 x86/ARM 上是原子的)
errno = saved_errno;
}
// 主循环中:
while (sigusr1_count > 0) {
int count = sigusr1_count;
__atomic_exchange_n(&sigusr1_count, 0, __ATOMIC_SEQ_CST);
for (int i = 0; i < count; i++)
handle_sigusr1();
}
陷阱 2:fork() 后的死锁
问题:fork() 只调用线程本身,但子进程继承了父进程所有互斥锁的状态(可能被其他线程持有)。如果信号处理函数中调用了 malloc(不可重入且持锁),子进程中再次调用 malloc 会死锁。
对策:使用 pthread_atfork() 注册三阶段锁处理:
void prefork(void) {
// fork 前:获取所有锁
pthread_mutex_lock(&global_lock);
}
void parent_postfork(void) {
// fork 后父进程:释放锁
pthread_mutex_unlock(&global_lock);
}
void child_postfork(void) {
// fork 后子进程:重新初始化所有锁
pthread_mutex_init(&global_lock, NULL);
}
// 注册
pthread_atfork(prefork, parent_postfork, child_postfork);
陷阱 3:SIGCHLD 的不可靠性
问题:多个子进程几乎同时退出,可能只收到一次 SIGCHLD。
对策:信号处理函数中循环 until waitpid 返回 0:
void sigchld_handler(int sig) {
(void)sig;
int saved_errno = errno;
pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
// 为每个收到的子进程收尸
}
errno = saved_errno;
}
九、进阶方向
信号处理的进阶话题包括:
- 实时信号的排队与优先级 ——
RT_SIGNAL 基于 siginfo_t 的 ssi_value 传递数据
- signalfd + pidfd —— Linux 5.3+ 的
pidfd_open() + pidfd_send_signal() 为进程间信号通信提供了 fd-based 替代
- signal-based AIO —— 传统的 POSIX AIO(
SIGEV_SIGNAL)与现代 io_uring 的对比
- seccomp 与信号的交互 —— seccomp 过滤器如何影响信号处理路径
- 容器中的信号 —— PID 1 的特殊信号行为(需要 init 进程正确处理僵尸进程)
十、总结
信号处理在系统编程中是一种"看似简单,实则深不可测"的机制。经过本文的探讨,可以总结出以下核心原则:
- 最小化处理 —— 信号处理函数仅做标志设置或写入管道,复杂逻辑留给主循环
- save/restore errno —— 任何修改 errno 的操作前后都必须保护它
- prefer signalfd/pidfd —— 在 Linux 上,用 fd-based API 替代传统的异步信号处理
- 多线程用 sigwait —— 专用线程通过 sigwait() 同步处理信号,彻底避开异步安全问题
- 警惕 fork/signal 交互 —— 子进程继承锁状态,必须用
pthread_atfork 防护
信号无处不在,一旦出错就极难复现和调试。理解其底层机制,选择正确的架构模式,是构建高可靠系统的基本功。
关键参考:
man 7 signal、man 7 signal-safety、man 2 signalfd、man 2 sigaction
POSIX.1-2017 (IEEE Std 1003.1) — Signal Concepts
Linux kernel source: kernel/signal.c
发表评论 取消回复