Linux 信号机制:从 kill -9 到可靠信号

1. 信号基础 Linux 信号(signal)是操作系统向进程发送的软件中断通知机制。当特定事件发生时——用户按下 Ctrl-C、子进程退出、进程访问无效内存、管道写入端关闭——内核向目标进程的进程控制块(PCB)中记录一个待处理的信号。

1. 信号基础

Linux 信号(signal)是操作系统向进程发送的软件中断通知机制。当特定事件发生时——用户按下 Ctrl-C、子进程退出、进程访问无效内存、管道写入端关闭——内核向目标进程的进程控制块(PCB)中记录一个待处理的信号。与硬件中断不同,信号完全在软件层面传递,但同样具有异步特性:进程无法精确预知信号何时到达。

标准信号

Linux 遵循 POSIX 标准定义了多种信号,可通过 kill -l 列出。以下是其中最关键的几类:

信号编号默认动作说明
SIGHUP1终止终端挂起或控制进程死亡,常用于通知守护进程重载配置
SIGINT2终止用户按下 Ctrl-C 产生的中断信号
SIGQUIT3CoreCtrl-\,终止并产生 core dump
SIGILL4Core非法指令
SIGABRT6Core调用 abort() 产生
SIGFPE8Core浮点异常/算术错误
SIGKILL9终止强制终止,不可捕获、不可忽略
SIGSEGV11Core段错误,非法内存访问
SIGPIPE13终止向无读取端的管道/socket 写入
SIGALRM14终止alarm()/setitimer() 定时器到期
SIGTERM15终止优雅终止请求(kill 默认信号)
SIGCHLD17忽略子进程停止或终止
SIGSTOP19停止暂停进程,不可捕获、不可忽略
SIGCONT18继续恢复被 SIGSTOP/SIGTSTP 停止的进程
SIGUSR110终止用户自定义信号
SIGUSR212终止用户自定义信号

POSIX 将信号分为两类:可靠信号不可靠信号。不可靠信号(早期 System V 实现)存在两个致命缺陷:一,同一信号在处理期间再次到达会被丢弃,造成信号丢失;二,信号处理函数执行完毕后会自动重置为默认动作(SIG_DFL),必须每次重新注册。Linux 的 signal() 行为因 glibc 版本和特性测试宏而异,正是这种历史包袱的直接体现。

每个信号的默认动作分为五类:终止进程(Terminate)、忽略信号(Ignore)、产生 core dump 并终止(Core Dump)、停止进程(Stop)、继续执行(Continue)。

2. 信号生命周期

理解信号机制需要把握其完整生命周期:

产生(Generation) —— 当信号事件发生时,内核在目标进程的 task_struct 中设置对应的 pending 位。若目标进程当前正在该 CPU 上运行或即将被调度,内核会在适当的时机尝试投递;若信号来自另一个 CPU,则通过处理器间中断(IPI)或延迟处理机制确保目标可见。

阻塞与掩码(Blocking/Masking) —— 每个进程拥有信号掩码(signal mask),可通过 sigprocmask() 显式阻塞特定信号。被阻塞的信号保持 pending 状态,直到被解除阻塞。此外,sigaction 注册时可额外指定在处理该信号期间自动阻塞的信号集,防止信号处理嵌套。

投递(Delivery) —— 信号并非立即执行处理函数。内核在以下时机检查并投递待处理信号:

  1. 从系统调用返回到用户空间之前
  2. 从异常/中断处理返回到用户空间之前
  3. 进程从内核态切换到用户态时

这意味着若进程始终在用户态运行从未陷入内核,信号不会立即得到处理。一个长时纯计算循环中的 SIGINT 可能延迟响应数秒。

执行(Execution) —— 投递成功后,内核为进程构建一个临时的栈帧,将执行流切换到信号处理函数。处理完成后通过 sigreturn 系统调用恢复原上下文。

3. 信号处理

signal() 与 sigaction()

历史上注册信号处理函数使用 signal()

#include <signal.h>

void handler(int sig) {
    /* 处理信号 */
}

