关键词:Linux信号、信号处理、进程通信、sigaction、可重入函数、信号掩码、信号生命周期、优雅退出、竞态条件、core dump
摘要:本文以“生活中的紧急通知”为类比,用通俗易懂的语言拆解Linux信号处理的核心机制。通过10个程序员必须掌握的关键点,结合代码示例和生活案例,帮你彻底理解信号的生成、传递、处理全流程,掌握编写健壮信号处理逻辑的实战技巧。
在Linux系统中,信号(Signal)是进程间通信(IPC)的“轻量级使者”,它能在不占用大量系统资源的情况下,快速通知进程“发生了什么事”。无论是用户按下Ctrl+C
终止程序,还是程序运行中出现段错误(如访问空指针),背后都是信号机制在起作用。本文将覆盖信号处理的核心知识,从基础概念到实战技巧,帮你写出更健壮的Linux程序。
Ctrl+C
背后的原理)本文以“信号的一生”为主线,拆解10个关键知识点,包含:信号的本质、常见类型、处理方式、核心函数(sigaction
)、注意事项(可重入函数)等。每部分均配有生活类比和代码示例。
术语 | 解释(小学生版) |
---|---|
信号(Signal) | 系统给进程发的“紧急通知”,比如“用户想终止你”(SIGINT)、“你出错了”(SIGSEGV)等。 |
信号处理函数 | 进程收到通知后执行的“应对方案”,比如收到“终止通知”时先保存数据再退出。 |
信号掩码 | 进程的“免打扰列表”,暂时不想处理的信号会被“屏蔽”,之后再处理。 |
可重入函数 | 可以“同时被多个人使用”的函数,即使在信号处理中调用也不会出问题(如write )。 |
竞态条件 | 两个“事件”同时发生时可能引发的混乱,比如信号处理和主程序同时修改同一个变量。 |
想象你是一个在办公室工作的“进程小助手”,每天专注处理任务(执行代码)。突然,前台阿姨(Linux内核)跑过来喊:“有你的紧急通知!”这个通知可能是:
Ctrl+C
,对应SIGINT)。你收到通知后有三种选择:
这就是Linux信号的核心逻辑——进程通过信号接收系统/其他进程的通知,并决定如何响应。
信号是Linux系统定义的整数编号事件(比如SIGINT=2,SIGTERM=15),每个编号对应一种“通知类型”。内核负责将信号“投递”到目标进程,就像快递员按地址(进程ID)送快递。
信号的一生有三个阶段:
Ctrl+C
)或其他进程(如kill
命令)触发;进程收到信号后,有三种处理方式:
signal(SIGINT, SIG_IGN)
忽略信号;void handler(int sig)
),信号到来时执行它。信号的“生成-传递-处理”就像“快递的一生”:
信号生成 → 内核检查进程权限 → 标记进程的待处理信号 → 进程从内核态返回时处理信号 → 执行处理逻辑(忽略/默认/自定义)
Linux定义了60多种信号(kill -l
查看),但常用的只有10多个。我们按“用途”分类:
信号名 | 编号 | 默认行为 | 典型触发场景 | 记忆口诀 |
---|---|---|---|---|
SIGINT | 2 | 终止进程 | 用户按Ctrl+C |
“终止我” |
SIGTERM | 15 | 终止进程 | kill 命令默认发送 |
“温柔终止” |
SIGKILL | 9 | 强制终止进程 | kill -9 (无法被忽略/捕获) |
“必杀技” |
SIGSEGV | 11 | 终止+生成core | 访问非法内存(如空指针) | “段错误,快崩溃” |
SIGALRM | 14 | 终止进程 | alarm() 函数定时到期 |
“闹钟响了” |
SIGCHLD | 17 | 忽略 | 子进程状态变化(退出/暂停) | “孩子有情况” |
SIGUSR1 | 10 | 终止进程 | 用户自定义(如通知重启) | “自定义通知1” |
SIGUSR2 | 12 | 终止进程 | 用户自定义(如通知刷新配置) | “自定义通知2” |
记忆技巧:记住前5个(SIGINT/SIGTERM/SIGKILL/SIGSEGV/SIGALRM),覆盖90%的日常场景。
每个信号都有默认处理方式,常见的有:
Ctrl+Z
触发);例子:程序访问空指针时触发SIGSEGV,默认行为是“Term+Core”,所以系统会终止进程并生成core
文件(用gdb core
可调试)。
要让进程对信号做出自定义响应,需要注册信号处理函数。Linux提供了两个函数:
signal()
(不推荐,行为依赖系统);sigaction()
(更可靠,推荐使用)。代码示例(用sigaction注册SIGINT处理函数):
#include
#include
#include
// 自定义信号处理函数
void handle_sigint(int sig) {
printf("\n收到SIGINT(Ctrl+C),不退出!再按一次才退出\n");
// 重新注册默认处理,下次按Ctrl+C会终止进程
signal(SIGINT, SIG_DFL);
}
int main() {
struct sigaction sa;
sa.sa_handler = handle_sigint; // 指定处理函数
sigemptyset(&sa.sa_mask); // 处理信号时不阻塞其他信号
sa.sa_flags = 0; // 无特殊标志
// 注册SIGINT信号的处理方式
sigaction(SIGINT, &sa, NULL);
while (1) {
printf("运行中... 按Ctrl+C试试\n");
sleep(1);
}
return 0;
}
运行效果:第一次按Ctrl+C
会触发自定义函数(打印提示),第二次按会触发默认行为(终止进程)。
有时进程需要暂时“屏蔽”某些信号(比如在关键代码段不希望被打断),这时可以设置信号掩码(Signal Mask)。被屏蔽的信号不会立即处理,而是进入“待处理列表”,等掩码解除后再处理。
生活类比:开会时把手机调为“免打扰”(屏蔽SIGINT),会后再查看未接通知(处理待处理信号)。
关键函数:
sigprocmask()
:修改当前进程的信号掩码;sigpending()
:检查待处理的信号。代码示例(屏蔽SIGINT 5秒):
#include
#include
#include
void handle_sigint(int sig) {
printf("收到SIGINT,但现在在屏蔽期,稍后处理\n");
}
int main() {
struct sigaction sa;
sa.sa_handler = handle_sigint;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
// 定义要屏蔽的信号集
sigset_t block_set;
sigemptyset(&block_set);
sigaddset(&block_set, SIGINT); // 屏蔽SIGINT
// 应用屏蔽(保存旧掩码)
sigset_t old_set;
sigprocmask(SIG_BLOCK, &block_set, &old_set);
printf("开始屏蔽SIGINT,5秒内按Ctrl+C无效...\n");
sleep(5);
printf("解除屏蔽,处理待处理的SIGINT...\n");
sigprocmask(SIG_SETMASK, &old_set, NULL); // 恢复旧掩码
while (1) {
sleep(1);
}
return 0;
}
运行效果:运行后按Ctrl+C
无反应(被屏蔽),5秒后解除屏蔽,之前积累的SIGINT会被处理(触发handle_sigint
)。
信号处理函数可能在任何时刻被触发(比如主程序正在调用malloc
时),如果处理函数中调用了不可重入函数(如malloc
、printf
),可能导致数据混乱(因为这些函数内部用了全局变量)。
可重入函数定义:可以被多个执行流(主程序、信号处理函数)同时调用而不引发错误的函数。
常见可重入函数:write
、read
、close
、sigaction
(基本是无全局状态的函数);
常见不可重入函数:printf
(用了全局IO缓冲区)、malloc
(用了全局内存管理结构)、strtok
(用了静态变量)。
血的教训:在信号处理函数中调用printf
可能导致输出混乱(主程序和处理函数同时写缓冲区)。
早期UNIX信号(编号1-31,非实时信号)是“不可靠”的:
Linux的实时信号(编号34-64)是“可靠”的:
SIGRTMAX - SIGRTMIN +1
次);总结:开发中尽量用实时信号(如SIGRTMIN+1)处理需要精确传递的场景,非实时信号(如SIGINT)用于简单通知。
信号可能在主程序执行到任意位置时触发,若处理函数和主程序共享变量(如flag
),可能引发竞态条件(Race Condition)。
例子:主程序正在检查flag
是否为1时,信号处理函数修改了flag
,导致主程序读到错误的值。
解决方案:
volatile
修饰共享变量(告诉编译器不要优化,每次从内存读取);__sync_add_and_fetch
)修改变量;代码示例(安全共享变量):
#include
#include
#include
volatile int flag = 0; // 用volatile防止编译器优化
void handle_sigint(int sig) {
flag = 1; // 原子操作(int是原子的,在x86架构下)
}
int main() {
struct sigaction sa;
sa.sa_handler = handle_sigint;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGINT, &sa, NULL);
while (!flag) {
printf("运行中...\n");
sleep(1);
}
printf("收到终止信号,退出\n");
return 0;
}
关键点:flag
用volatile
修饰,确保主循环每次读取最新值;修改flag
是原子操作(int在大多数架构下是原子的)。
子进程通过fork()
创建时,会继承父进程的信号处理方式(除了SIGCHLD
默认是忽略)。但子进程终止时会向父进程发送SIGCHLD
信号,父进程需要处理该信号以避免“僵尸进程”。
处理SIGCHLD的正确方式:
#include
#include
#include
#include
void handle_sigchld(int sig) {
int status;
pid_t pid;
// 循环等待所有子进程(避免遗漏)
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
printf("子进程%d退出,状态:%d\n", pid, WEXITSTATUS(status));
}
}
int main() {
struct sigaction sa;
sa.sa_handler = handle_sigchld;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // 自动重启被信号中断的系统调用
sigaction(SIGCHLD, &sa, NULL);
pid_t pid = fork();
if (pid == 0) {
// 子进程
sleep(2);
return 42; // 退出码42
} else {
// 父进程
while (1) {
sleep(1);
}
}
return 0;
}
关键点:
waitpid(-1, &status, WNOHANG)
:等待任意子进程(-1),非阻塞(WNOHANG);SA_RESTART
:让被信号中断的系统调用(如read
)自动重启;waitpid
:防止多个子进程同时退出时漏处理。信号处理中常见的问题:
调试工具:
strace
:跟踪进程收到的信号(strace -e signal ./program
);gdb
:设置信号断点(handle SIGINT stop
,中断时查看调用栈);ps -ef | grep Z
:检查僵尸进程(状态为Z的进程)。例子:用strace
查看程序是否收到SIGINT:
$ strace -e signal ./a.out
运行中... 按Ctrl+C试试
--- SIGINT {si_signo=SIGINT, si_code=SI_USER, si_pid=1234, si_uid=1000} ---
收到SIGINT(Ctrl+C),不退出!再按一次才退出
生产环境中,程序需要在收到终止信号(如SIGTERM)时:
代码示例(HTTP服务器的优雅退出):
#include
#include
#include
volatile int running = 1;
void handle_sigterm(int sig) {
running = 0;
printf("收到SIGTERM,准备优雅退出...\n");
}
int main() {
struct sigaction sa;
sa.sa_handler = handle_sigterm;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGTERM, &sa, NULL);
while (running) {
printf("处理请求中...\n");
sleep(1);
}
printf("保存状态...\n");
sleep(2); // 模拟保存操作
printf("退出成功\n");
return 0;
}
运行效果:
$ ./a.out
处理请求中...
处理请求中...
# 另一个终端执行:kill -15 <进程ID>
处理请求中...
收到SIGTERM,准备优雅退出...
保存状态...
退出成功
gcc
(sudo apt install gcc
);vim
或VS Code。目标:程序启动后,每隔5秒打印“工作中…”,收到SIGALRM信号时提醒“该休息了!”。
#include
#include
#include
// 闹钟信号处理函数
void handle_alarm(int sig) {
printf("\n叮!该休息了!\n");
alarm(5); // 重新设置5秒后触发SIGALRM
}
int main() {
// 注册SIGALRM处理函数
struct sigaction sa;
sa.sa_handler = handle_alarm;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
sigaction(SIGALRM, &sa, NULL);
alarm(5); // 首次设置5秒后触发SIGALRM
while (1) {
printf("工作中... ");
fflush(stdout); // 刷新输出缓冲区(否则可能看不到即时打印)
sleep(1);
}
return 0;
}
alarm(5)
:设置5秒后向自身发送SIGALRM信号;handle_alarm
函数:收到SIGALRM时打印提醒,并再次调用alarm(5)
(循环触发);fflush(stdout)
:printf
默认行缓冲(遇到换行才输出),用fflush
强制刷新,确保“工作中…”即时显示。运行效果:
工作中... 工作中... 工作中... 工作中... 工作中...
叮!该休息了!
工作中... 工作中... 工作中... 工作中... 工作中...
叮!该休息了!
...
场景 | 适用信号 | 处理方式 | 目标 |
---|---|---|---|
守护进程优雅退出 | SIGTERM | 自定义处理(保存状态、关闭连接) | 避免数据丢失 |
捕获程序崩溃(如空指针) | SIGSEGV | 自定义处理(生成core dump) | 调试崩溃原因 |
定时任务(如日志切割) | SIGALRM | 自定义处理(调用切割函数) | 定期执行任务 |
通知进程刷新配置 | SIGUSR1 | 自定义处理(重读配置文件) | 动态更新配置 |
避免僵尸进程 | SIGCHLD | 自定义处理(waitpid 回收子进程) |
清理子进程资源 |
工具/资源 | 用途 | 链接/命令 |
---|---|---|
man 7 signal |
查看信号详细说明(类型、默认行为) | man 7 signal |
kill -l |
列出所有信号编号和名称 | 终端输入kill -l |
strace |
跟踪进程收到的信号 | strace -e signal ./app |
gdb |
调试信号处理函数(设置断点) | gdb ./app → handle SIGINT stop |
《Unix环境高级编程》 | 信号处理的经典教材 | 书籍或在线资源 |
sigaltstack
(信号栈),允许自定义信号处理的栈空间(避免栈溢出);pthread_sigmask
设置线程掩码)。sigaction
(注册处理函数)、sigprocmask
(设置掩码);信号的一生像“快递的一生”:
生成(用户/系统触发)→ 传递(内核投递)→ 处理(进程决定如何响应)。
关键是要掌握:如何注册处理函数、如何避免竞态条件、如何处理子进程信号。
SIGKILL
和SIGSTOP
不能被忽略或捕获?(提示:系统需要“终极手段”终止失控进程)exit()
会发生什么?(提示:主程序的资源可能未释放)SIGINT
时,先保存当前状态,再等待30秒后退出?(提示:用alarm
设置延迟退出)Q:为什么我的自定义处理函数没触发?
A:可能原因:
sigprocmask
检查掩码);SIGKILL
/SIGSTOP
(无法被捕获);sigaction
代替signal
)。Q:signal()
和sigaction()
有什么区别?
A:signal()
是旧版函数,行为依赖系统(如某些系统中处理函数执行期间信号不会被阻塞);sigaction()
更可控(可设置掩码、标志位),推荐使用。
Q:如何生成core dump?
A:确保:
ulimit -c unlimited
);Core
(如SIGSEGV);abort()
触发core)。