Linux系统编程(四)——进程信号
1. 前言上一篇讲进程间通信时通信双方都是约定好再交流管道要先建立共享内存要先挂接数据的收发时机是可控的。但还有一种通知完全不受进程控制——无论进程此刻执行到哪一行代码按下 Ctrl-C 它都会终止。这种异步通知机制就是信号Signal它是 Linux 中进程之间事件异步通知的一种方式。本章主要目标理解信号的概念与生命周期掌握信号递达时的三种处理方式。理解信号在内核中的表示三张表掌握信号集操作与 sigprocmask 的使用。掌握 sigaction 信号捕捉并通过 volatile 与 SIGCHLD 两个典型应用理解信号的实际用途。2. 信号的基本概念2.1 信号是什么一句话定义信号是进程之间事件异步通知的一种方式属于软中断。两个关键词异步信号什么时候到来进程无法预测它相对于进程自身的控制流是异步发生的。进程执行到任何一行代码时都可能收到信号。软中断信号是软件层面模拟中断机制的产物区别于键盘、网卡等外设触发的硬件中断。比如按下 Ctrl-C 时键盘输入先通过硬件中断到达操作系统操作系统再把它解释成信号发给前台进程。信号本身只携带一个编号告诉进程某件事发生了不传输业务数据。 使用kill -l可以查看系统支持的全部信号kill -l # 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP # ...131 号是普通信号3464 号是实时信号本章重点讨论普通信号。查看信号详细说明可以执行man 7 signal。2.2 信号的生命周期一个信号从产生到处理完毕经历三个阶段产生 → 未决Pending → 递达Delivery未决Pending信号已经产生但还没有被处理处于等待状态。这段时间里信号被记录在进程的 PCB 中。递达Delivery实际执行信号的处理动作。信号递达时有三种处理方式三选一执行默认动作SIG_DFL每个信号出厂自带的处理行为大多数信号的默认动作是终止进程。忽略此信号SIG_IGN递达后什么都不做。自定义捕捉注册一个处理函数内核在递达该信号时调用它。这是回调机制handler函数不是main主动调用而是操作系统触发调用。2.3 常见信号汇总编号名称触发场景默认动作2SIGINTCtrl-C终止进程3SIGQUITCtrl-\终止 Core Dump8SIGFPE除零等算术异常终止 Core Dump9SIGKILLkill -9终止进程11SIGSEGV野指针等非法内存访问终止 Core Dump13SIGPIPE管道读端关闭后继续写终止进程14SIGALRMalarm 设定的闹钟到点终止进程17SIGCHLD子进程终止时通知父进程忽略19SIGSTOPkill -19暂停进程重点SIGKILL(9) 和 SIGSTOP(19) 比较特殊不能被捕捉、不能被阻塞、不能被忽略后面会解释底层原因。2.4 阻塞与忽略的区别进程可以选择**阻塞Block**某个信号被阻塞的信号产生后会一直停留在未决状态直到解除阻塞才能递达。一句话区分阻塞是信号还没递达就被拦住不许进门忽略是信号已经递达后选择不处理进门了装作没看见。 阻塞决定信号能不能被处理忽略只是递达之后的一种处理策略。3. 信号的产生信号共有四种产生方式终端按键、系统调用、软件条件、硬件异常。3.1 终端按键终端按键产生的信号只会发送给前台进程。命令末尾加可以把进程放到后台运行后台进程无法接收键盘信号只能使用kill命令发送信号。按键信号默认动作Ctrl-CSIGINT(2)终止Ctrl-\SIGQUIT(3)终止 Core DumpCtrl-ZSIGTSTP(20)暂停3.2 系统调用产生信号#include signal.h int kill(pid_t pid, int sig); // 向指定进程发信号kill 命令的底层实现 int raise(int sig); // 向自己发信号等价于 kill(getpid(), sig) #include stdlib.h void abort(void); // 向自己发 SIGABRT(6)异常终止进程3.3 软件条件产生信号程序运行满足软件条件内核自动发送信号属于软件层面触发。 典型例子SIGPIPE管道读端关闭写端继续调用write写数据内核向写进程发送SIGPIPE(13)默认终止进程。SIGALRM闹钟信号#include unistd.h unsigned int alarm(unsigned int seconds); // 告诉内核 seconds 秒后向自己发 SIGALRM(14) // 返回值0或上一个闹钟的剩余秒数alarm(0) 取消之前的闹钟3.4 硬件异常产生信号CPU/MMU检测到硬件异常内核捕获异常翻译成信号发送给当前进程整数除以 0CPU运算单元检测产生 SIGFPE(8)访问非法内存解引用空指针、越界访问MMU检测页表异常产生 SIGSEGV(11)核心结论C/C里面遇到的除零、内存越界崩溃底层都是硬件异常被内核转为信号处理。程序崩溃链路MMU发现非法访问 → 通知内核 → 内核向进程发送SIGSEGV → 默认动作终止进程。4. 信号在内核中的表示4.1 内核三张表信号能否被处理、是否已经产生、如何处理全部记录在进程PCBtask_struct内部的三张表block 表阻塞信号集 pending 表未决信号集 handler 表处理动作 1 0 0 1 ... 0 1 0 0 ... SIG_DFL / SIG_IGN / 函数指针三条核心规则信号产生时内核把 pending 表对应bit置1信号递达处理完成后该bit清零。发送信号的本质仅仅是修改pending位图的bit位没有任何数据拷贝。如果信号被block阻塞就算信号产生pending置1也停留在未决状态不会递达。handler处理动作在信号到来之前就提前登记进程预先知道每个信号该如何处理。三张表底层都是位图数据类型为sigset_t不能直接用普通位运算操作必须调用系统提供的信号集函数。4.2 信号集操作函数函数功能sigemptyset清空信号集全部置0sigfillset将信号集全部bit置1sigaddset向集合中添加指定信号sigdelset从集合中删除指定信号sigismember判断指定信号是否在集合中存在返回1不存在返回04.3 sigprocmask 与 sigpending#include signal.h int sigprocmask(int how, const sigset_t *set, sigset_t *oset); // 读写block阻塞信号集 // set不为空根据how参数修改阻塞掩码oset不为空输出修改前旧的阻塞信号集 int sigpending(sigset_t *set); // 读取pending未决信号集把当前未决信号放入sethow参数三种取值how含义SIG_BLOCK将set里的信号追加到阻塞集按位或新增阻塞SIG_UNBLOCK从阻塞集中解除set内信号的阻塞SIG_SETMASK使用set整体直接替换当前阻塞掩码4.4 实验阻塞 SIGINT 观察未决信号// block_sig.c #include stdio.h #include signal.h #include unistd.h void printpending(sigset_t *set) { for (int i 1; i 31; i) { if (sigismember(set, i)) putchar(1); else putchar(0); } putchar(\n); } int main() { sigset_t iset, oset, pset; sigemptyset(iset); sigaddset(iset, SIGINT); // 集合中加入 SIGINT sigprocmask(SIG_BLOCK, iset, oset); // 阻塞 SIGINT while (1) { sigpending(pset); // 读取未决信号集并打印 printpending(pset); sleep(1); } return 0; }编译运行gcc block_sig.c -o block_sig ./block_sig # 0000000000000000000000000000000 # 0000000000000000000000000000000 # 按下 Ctrl-C 后 # 0100000000000000000000000000000 # 0100000000000000000000000000000按下Ctrl-CSIGINT对应的bit变为1并保持不变信号已经产生pending标记置1但是被阻塞无法递达。此时按下Ctrl-\发送SIGQUIT该信号没有被阻塞正常递达终止进程。5. 信号的捕捉5.1 signal 简易接口#include signal.h void (*signal(int signum, void (*handler)(int)))(int); // 用法signal(信号编号, 处理函数) // 处理函数原型必须是 void func(int)捕捉SIGINT示例代码// catch_sig.c #include stdio.h #include signal.h void handler(int sig) { printf(catch a sig : %d\n, sig); } int main() { signal(2, handler); while (1); return 0; }gcc catch_sig.c -o catch_sig ./catch_sig ^Ccatch a sig : 2 ^Ccatch a sig : 2捕捉后Ctrl-C不再终止进程每次按下触发handler打印。此时只能用Ctrl-\退出因为SIGQUIT没有注册捕捉函数执行默认动作终止。5.2 硬件异常捕捉实验// segv_sig.c #include stdio.h #include signal.h #include unistd.h void handler(int sig) { printf(catch a sig : %d\n, sig); } int main() { signal(SIGSEGV, handler); sleep(1); int *p NULL; *p 100; // 对空指针写入触发 SIGSEGV while (1); return 0; }gcc segv_sig.c -o segv_sig ./segv_sig # catch a sig : 11 # catch a sig : 11 # ...无限刷屏现象分析捕捉SIGSEGV之后进程不会退出持续循环打印信号编号。 原因handler执行结束后CPU回到触发异常的那条非法指令*p100重新执行。内存非法访问问题本身没有修复指令再次执行再次触发SIGSEGV形成死循环。重点信号捕捉只是接住通知不会自动修复异常。想要跳出需要在handler内部使用非局部跳转longjmp跳出错误代码。5.3 sigaction推荐标准接口signal接口简单但是存在跨平台行为差异正式开发优先使用posix标准接口sigaction。#include signal.h int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact); struct sigaction { void (*sa_handler)(int); // 处理动作SIG_DFL / SIG_IGN / 自定义函数 sigset_t sa_mask; // handler执行期间额外屏蔽的信号集 int sa_flags; // 选项一般置0 };使用示例struct sigaction act; act.sa_handler handler; sigemptyset(act.sa_mask); // sa_mask使用前必须初始化清空 act.sa_flags 0; sigaction(SIGINT, act, NULL);两个核心细节handler执行的时候当前触发的信号会自动屏蔽防止递归触发sa_mask用来指定handler运行期间额外要屏蔽的其他信号。handler函数返回后阻塞信号集自动恢复。自定义handler运行在用户态和main属于同一个进程没有函数调用关系是两条独立控制流栈空间互相独立。5.4 SIGKILL 和 SIGSTOP 不能被捕捉SIGKILL(9) 和 SIGSTOP(19)无法捕捉、无法阻塞、无法忽略。 设计原因操作系统必须保留对进程的最高管控权限。如果进程可以屏蔽所有信号恶意程序就会拒绝被终止操作系统失去控制权系统卡死。kill -9永远可以杀掉正常存活的进程就是这个原理。6. 两个典型应用6.1 volatile保持内存可见性// volatile_sig.c #include stdio.h #include signal.h int flag 0; // 修改为 volatile int flag 0; void handler(int sig) { flag 1; } int main() { signal(2, handler); while (!flag); printf(process quit normal\n); return 0; }编译运行开启O2优化gcc -O2 volatile_sig.c -o volatile_sig ./volatile_sig # 按下Ctrl-Chandler执行但是循环不会退出 # 修改为 volatile int flag 0; 重新编译按下Ctrl-C程序正常退出原理编译器优化时发现main函数内部没有修改flag把flag缓存到CPU寄存器。while循环每次读取寄存器副本永远是0但是handler修改的是内存中的flag寄存器没有同步更新循环无法退出。volatile作用保持内存可见性告诉编译器该变量读写必须访问内存禁止缓存到寄存器。注意volatile只保证可见性不保证原子性i这种复合操作依旧需要加锁保证线程安全。6.2 SIGCHLD异步回收子进程回收子进程传统两种方案阻塞wait父进程卡住等待、轮询非阻塞waitpid不断循环查询浪费CPU。 借助SIGCHLD信号子进程退出时内核主动发送SIGCHLD通知父进程父进程在信号handler里面完成回收不阻塞业务逻辑。// wait_sig.c #include stdio.h #include stdlib.h #include unistd.h #include signal.h #include sys/wait.h void handler(int sig) { pid_t id; // 必须while不能if普通信号不排队多个子进程同时退出只会置一次pending位 // while循环一次性把所有已经退出的子进程全部回收干净 while ((id waitpid(-1, NULL, WNOHANG)) 0) { printf(wait child success: %d\n, id); } } int main() { signal(SIGCHLD, handler); pid_t cid fork(); if (cid 0) { // 子进程3 秒后退出 sleep(3); exit(1); } while (1) { // 父进程继续执行自己的业务任务 printf(father working...\n); sleep(1); } return 0; }gcc wait_sig.c -o wait_sig ./wait_sig # father working... # father working... # father working... # wait child success: 7340 # father working...关键点使用waitpid(-1, NULL, WNOHANG)非阻塞回收没有可回收子进程立刻返回0避免handler卡住。必须while循环普通信号pending只是位图多个子进程同时退出只会记录一次信号if只能回收一个子进程while保证全部僵尸子进程被回收。7. 本章小结信号是进程之间事件异步通知的一种方式属于软中断生命周期产生 → 未决Pending→ 递达Delivery。三种处理方式SIG_DFL 默认动作、SIG_IGN 忽略、自定义函数捕捉阻塞发生在递达之前拦截信号忽略是递达之后选择不处理二者阶段不同。信号4种产生方式终端按键、系统调用、软件条件、硬件异常C/C除零、内存越界底层都会转化为信号。内核依靠PCB内block/pending/handler三张表管理信号发送信号本质是修改pending位图对应bit无数据拷贝。sigaction捕捉信号handler运行在用户态与main是两条独立控制流handler执行期间当前信号自动屏蔽SIGKILL(9)与SIGSTOP(19)不可捕捉、阻塞、忽略。volatile保证内存可见性阻止编译器把变量缓存寄存器SIGCHLD配合whilewaitpid(WNOHANG)异步回收子进程避免僵尸进程。