int main(void) {
    signal(SIGINT, handler);  /* 不可靠,行为因平台而异 */
    /* ... */
}

signal() 的问题在于其行为未标准化:在不同 UNIX 变体中,它可能表现为可靠语义(SA_RESTART + 不重置处理器),也可能表现为不可靠语义(不自动重启被中断的系统调用,且处理完即恢复默认动作)。可移植代码绝不应直接使用 signal()

现代程序应始终使用 sigaction()

#include <signal.h>
#include <stdio.h>
#include <unistd.h>

void handler(int sig) {
    const char msg[] = "Caught signal\n";
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
}

int main(void) {
    struct sigaction sa;
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;  /* 自动重启被中断的系统调用 */

    if (sigaction(SIGINT, &sa, NULL) == -1) {
        perror("sigaction");
        return 1;
    }

    while (1) {
        pause();
    }
    return 0;
}

sigaction 结构体包含三个核心字段:

  • sa_handlersa_sigaction:处理函数。若设置 SA_SIGINFO 标志,则使用三参数版本 sa_sigaction(sig, siginfo_t *, void *),可获取信号来源(发送者 PID、UID、导致段的错误地址等)。
  • sa_mask:在处理该信号期间额外阻塞的信号集,防止重入竞争。
  • sa_flags:控制行为,常用标志包括:
    • SA_RESTART:自动重启被信号中断的系统调用(如 read()write()accept()),避免编写复杂的 EINTR 重试逻辑。
    • SA_RESETHAND:处理一次后恢复默认动作(模拟旧 signal() 行为)。
    • SA_SIGINFO:使用扩展信息处理函数。
    • SA_NODEFER:处理该信号期间不自动阻塞自身(允许嵌套处理,极度危险)。

异步信号安全

信号处理函数运行在完全独立的上下文中,此时进程可能正处于:分配堆内存的 malloc() 中途、持有某把 pthread_mutex_t 锁、修改全局数据结构的过程中。若信号处理函数再调用同样不可重入的函数,就会引发数据不一致甚至崩溃。

因此,信号处理函数中只能调用异步信号安全(async-signal-safe)的函数。根据 man 7 signal-safety,清单包括:

  • 系统调用:read, write, close, _exit, kill, sigaction, sigprocmask, alarm, setitimer, fcntl, dup, pipe, fork, waitpid
  • 标准库中明确安全的:strlen(部分实现)、memset 等无锁纯函数(严格来说标准未全部保证)

绝对禁止在信号处理函数中调用的典型函数printf/fprintf(内部使用锁)、malloc/free(锁 + 堆状态可能损坏)、strlen(部分 glibc 实现不安全)、任何 pthread_mutex_lock、任何 I/O 流操作、longjmp/siglongjmp(除非极其小心)等。

最常见的信号处理 BUG 就是写 printf("got signal\n")。它在压力测试下往往正常工作,一旦生产环境高并发触发,极易死锁或崩溃。正确的做法是:仅用 write(STDERR_FILENO, ...) 写入固定字符串,或将信息通过管道/套接字交给主循环处理。

4. SIGKILL 与 SIGSTOP

SIGKILL(信号 9)和 SIGSTOP(信号 19)是 Linux 信号体系中唯二不能被捕获、阻塞或忽略的信号。

这一限制源于内核层面的硬编码实现。当进程收到 SIGKILL 时,内核直接将其状态置为 EXIT_DEAD,跳过所有用户态处理逻辑;SIGSTOP 则将进程移入停止队列。这是保证系统管理员拥有最终控制权的必要机制——否则恶意程序完全可以注册空处理函数拒绝退出。

然而,SIGKILL 是核武器,应最后才使用。被 SIGKILL 终止的进程:

  • 信号处理函数、atexit 注册的函数完全不会执行
  • 临时文件不会被清理
  • 打开的数据库连接不会优雅关闭
  • 正在写入的日志可能被截断
  • 子进程成为孤儿进程由 init 接管

最佳实践是:先用 SIGTERM 请求优雅关闭,给予合理超时(如 30 秒),若进程仍存活,再发送 SIGKILL

