1. 信号基础
Linux 信号(signal)是操作系统向进程发送的软件中断通知机制。当特定事件发生时——用户按下 Ctrl-C、子进程退出、进程访问无效内存、管道写入端关闭——内核向目标进程的进程控制块(PCB)中记录一个待处理的信号。与硬件中断不同,信号完全在软件层面传递,但同样具有异步特性:进程无法精确预知信号何时到达。
标准信号
Linux 遵循 POSIX 标准定义了多种信号,可通过 kill -l 列出。以下是其中最关键的几类:
| 信号 | 编号 | 默认动作 | 说明 |
|---|---|---|---|
SIGHUP | 1 | 终止 | 终端挂起或控制进程死亡,常用于通知守护进程重载配置 |
SIGINT | 2 | 终止 | 用户按下 Ctrl-C 产生的中断信号 |
SIGQUIT | 3 | Core | Ctrl-\,终止并产生 core dump |
SIGILL | 4 | Core | 非法指令 |
SIGABRT | 6 | Core | 调用 abort() 产生 |
SIGFPE | 8 | Core | 浮点异常/算术错误 |
SIGKILL | 9 | 终止 | 强制终止,不可捕获、不可忽略 |
SIGSEGV | 11 | Core | 段错误,非法内存访问 |
SIGPIPE | 13 | 终止 | 向无读取端的管道/socket 写入 |
SIGALRM | 14 | 终止 | alarm()/setitimer() 定时器到期 |
SIGTERM | 15 | 终止 | 优雅终止请求(kill 默认信号) |
SIGCHLD | 17 | 忽略 | 子进程停止或终止 |
SIGSTOP | 19 | 停止 | 暂停进程,不可捕获、不可忽略 |
SIGCONT | 18 | 继续 | 恢复被 SIGSTOP/SIGTSTP 停止的进程 |
SIGUSR1 | 10 | 终止 | 用户自定义信号 |
SIGUSR2 | 12 | 终止 | 用户自定义信号 |
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) —— 信号并非立即执行处理函数。内核在以下时机检查并投递待处理信号:
- 从系统调用返回到用户空间之前
- 从异常/中断处理返回到用户空间之前
- 进程从内核态切换到用户态时
这意味着若进程始终在用户态运行从未陷入内核,信号不会立即得到处理。一个长时纯计算循环中的 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_handler或sa_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,避免覆盖被中断的系统调用错误码 */
}
注意关键细节:
- 使用
while循环 +WNOHANG:因为多个子进程同时终止可能只产生一个SIGCHLD(标准未保证信号排队)。 - 保存并恢复
errno:waitpid可能修改errno,若信号打断了正检查errno的代码,会导致逻辑错误。
在现代 Linux(glibc 2.26+)中,将 SIGCHLD 的处理显式置为 SIG_IGN,或设置 sigaction 的 SA_NOCLDWAIT 标志,可使内核自动回收子进程,无需父进程 waitpid。但注意:这不会发送 SIGCHLD,因此不要混用两种模式。
6. 并发程序中的信号
信号的主要难点在于它是异步的。在多线程程序中,信号投递行为更加复杂:
- 同步信号(如
SIGSEGV、SIGFPE,由线程自身指令触发)投递给产生它的线程。 - 异步信号(如
SIGINT、SIGTERM、SIGUSR1)投递给任意未阻塞该信号的线程。
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;
}
关键要点回顾
- 永远使用
sigaction(),不要使用signal()。 - 信号处理函数中只调用异步信号安全函数,
printf和malloc是陷阱。 SIGKILL是最终手段,先用SIGTERM请求优雅退出。- 回收子进程时要在
SIGCHLD处理函数中循环调用waitpid(-1, NULL, WNOHANG)。 - 复杂服务优先使用
signalfd或self-pipe trick将异步信号同步化。 - 多线程程序使用
pthread_sigmask+ 专用信号线程,避免信号投递到随机工作线程。 - 处理信号时务必保存和恢复
errno。
掌握 Linux 信号机制不仅是写出可靠系统程序的基础,更是理解操作系统进程间通信和内核中断模型的关键一环。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。