小叶-duck个人主页❄️个人专栏《Data-Structure-Learning》《C入门到进阶自我学习过程记录》《Linux操作系统从入门到实践》《Qt从入门到实践》《算法题讲解指南》--优选算法《算法题讲解指南》--递归、搜索与回溯算法《算法题讲解指南》--动态规划算法✨未择之路不须回头已择之路纵是荆棘遍野亦作花海遨游目录前言一、 信号捕捉自定义处理信号的完整流程1.1 什么叫内核态、什么叫做用户态1.2 为什么要区分这两种状态1.3 信号处理的流程1.3.1 信号捕捉的流程1.3.2 记忆信号捕捉流程1.4 话题穿插操作系统是怎么运行的附硬件中断时钟中断软中断……1.4.1 硬件中断1.4.2 时钟中断1.4.2.1 时钟中断机制1.4.2.2 时间戳​编辑1.4.2.3 时间片1.4.3 软中断1.4.4 缺页中断1.4.5 中断分类1.5 重谈内核态和用户态理解1.6 sigaction 函数推荐使用功能更强二、可重入函数 volatile SIGCHLD信号2.1 可重入函数2.1.1 如何判断可重入和不可重入2.1.2 场景应用的区别2.2 volatile2.2.1 核心概念什么是 volatile2.2.2 现象产生消失的信号响应2.2.3 现象原因CPU寄存器与内存的“可见性”冲突2.2.4 现象解决方案volatile 的作用2.2.5 为什么不默认给所有变量加 volatile2.3 SIGCHLD信号2.3.1 证明SIGCHLD信号的存在2.3.2 为什么要处理 SIGCHLD僵尸进程问题2.3.3 SIGCHLD 信号的作用2.3.4 最省事的方案SIG_IGN2.3.5 总结子进程回收的三种方案结束语前言在上篇文章中我们对信号的第二个个板块信号的保存进行了讲解并且对信号产生中的core文件进行了补充。在本篇文章中我们将会对信号的最后一个板块——信号捕捉进行讲解其中我们将会着重理解内核态和用户态并且我们还会补充一个额外的知识操作系统是如何运行的最后我们还会讲解可重入函数、Volatile关键字、SIGCHLD信号这三个知识点内容比较多我会尽量用通俗易懂的理解对这些知识点进行详细的讲解。一、 信号捕捉自定义处理信号的完整流程信号捕捉是指进程自定义信号的处理动作而非默认动作或忽略核心依赖 signal 或 sigaction 函数其中sigaction是更推荐的标准接口。信号收到不一定会立即递达而是在合适的时候。在什么合适的时候呢1、核心工作做完2、进程从内核态返回用户态的时候进行信号的检测和处理。1.1 什么叫内核态、什么叫做用户态用户执行自身代码——用户态访问操作系统内核结构的代码和数据——内核态。但是在原理上我们还是不太清楚用户态和内核态究竟是什么当我们把信号处理的知识讲解后还会重谈这两个状态。1.2 为什么要区分这两种状态1.3 信号处理的流程1.3.1 信号捕捉的流程当进程注册了自定义处理函数后信号递达的流程如下用户态↔内核态切换 进程在用户态执行主流程因中断 / 异常 / 系统调用进入内核态内核处理完中断后准备返回用户态前检查未决信号集若有可递达的信号未阻塞且有自定义处理函数内核不直接返回主流程而是切换到用户态执行自定义处理函数处理函数执行完毕后自动调用 sigreturn 系统调用再次进入内核态内核确认无其他未递达信号后返回用户态恢复主流程的执行。通过信号处理的流程中我们发现第一步到第二步时处理异常和处理信号的分开的两步为什么是这样的呢1.3.2 记忆信号捕捉流程或许一开始看到这个状态切换图的时候有些人会不清楚到底是什么个流程所以有一个比较好记忆这个信号捕捉流程的图这个东西是什么很像无穷大、极限的概念。交点处检测点检查并且处理信号。横线上四个交点四次状态切换 信号处理的流程就是倒着的8加一条横线但一定要注意横线是在交点的上方因为不难解释交点是检查pending表这个操作是在内核中完成的。通过上面的步骤说明我们会发现这个交点并不是单向的而是判断当前的情况进行流向。1.4 话题穿插操作系统是怎么运行的附硬件中断时钟中断软中断……1.4.1 硬件中断中断向量表就是操作系统的⼀部分启动就加载到内存中了通过外部硬件中断操作系统就不需要对外设进行任何周期性的检测或者轮询由外部设备触发的中断系统运行流程叫做硬件中断// Linux 内核 0.11 源码片段带详细注释 void trap_init(void) { int i; // 设置陷阱门trap gate用于处理 CPU 异常 // 除零错误 set_trap_gate(0, divide_error); // 单步调试 set_trap_gate(1, debug); // 非屏蔽中断 NMI set_trap_gate(2, nmi); // 以下 3-5 号陷阱门允许从任何特权级调用系统门 set_system_gate(3, int3); // int3 断点 set_system_gate(4, overflow); // 溢出 set_system_gate(5, bounds); // 边界检查 // 继续设置陷阱门 set_trap_gate(6, invalid_op); // 无效操作码 set_trap_gate(7, device_not_available); // 设备不可用 set_trap_gate(8, double_fault); // 双重故障 set_trap_gate(9, coprocessor_segment_overrun); // 协处理器段越界 set_trap_gate(10, invalid_TSS); // 无效 TSS set_trap_gate(11, segment_not_present); // 段不存在 set_trap_gate(12, stack_segment); // 栈段错误 set_trap_gate(13, general_protection); // 一般保护异常 set_trap_gate(14, page_fault); // 页错误 set_trap_gate(15, reserved); // 保留 set_trap_gate(16, coprocessor_error); // 协处理器错误 // 将 int 17~47 的陷阱门暂时都设置为 reserved保留处理 // 后续各个硬件初始化时会重新设置自己的中断向量 for (i 17; i 48; i) set_trap_gate(i, reserved); // 设置协处理器中断IRQ13中断号 45 set_trap_gate(45, irq13); // 允许主 8259A 芯片的 IRQ2 中断请求用于级联从片 outb_p(inb_p(0x21) 0xfb, 0x21); // 允许从 8259A 芯片的 IRQ13 中断请求协处理器 outb(inb_p(0xA1) 0xdf, 0xA1); // 设置并行口的陷阱门IRQ7 或 中断号 39具体取决于硬件 set_trap_gate(39, parallel_interrupt); } void rs_init(void) { // 设置串行口1的中断门硬件 IRQ4 对应中断号 0x24 set_intr_gate(0x24, rs1_interrupt); // 设置串行口2的中断门硬件 IRQ3 对应中断号 0x23 set_intr_gate(0x23, rs2_interrupt); // 初始化 tty 表对应串行口1和串行口2的读队列端口号 init(tty_table[1].read_q.data); // 串行口1 init(tty_table[2].read_q.data); // 串行口2 // 允许主 8259A 芯片的 IRQ3 和 IRQ4 中断请求分别对应串口2和串口1 outb(inb_p(0x21) 0xE7, 0x21); }1.4.2 时钟中断进程可以在操作系统的指挥下被调度被执行那么操作系统自己被谁指挥被谁推动执行呢回答操作系统不是一个独立的“进程”它本质上是硬件的“管家”。当用户程序运行时CPU 在执行用户代码只有发生硬件中断如时钟中断、键盘中断、异常如缺页或系统调用程序主动请求内核服务时CPU 才会切换到内核态执行操作系统代码。也就是说操作系统是靠硬件事件和程序主动调用来“唤醒”的。没有事件时CPU 会执行一个特殊的“空闲进程”idle什么都不做等待下一次中断。外部设备可以触发硬件中断但是这个是需要用户或者设备自己触发有没有自己可以定期触发的设备?回答有最常见的是定时器Timer。例如 PC 上的可编程间隔定时器PIT可以设置一个时间间隔每隔固定时间比如 10 毫秒自动向 CPU 发送一个时钟中断。操作系统利用这个中断来切换进程、统计时间等。这类设备不需要人为触发只要通电并按设定运行就会一直自己“滴答”地产生中断。// Linux 内核 0.11 // main.c sched_init(); // 调度程序初始化加载了任务0 的 tr, ldtr kernel/sched.c // 调度程序的初始化子程序。 void sched_init(void) { // ... 其他初始化代码 ... // 设置时钟中断门中断号 0x20对应硬件 IRQ0 set_intr_gate(0x20, timer_interrupt); // 修改中断控制器屏蔽码允许时钟中断清除 IRQ0 的屏蔽位 outb(inb_p(0x21) ~0x01, 0x21); // 设置系统调用中断门中断号 0x80int 0x80 用于系统调用 set_system_gate(0x80, system_call); // ... } // system_call.s 中的汇编代码片段 _timer_interrupt: // ... 保存现场等操作 ... ; // do_timer(CPL) 执行任务切换、计时等工作在 kernel/sched.c 第305行实现 call _do_timer // do_timer(long CPL) 负责所有定时器相关处理最终可能调用 schedule() // ... 恢复现场、iret 等 ... // 调度入口函数kernel/sched.c void do_timer(long cpl) { // ... 更新进程时间片、处理定时器等 ... schedule(); // 调用主调度函数 } // 进程调度函数 void schedule(void) { // ... 选择下一个要运行的任务next ... switch_to(next); // 切换到任务号为 next 的任务并运行之 }1.4.2.1 时钟中断机制OS的部分源码void main(void) /* 这里确实是void并没错。 */ { /* 在startup 程序(head.s)中就是这样假设的。 */ // ... 前面的初始化代码 ... /* * 注意!! 对于任何其它的任务pause()将意味着我们必须等待收到一个信号才会返 * 回就绪运行态但任务0task0是唯一的意外情况参见schedule()因为任 * 务0 在任何空闲时间里都会被激活当没有其它任务在运行时 * 因此对于任务0pause()仅意味着我们返回来查看是否有其它任务可以运行如果没 * 有的话我们就回到这里一直循环执行pause()。 */ for (;;) pause(); // 任务0进入空闲循环当没有其他任务时被激活不断检查是否有任务可运行 } // end main问题当操作系统空闲的情况下硬件时钟源还会一直工作吗是的1.4.2.2 时间戳1.4.2.3 时间片1.4.3 软中断上述外部硬件中断需要硬件设备触发。有没有可能因为软件原因也触发上面的逻辑有为了让操作系统支持进行系统调用CPU也设计了对应的汇编指令(int 或者 syscall),可以让CPU内部触发中断逻辑。用户层怎么把系统调用号给操作系统 - 寄存器(比如EAX)操作系统怎么把返回值给用户- 寄存器或者用户传入的缓冲区地址系统调用的过程其实就是先int 0x80、syscall陷入内核本质就是触发软中断CPU就会自动执行系统调用的处理方法而这个方法会根据系统调用号自动查表执行对应的方法系统调用号的本质数组下标// sys.h 中定义的系统调用函数指针表用于 int 0x80 中断处理程序跳转 extern int sys_setup(); // 系统启动初始化设置 extern int sys_exit(); // 程序退出 // ... 其他系统调用声明省略 ... // 系统调用函数指针表跳转表按系统调用号索引 fn_ptr sys_call_table[] { sys_setup, sys_exit, sys_fork, sys_read, sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link, sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod, sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount, sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm, sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access, sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir, sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid, sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys, sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit, sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid, sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask, sys_setreuid, sys_setregid }; // 调度程序初始化 void sched_init(void) { // ... // 设置系统调用中断门中断号 0x80入口为 system_call 汇编例程 set_system_gate(0x80, system_call); } // system_call.s 中的汇编代码简化 _system_call: // 检查系统调用号eax是否超出范围 cmp eax, nr_system_calls-1 ja bad_sys_call // 保存用户态段寄存器及参数 push ds push es push fs push edx // 第3个参数 push ecx // 第2个参数 push ebx // 第1个参数 // 设置 ds, es 指向内核数据段 mov edx, 10h mov ds, dx mov es, dx // 设置 fs 指向用户数据段局部描述符表 mov edx, 17h mov fs, dx // 通过系统调用号索引跳转表调用对应的 C 函数 // 等价于 call sys_call_table[eax*4] call [_sys_call_table eax*4] // 返回值已在 eax将其压栈以备后续处理 push eax // 取当前任务进程结构指针 mov eax, _current // 检查当前进程状态如果 state ! 0 或 counter 0则重新调度 cmp dword ptr [stateeax], 0 jne reschedule cmp dword ptr [countereax], 0 je reschedule ret_from_sys_call: // 从系统调用返回后会检查信号并恢复现场最终 iret 回到用户态 // ...系统调用流程简要说明用户程序执行int $0x80传入系统调用号eax和参数ebx, ecx, edx。CPU 通过中断门跳转到system_call汇编入口。保存现场将段寄存器指向内核段fs 指向用户段便于访问用户空间。以系统调用号为索引从sys_call_table中取出对应函数地址并调用。C 函数执行完毕返回值存于 eax。检查当前进程是否需要重新调度状态或时间片若需要则切换进程。最后恢复现场iret 返回用户态继续执行。可是为什么我们用的系统调用从来没有见过什么 int 0x80 或者 syscall 呢都是直接调用上层的函数的啊那是因为 Linux 的 gnu C 标准库给我们把几乎所有的系统调用全部封装了。// 源代码路径linux-2.6.18\linux-2.6.18\include\asm-x86_64\unistd.h // 该头文件定义了 x86_64 架构的系统调用号及对应的内核函数入口 /* at least 8 syscall per cacheline */ // 注释每个缓存行至少包含 8 个系统调用优化缓存局部性 // 系统调用号从 0 开始递增每个宏定义分配一个唯一的编号 #define __NR_read 0 // 系统调用号 0读取文件 __SYSCALL(__NR_read, sys_read) // __SYSCALL 宏将系统调用号与内核实现函数关联用于构建系统调用表 #define __NR_write 1 // 系统调用号 1写入文件 __SYSCALL(__NR_write, sys_write) #define __NR_open 2 // 系统调用号 2打开文件 __SYSCALL(__NR_open, sys_open) #define __NR_close 3 // 系统调用号 3关闭文件 __SYSCALL(__NR_close, sys_close) #define __NR_stat 4 // 系统调用号 4获取文件状态通过路径 __SYSCALL(__NR_stat, sys_newstat) // 使用新的 stat 结构sys_newstat #define __NR_fstat 5 // 系统调用号 5获取文件状态通过文件描述符 __SYSCALL(__NR_fstat, sys_newfstat) #define __NR_lstat 6 // 系统调用号 6获取链接文件状态不跟随符号链接 __SYSCALL(__NR_lstat, sys_newlstat) #define __NR_poll 7 // 系统调用号 7poll 多路复用 I/O __SYSCALL(__NR_poll, sys_poll) #define __NR_lseek 8 // 系统调用号 8重定位文件读写偏移 __SYSCALL(__NR_lseek, sys_lseek) #define __NR_mmap 9 // 系统调用号 9内存映射 __SYSCALL(__NR_mmap, sys_mmap) #define __NR_mprotect 10 // 系统调用号 10修改内存保护属性 __SYSCALL(__NR_mprotect, sys_mprotect) #define __NR_munmap 11 // 系统调用号 11解除内存映射 __SYSCALL(__NR_munmap, sys_munmap) #define __NR_brk 12 // 系统调用号 12调整数据段堆大小 __SYSCALL(__NR_brk, sys_brk) #define __NR_rt_sigaction 13 // 系统调用号 13实时信号处理函数设置 __SYSCALL(__NR_rt_sigaction, sys_rt_sigaction) #define __NR_rt_sigprocmask 14 // 系统调用号 14实时信号屏蔽字操作 __SYSCALL(__NR_rt_sigprocmask, sys_rt_sigprocmask) #define __NR_rt_sigreturn 15 // 系统调用号 15从实时信号处理函数返回 __SYSCALL(__NR_rt_sigreturn, stub_rt_sigreturn) // 使用汇编桩stub实现 // ... 后续还有更多系统调用定义 ...1.4.4 缺页中断缺页中断内存碎片处理除零野指针错误这些问题全部都会被转换成为CPU内部的软中断然后走中断处理例程完成所有处理。有的是进行申请内存填充页表进行映射的。有的是用来处理内存碎片的有的是用来给目标进行发送信号杀掉进程等等。1.4.5 中断分类1.5 重谈内核态和用户态理解在OS层面上进程所对应的0-3GB通常会由用户映射表映射到对应位置我们3-4GB内核空间也会映射所以有内核页表和用户页表。不过不同进程内核页表都是一样的不过实际上其实两个页表是混在一起的。逻辑上我们分开来说。进程之间是具有独立性的。无论我的进程怎么调度进程PCB怎么切换我们都可以找到同一个操作系统变的只是0~3GB的用户区。Linux中任何函数跳转都是在进程的地址空间内进行跳转的。 从逻辑上可以直接跳转到内核区但是实际上是不行的。我们设置了一个条件CPU里面有个CS寄存器用低两位的比特位来表示当前CPU本身的执行级别其中Linux只使用这两种执行级别00(0)表示内核态11(3) 表示用户态。所以我们就会判断执行级别如果是11会禁止是00系统调用才能被调用。我们当前进程是处于用户态还是内核态是由CPU里面来标识的。那么现在的问题就是对应的用户能不能直接访问3-4GB呢是不行的因为会对执行级别进行处理我要访问内核就得3变为0内核态才行我们使用汇编语言其实也是改不了这个的。要改只能int80 或者 syscall自动由3-0结束再变回3和这个软中断强制绑定了。执行级别为0才可以访问OS。1.6 sigaction 函数推荐使用功能更强signal 函数简单但兼容性差sigaction 支持更精细的控制如设置额外阻塞信号、保留信号上下文原型如下#include signal.h int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);struct sigaction 结构体解析struct sigaction { void (*sa_handler)(int); // 信号处理函数与signal兼容 void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号处理函数 sigset_t sa_mask; // 处理信号时额外阻塞的信号集 int sa_flags; // 选项0表示默认 void (*sa_restorer)(void); // 已废弃不用关注 };关键特性sa_mask处理信号时内核会自动将当前信号加入阻塞集同时阻塞sa_mask中的信号避免嵌套处理sa_flags常用SA_RESTART被信号中断的系统调用自动重启。实战 1用 sigaction 自定义捕捉2号信号实战 2证明信号的处理函数在被调用时会自动将当前信号加入信号屏蔽字block表中#include iostream #include signal.h #include unistd.h void handler(int signum) { std::cout 捕捉信号: signum std::endl; while(true) { //打印pending表判断信号处理过程中输入相同信号是否被阻塞 sigset_t pending; sigpending(pending); for(int i 31; i 1; i--) { if(sigismember(pending, i)) { std::cout 1; } else { std::cout 0; } } std::cout std::endl; sleep(1); } exit(1); } int main() { struct sigaction act, oldact; act.sa_handler handler; //自定义信号捕捉 sigisemptyset(act.sa_mask); //信号集初始化 act.sa_flags 0; sigaction(SIGINT, act, oldact);//对2号信号进行捕捉 while(true) { std::cout hello world, pid: getpid() std::endl; sleep(1); } return 0; }实战 3利用 sa_mask 额外屏蔽其他信号#include iostream #include signal.h #include unistd.h void handler(int signum) { std::cout 捕捉信号: signum std::endl; while(true) { //打印pending表判断信号处理过程中输入相同信号是否被阻塞 sigset_t pending; sigpending(pending); for(int i 31; i 1; i--) { if(sigismember(pending, i)) { std::cout 1; } else { std::cout 0; } } std::cout std::endl; sleep(1); } exit(1); } int main() { struct sigaction act, oldact; act.sa_handler handler; //自定义信号捕捉 sigisemptyset(act.sa_mask); //信号集初始化 act.sa_flags 0; //sa_mask额外屏蔽其他信号 sigaddset(act.sa_mask, 3); sigaddset(act.sa_mask, 4); sigaction(SIGINT, act, oldact);//对2号信号进行捕捉 while(true) { std::cout hello world, pid: getpid() std::endl; sleep(1); } return 0; }二、可重入函数 volatile SIGCHLD信号2.1 可重入函数下图展示了在多任务或信号处理环境下如果是不可重入函数Non-reentrant function会导致的“灾难现场” 。重入问题示例main 函数调用 insert 向链表插入节点刚完成第一步时发生硬件中断。进程切回用户态前发现待处理信号执行信号处理函数 sighandler。sighandler 也调用了 insert 插入另一个节点并完整执行了两步。返回 main 后继续完成之前未完成的第二步插入操作。结果两个不同控制流先后插入节点但链表最终只成功插入了一个节点数据被覆盖或丢失。重入与可重入定义重入同一个函数在第一次调用尚未返回时又被另一个控制流程再次调用。不可重入函数函数内部访问了全局或静态数据结构如上述insert操作的全局链表可能因重入导致数据错乱。可重入函数函数只使用自身的局部变量或参数不依赖共享资源可以被多个控制流安全地同时调用。为什么局部变量不会出错每次调用函数时局部变量和参数都会在栈上分配独立的空间不同调用之间互不干扰因此不会出现交叉污染。2.1.1 如何判断可重入和不可重入2.1.2 场景应用的区别可重入还是不可重入都没有绝对的好坏只有场景的适配不可重入函数编写简单适合单线程、不涉及异步信号、对性能要求极高避免栈开销的底层代码。可重入函数是线程安全Thread-safe的基石。在编写多线程程序、实时系统RTOS或信号处理程序时必须保证函数是可重入的。核心原则在sighandler信号处理函数内部永远只调用“Async-signal-safe异步信号安全 ”的函数。像 printf 内部维护了全局缓冲区在信号处理里用它其实是很危险的2.2 volatilevolatile 关键字的核心作用确保内存变量的“可见性”抑制编译器的过度优化。2.2.1 核心概念什么是 volatilevolatile 的字面意思是“易变的”。在编程中它告诉编译器这个变量可能会在当前代码流之外被修改例如由信号处理函数、多线程、或者硬件中断修改。因此编译器在编译时严禁对该变量进行任何“假定不变”的优化每次使用它时必须老老实实地去内存地址中读取。2.2.2 现象产生消失的信号响应#include stdio.h #include signal.h #include unistd.h #include sys/wait.h // int flag 0; volatile int flag 0; void handler(int signum) { (void) signum; flag 1; printf(更改flag 0-1\n); } int main() { signal(2, handler); while(!flag); printf(进程正常退出\n); return 0; }上述代码通过一个典型的while(!flag)循环展示了优化带来的问题代码意图main 函数进入死循环等待 flag 变为 1当用户按下 Ctrl C 触发信号执行 handler 将 flag 改为 1理论上 main 应该退出循环。非优化状态-O0程序运行符合预期信号触发后进程正常退出。开启优化如 -O1 及以上异常现象即便执行了 handler 并打印了“更改 flag 0-1”main 函数的循环依然停不下来。原因编译器认为在 main 函数的执行流中没有修改 flag 的语句为了提高效率它将flag 的值缓存到了 CPU 寄存器中。2.2.3 现象原因CPU寄存器与内存的“可见性”冲突编译器的推断编译器扫描 main 函数发现从循环开始到结束没有任何逻辑会改变 flag。寄存器缓存CPU 访问寄存器的速度远快于内存。为了追求极致性能编译器生成的汇编代码只在循环开始前将内存中的 flag 载入寄存器如 eax后续的 while 判断全部基于寄存器中的旧值。逻辑断层当信号发生时内核触发的 handler修改的是内存里的 flag。但此时main 函数只盯着自己的寄存器看完全不知道内存里的值已经变了。这就是“内存不可见性” 内存的改变对正在高速运行的 CPU 寄存器副本无效。2.2.4 现象解决方案volatile 的作用当给变量加上volatile 修饰volatile int flag 0;后情况发生了根本变化抑制优化强制要求编译器每次执行 while 判断时必须重新生成一条 mov 指令从内存地址读取最新值。保持可见性确保了异步执行流如信号处理对变量的修改能即时被当前执行流main察觉。2.2.5 为什么不默认给所有变量加 volatile2.3 SIGCHLD信号对于这个信号我们主要讨论一下SIGCHLD 信号的处理机制以及如何优雅地解决“僵尸进程”回收问题。2.3.1 证明SIGCHLD信号的存在2.3.2 为什么要处理 SIGCHLD僵尸进程问题在多进程服务器中父进程通常在忙于处理业务逻辑如 while(1) 循环无法预知子进程何时退出。如果不回收子进程变成僵尸进程Zombie占用进程表项。常规阻塞等待如果在主逻辑调用 wait()父进程会被阻塞无法处理新请求。解决方案利用信号处理机制。当 SIGCHLD 到达时父进程异步跳转到处理函数中执行 waitpid实现“随死随埋”。我们以前等待子进程是父进程主动去等那我们现在等待子进程方式要不要发生一些改变父进程自己忙自己的事不退出就行当父进程收到信号在信号处理函数内部回收子进程是可以的这也就是 SIGCHLD 信号存在的其中一个意义。2.3.3 SIGCHLD 信号的作用#include iostream #include signal.h #include unistd.h #include sys/wait.h void Say(int signum) { // 父进程和子进程异步解耦 waitpid(-1, nullptr, 0); std::cout father get a signal: signum std::endl; } int main() { // 父进程 // signal(SIGCHLD, Say); struct sigaction act, oldact; act.sa_handler Say; sigaction(SIGCHLD, act, oldact); sleep(3); int id fork(); if (id 0) { // 子进程 std::cout I am child, exit std::endl; sleep(3); exit(3); // 当子进程退出时则会发送SIGCHLD信号则被自定义捕捉 } // waitpid(id, nullptr, 0); while (true) { std::cout I am father, exit std::endl; sleep(1); } return 0; }#include iostream #include signal.h #include unistd.h #include sys/wait.h // 配合 whileWNOHANG 批量回收并发退出的子进程,弥补普通信号丢失缺陷 void waitAll(int signum) { while (true) { // int n waitpid(-1, nullptr, 0); // waitpid默认是阻塞等待的即会导致如果一个子进程不退出则无法跳出循环 int n waitpid(-1, nullptr, WNOHANG); if (n 0) { break; // 返回值为0则说明当前无退出的子进程则退出循环让父进程继续执行自己的代码不要卡在信号处理函数中 } else if (n 0) { std::cout waitpid error std::endl; break; } } std::cout father get a signal: signum std::endl; } int main() { // 父进程 // signal(SIGCHLD, Say); struct sigaction act, oldact; act.sa_handler waitAll; sigaction(SIGCHLD, act, oldact); sleep(3); // 多个子进程并发退出 for (int i 0; i 6; i) { int id fork(); if (id 0) { std::cout I am child, exit std::endl; sleep(3); exit(3); } } // waitpid(id, nullptr, 0); while (true) { std::cout I am father, exit std::endl; sleep(1); } return 0; }2.3.4 最省事的方案SIG_IGNsignal(SIGCHLD, SIG_IGN);特殊性在 Linux 中如果父进程显式地将 SIGCHLD 的处理动作设为SIG_IGN忽略那么子进程在退出时会直接被内核回收不会产生僵尸进程。优点代码最简无需写处理函数无需手动调用 wait。2.3.5 总结子进程回收的三种方案结束语到此信号这个部分我们用了三篇文章就全部讲解完了。从信号的产生到信号的保存再到信号的处理在其中我们还补充了很多额外拓展的知识。而接下来就是对线程这个大板块进行讲解希望本篇文章对大家学习信号有所帮助