/* 典型服务关闭流程 */
kill(pid, SIGTERM);       /* 请求优雅退出 */
sleep(30);                /* 等待 */
if (kill(pid, 0) == 0) {  /* 进程是否仍存在 */
    kill(pid, SIGKILL);   /* 强制终止 */
}

systemd 的 TimeoutStopSec 正是按此逻辑实现:先 SIGTERM,超时后 SIGKILL

5. SIGCHLD 与僵尸进程

当子进程终止(正常退出或被信号杀死)或停止(收到 SIGSTOP/SIGTSTP)时,内核向父进程发送 SIGCHLD。父进程应在处理函数中调用 waitpid() 读取子进程退出状态,否则已终止子进程的 PID 和状态将滞留在进程表中,形成僵尸进程(Zombie)——占据少量内核资源,且 PID 无法被复用。

#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>

void sigchld_handler(int sig) {
    int saved_errno = errno;
    pid_t pid;
    int status;

    /* WNOHANG: 非阻塞,尽可能回收所有已终止子进程 */
    while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
        char buf[64];
        int n = snprintf(buf, sizeof(buf), "reaped child %d\n", (int)pid);
        write(STDERR_FILENO, buf, n);
    }
    errno = saved_errno;  /* 恢复 errno,避免覆盖被中断的系统调用错误码 */
}

注意关键细节:

  1. 使用 while 循环 + WNOHANG:因为多个子进程同时终止可能只产生一个 SIGCHLD(标准未保证信号排队)。
  2. 保存并恢复 errnowaitpid 可能修改 errno,若信号打断了正检查 errno 的代码,会导致逻辑错误。

在现代 Linux(glibc 2.26+)中,将 SIGCHLD 的处理显式置为 SIG_IGN,或设置 sigactionSA_NOCLDWAIT 标志,可使内核自动回收子进程,无需父进程 waitpid。但注意:这不会发送 SIGCHLD,因此不要混用两种模式。

6. 并发程序中的信号

信号的主要难点在于它是异步的。在多线程程序中,信号投递行为更加复杂:

  • 同步信号(如 SIGSEGVSIGFPE,由线程自身指令触发)投递给产生它的线程。
  • 异步信号(如 SIGINTSIGTERMSIGUSR1)投递给任意未阻塞该信号的线程。

Self-Pipe Trick

将异步信号转换为同步事件的经典方案。主程序创建一个管道,信号处理函数向管道写一个字节;主事件循环将管道读端加入 select/poll/epoll,从而在同一线程、正常代码路径中处理信号。

#include <signal.h>
#include <unistd.h>
#include <fcntl.h>

static int self_pipe[2];

void signal_handler(int sig) {
    int saved_errno = errno;
    char byte = (char)sig;
    write(self_pipe[1], &byte, 1);  /* 异步信号安全 */
    errno = saved_errno;
}

int setup_self_pipe(void) {
    if (pipe(self_pipe) == -1) return -1;
    fcntl(self_pipe[0], F_SETFL, O_NONBLOCK);
    fcntl(self_pipe[1], F_SETFL, O_NONBLOCK);

    struct sigaction sa;
    sa.sa_handler = signal_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    sigaction(SIGTERM, &sa, NULL);
    sigaction(SIGINT, &sa, NULL);
    return self_pipe[0];
}

/* 主循环中:
 *   将 self_pipe[0] 加入 epoll/poll 的监听集合
 *   可读时 read() 字节,根据值执行对应逻辑
 */

signalfd

Linux 2.6.22 引入 signalfd,将信号转化为文件描述符事件,彻底融入事件驱动架构:

#include <sys/signalfd.h>
#include <signal.h>
#include <unistd.h>

int setup_signalfd(void) {
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGTERM);
    sigaddset(&mask, SIGINT);
    sigaddset(&mask, SIGUSR1);

    /* 阻塞信号,使其只能通过 signalfd 接收 */
    sigprocmask(SIG_BLOCK, &mask, NULL);

    int fd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
    return fd;
}

/* 主循环中 read(signalfd, &siginfo, sizeof(siginfo)) */

