异步信号安全深度实战:从 POSIX 信号到事件循环的信号处理架构


一、问题的本质:信号是软件中断

在 POSIX 系统中,信号(Signal)是异步事件通知机制,类似于硬件中断的软件实现。进程收到信号时,内核会打断当前执行流,强制跳转到信号处理函数(signal handler)。这种"突然插入"带来了一个根本性问题:

主程序在任意时刻都可能被打断,而信号处理函数执行完毕后,主程序继续执行 —— 如果两者竞争共享资源,就会产生竞态条件。

更糟的是,在多线程程序中,内核可以从任一线程中选择一个来递送信号(取决于信号类型),这意味着你甚至无法预测哪个线程会执行信号处理代码。

这就是"异步信号安全"(Async Signal Safety)要解决的问题:什么样的代码可以在信号处理函数中被安全调用?

二、异步信号安全 vs 可重入性

这两个概念经常被混淆,但它们是两个正交的维度:

概念 定义 关注点
不可重入(Non-reentrant) 函数被调用时再次进入会导致错误 函数自身的状态管理

一个函数可以不可重入但异步信号安全,反之亦然:


// malloc: 不可重入(修改全局堆结构),但如果主程序不在执行 malloc,信号处理函数中调用它是安全的
// strtok: 不可重入(使用静态缓冲区),即使主程序不在调用它也不安全 —— 因为信号处理函数和主程序可能交替调用

实际上,在 Linux 系统中,只有有限数量的函数被标准保证为异步信号安全。这些函数被称为 async-signal-safe functions。

三、Linux 的 async-signal-safe 函数清单

根据 POSIX.1-2017 和 Linux man pages (signal-safety(7)),以下是在信号处理函数中可以安全调用的核心函数:


_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;
}

这个模式的精髓在于:

  1. 信号处理函数只做一件事 —— write() 写入一个字节到管道
  2. 主循环通过 read() 或 poll() 的完整集合来读取 —— 在正常的同步上下文中处理
  3. 必须保存和恢复 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 的优势:

  1. 完全绕过信号处理函数 —— 无需担心 async-signal-safe 限制
  2. 保留信号的丰富信息 —— 包括发送者的 PID、UID、实时信号的附加值
  3. 天然集成到事件循环 —— 与其他 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。

这说明了两个重要问题:

  1. 信号处理函数必须保存和恢复 errno
  2. 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;
}

这个架构的关键设计点:

  1. signalfd 作为普通 fd 统一纳入 epoll —— 无需区分信号事件和 I/O 事件
  2. 信号的丰富信息通过 signalfd_siginfo 获取 —— 比传统 sa_handler 的 int signo 参数信息量更大
  3. 优雅的关闭通过 running 标志实现 —— 收到 SIGTERM/SIGINT 后不再接受新事件,完成当前处理后再退出

八、生产环境实战:信号处理清单

8.1 必须处理的信号

异步信号不安全(Async signal unsafe) 在信号处理函数中调用时,可能与被中断的主代码产生竞争 函数与"被打断的代码"之间的交互
信号 默认行为 推荐处理 说明
SIGTERM 终止 优雅退出 标准的"请退出"信号
SIGINT 终止 优雅退出 Ctrl+C 触发
SIGPIPE 终止 忽略(SIG_IGN) 写已关闭的管道/ socket
SIGHUP 终止 重载配置 终端断开,常用于 daemon
SIGUSR1 终止 自定义 常用于日志重开
SIGUSR2 终止 自定义 常用于调试模式切换

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 进程正确处理僵尸进程)

十、总结

信号处理在系统编程中是一种"看似简单,实则深不可测"的机制。经过本文的探讨,可以总结出以下核心原则:

  1. 最小化处理 —— 信号处理函数仅做标志设置或写入管道,复杂逻辑留给主循环
  2. save/restore errno —— 任何修改 errno 的操作前后都必须保护它
  3. prefer signalfd/pidfd —— 在 Linux 上,用 fd-based API 替代传统的异步信号处理
  4. 多线程用 sigwait —— 专用线程通过 sigwait() 同步处理信号,彻底避开异步安全问题
  5. 警惕 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

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
SIGCHLD 忽略 waitpid 收尸 子进程退出