signalfd 的优势在于彻底绕过了信号处理函数的限制,读取操作发生在正常事件循环中,可安全调用任何函数。它是 modern Linux 服务端程序处理信号的首选方案。

每线程信号掩码

多线程程序应使用 pthread_sigmask() 而非 sigprocmask() 来控制各线程的信号屏蔽字。通常策略是:在主线程创建其他线程之前阻塞所有异步信号,然后仅在一个专用线程中通过 sigwait()signalfd 统一处理,实现完全同步的信号管理。

sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGTERM);
sigaddset(&set, SIGINT);
pthread_sigmask(SIG_BLOCK, &set, NULL);

/* 工作线程继承阻塞掩码,不会收到信号 */
/* 专用线程: */
int sig;
sigwait(&set, &sig);  /* 同步等待,线程安全 */
handle_shutdown(sig);

7. 实践经验

守护进程信号处理

生产级守护进程的信号处理通常包含以下映射:

信号动作
SIGTERM优雅关闭:停止接受新连接,等待活跃请求完成,释放资源,退出
SIGINT开发/调试时等同于 SIGTERM,或立即退出(视场景)
SIGHUP重载配置文件、重新打开日志文件(logrotate 配套)
SIGUSR1切换调试日志级别、dump 内部统计信息
SIGUSR2自定义运维操作(如强制 GC、连接池健康检查)
SIGPIPE通常忽略,避免网络写入时未读端关闭导致进程崩溃

完整示例:服务端信号处理

#define _GNU_SOURCE
#include <signal.h>
#include <sys/signalfd.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <errno.h>

static volatile sig_atomic_t should_exit = 0;
static volatile sig_atomic_t reload_config = 0;

/* 简单场景下用 volatile sig_atomic_t 原子标志也可以 */
void basic_handler(int sig) {
    if (sig == SIGTERM || sig == SIGINT)
        should_exit = 1;
    else if (sig == SIGHUP)
        reload_config = 1;
}

/* 可靠方案:signalfd + 事件循环 */
int main(void) {
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGTERM);
    sigaddset(&mask, SIGINT);
    sigaddset(&mask, SIGHUP);
    sigaddset(&mask, SIGUSR1);

    if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1) {
        perror("sigprocmask");
        return 1;
    }

    int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
    if (sfd == -1) {
        perror("signalfd");
        return 1;
    }

    /* 将 sfd 加入 epoll/poll,此处简化为循环 read */
    struct signalfd_siginfo fdsi;
    while (!should_exit) {
        ssize_t s = read(sfd, &fdsi, sizeof(fdsi));
        if (s == -1) {
            if (errno == EAGAIN || errno == EINTR)
                continue;
            perror("read");
            break;
        }

        switch (fdsi.ssi_signo) {
        case SIGTERM:
        case SIGINT:
            should_exit = 1;
            break;
        case SIGHUP:
            reload_config = 1;
            break;
        case SIGUSR1:
            /* dump stats */
            break;
        }

        if (reload_config) {
            reload_config = 0;
            /* 安全地重载配置 */
        }
    }

    /* 优雅关闭资源 */
    close(sfd);
    return 0;
}

关键要点回顾

  1. 永远使用 sigaction(),不要使用 signal()
  2. 信号处理函数中只调用异步信号安全函数,printfmalloc 是陷阱。
  3. SIGKILL 是最终手段,先用 SIGTERM 请求优雅退出。
  4. 回收子进程时要在 SIGCHLD 处理函数中循环调用 waitpid(-1, NULL, WNOHANG)
  5. 复杂服务优先使用 signalfdself-pipe trick 将异步信号同步化。
  6. 多线程程序使用 pthread_sigmask + 专用信号线程,避免信号投递到随机工作线程。
  7. 处理信号时务必保存和恢复 errno

掌握 Linux 信号机制不仅是写出可靠系统程序的基础,更是理解操作系统进程间通信和内核中断模型的关键一环。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 进程与线程:从 PCB 到内核调度实体
  2. 虚拟内存与分页机制:从 MMU 到 TLB
  3. 系统性能诊断与调优:strace、perf、bpftrace