Linux信号机制(Extra):可重入、volatile与SIGCHLD
目录一、异步打断问题1. 信号带来的异步性二、可重入函数1. 什么是可重入2. 链表插入案例3. 不可重入原因三、volatile1. 为什么需要volatile2. volatile 作用3. volatile 不是线程安全四、SIGCHLD1. SIGCHLD 概念2. 僵尸进程问题3. 示例代码总结一、异步打断问题在前面的章节中我们已经完整建立了信号从产生到处理的物理链路。然而这种机制在赋予操作系统强大异步处理能力的同时也给用户态程序的稳定性带来了挑战本篇我们将探讨程序设计中一个极具隐蔽性的问题信号带来的异步打断以及由此引出的可重入性与内存可见性问题1. 信号带来的异步性在单线程模型中开发者习惯于认为代码是按顺序执行的。然而信号的介入打破了这种确定性。信号是异步的这意味着它可以在进程执行的任何时刻、任何指令之间发生(1) 非原子语句很多开发者认为一行简单的 C 语言代码是不可分割的执行单元但在 CPU 视角下即使是 g_count 这样简单的操作通常也需要三条机器指令从内存加载变量到寄存器mov在寄存器中进行加法运算add将结果写回内存mov如果信号在指令 1 和指令 2 之间抵达且信号处理函数Handler也访问了同一个变量 g_count那么原本预期的逻辑就会被彻底破坏(2) 执行流强行插入当信号递达时内核会强行挂起当前的用户态执行流转而去执行信号处理函数。这种行为类似于在一个单线程程序中突然插入了一个临时线程核心问题如果主执行流正在调用某个函数而信号处理函数也恰好调用了同一个函数这就形成了对该函数的重入二、可重入函数1. 什么是可重入一个函数被称为可重入函数是指该函数执行被中断后在重新进入仍能保证结果正确性的函数。具体表现为函数执行过程中被中断转而执行其他代码或再次调用该函数在恢复执行后仍能产生预期结果核心特征不依赖全局变量、静态变量不调用不可重入的函数如 malloc、printf安全性可重入函数是异步信号安全的2. 链表插入案例这是一个典型的看似正常但会导致灾难的代码场景。假设我们有一个全局链表主执行流正在执行插入操作// 伪代码全局链表插入 void insert(Node* new_node) { new_node-next head; // 步骤 1 // 如果在此处被信号打断 head new_node; // 步骤 2 }主执行流执行了步骤 1此时 new_node-next 已经指向了当前的 head信号抵达内核跳转到 handlerhandler内部也调用了 insert(signal_node)。它完整地执行了步骤 1 和步骤 2。此时head 已经指向了信号流中的新节点信号返回主执行流从断点处继续执行自己的步骤 2将 head 指向了自己的 new_node后果信号流中插入的那个节点将从链表中永远丢失造成内存泄漏和逻辑错误3. 不可重入原因一个函数之所以不可重入通常是因为它访问了临界资源或具有副作用使用了静态或全局数据结构如上述链表、全局计数器调用了标准 I/O 库函数很多标准库如 printf、malloc为了性能内部维护了全局缓冲区或链表信号处理函数应当保持极度的精简。在 handler 中调用不可重入函数尤其是 malloc是导致程序发生死锁或内存崩溃的常见原因最佳实践建议在信号处理函数中应当仅设置一个标志位Flag然后尽快返回让主逻辑在安全的时候去处理实际业务三、volatile1. 为什么需要volatile在现代编译器的优化算法中为了提高程序的执行效率编译器会尽可能减少对内存的直接访问假设有如下代码片段int quit 0; void handler(int sig) { quit 1; } int main() { signal(SIGINT, handler); while (!quit) { // 执行某些任务 } return 0; }在编译器看来main函数的循环体中并没有任何修改 quit 变量的代码。为了优化编译器可能会将 quit 的值缓存到 CPU 的寄存器中每次循环只检查寄存器的值而不是重新从内存读取然而handler 是由内核异步调用的。当信号触发时handler 修改了内存中的 quit但 main 函数所在的执行流仍然盯着寄存器里的旧值。这种内存与寄存器之间的数据不一致会导致程序陷入死循环即便信号已经发生2. volatile 作用volatile 关键字的字面意思是 易变的。它的核心作用是向编译器发出明确指令禁止对该变量进行优化具体而言volatile 确保了以下两点内存可见性每次读取该变量时CPU 必须直接从内存地址中获取数据而不是使用寄存器中的缓存禁止指令重排编译器在处理该变量时不能为了性能而打乱涉及该变量的指令顺序通过将变量声明为 volatile int quit 0我们强制让 main 函数在每次循环判定时都进行一次内存交互从而能够及时感知到 handler 对标志位的修改3. volatile 不是线程安全这是最容易产生误区的地方。volatile仅保证了可见性但它并不保证原子性也不是线程安全的可见性我能看到你改了但不代表我改的时候是安全的原子性如前文所述g_count 是三步操作。即使变量被声明为 volatile在执行这三步期间依然可能被其他线程或信号处理函数打断。volatile 无法阻止这种竞争条件并发控制在多线程环境下保护共享资源需要使用互斥锁Mutex或原子操作。volatile 既不能代替锁也无法提供类似 std::atomic 的同步语义总结在信号处理中volatile 仅适用于一种场景在 handler中修改全局标志位并在主循环中读取该标志位。对于更复杂的数据操作它无能为力四、SIGCHLD我们来看一个信号在多进程编程中的经典应用SIGCHLD1. SIGCHLD 概念在 Linux 中子进程和父进程是异步运行的。当子进程的状态发生改变终止、停止或继续运行时内核会向其父进程发送一个编号为 17 的信号 —— SIGCHLD默认行为内核对 SIGCHLD 的默认处理动作是忽略存在意义它就像是子进程在临终前发给父进程的遗言通知父进程可以来回收自己的资源了2. 僵尸进程问题我们在之前的进程控制章节中提到过当一个子进程结束时它并不会立即从系统中彻底消失僵尸状态进程虽然已经停止运行但其 task_struct 结构体和基础信息PID、退出码、运行时间等仍保留在内核的进程表中危害如果父进程不处理子进程这些僵尸会一直占用进程号。由于系统的 PID 资源是有限的大量的僵尸进程会导致系统无法创建新进程传统的解决办法是父进程主动调用 wait() 或 waitpid()但这会导致父进程阻塞原地等待子进程死掉从而无法处理自己的业务。而利用 SIGCHLD 信号我们可以实现异步回收3. 示例代码要优雅地回收子进程我们需要在 SIGCHLD 的处理函数中执行回收逻辑#include stdio.h #include stdlib.h #include unistd.h #include signal.h #include sys/wait.h #include errno.h // 信号处理函数 void cleanup(int sig) { // 保存 errno保证可重入性安全 int save_errno errno; int status; pid_t pid; // 使用 while 循环和 WNOHANG // -1 表示回收任意子进程 // WNOHANG 表示非阻塞如果没有僵尸进程了立即返回 while ((pid waitpid(-1, status, WNOHANG)) 0) printf(Handler: Child %d reaped. Status: %d\n, pid, WEXITSTATUS(status)); // 恢复 errno errno save_errno; } int main() { // 注册 SIGCHLD 信号处理函数 struct sigaction sa; sa.sa_handler cleanup; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; // 防止系统调用被信号中断后不重启 if (sigaction(SIGCHLD, sa, NULL) -1) { perror(sigaction); exit(1); } // 创建多个子进程 for (int i 0; i 5; i) { if (fork() 0) { printf(Child %d start\n, getpid()); sleep(i 1); // 不同时间退出 printf(Child %d exiting\n, getpid()); exit(i); } } // 父进程继续忙自己的业务 printf(Parent working...\n); while (1) sleep(10); return 0; }代码解析为什么必须用 while 循环信号是不排队的。如果有 5 个子进程同时退出内核可能只向父进程发送一次 SIGCHLD 信号。如果处理函数里只调用一次 waitpid剩下的 4 个子进程就会变成僵尸。通过 while 循环我们可以一次性把当前所有死去的子进程全部收完为什么必须用 WNOHANG如果不用 WNOHANG非阻塞模式当最后一个僵尸进程被收完后waitpid 可能会停在那里死等下一个子进程退出这会阻塞信号处理函数进而阻塞主程序关于 errno 的保存 这是我们在可重入小节讨论的实践。waitpid 可能会修改全局变量 errno。如果主程序正好也在进行某个依赖 errno 的系统调用被信号打断后回来errno 可能已经被 waitpid 改了。手动保存和恢复 errno 是编写高质量异步代码的标配总结综上所述信号机制真正困难的地方并不在于 发送信号 本身而在于它会以一种异步的方式打断程序的正常执行流。无论是链表插入过程中暴露出的可重入问题还是 volatile 为应对编译器优化而存在的意义本质上都说明了一件事多个执行流一旦同时访问共享资源程序的正确性就会变得极其脆弱与此同时SIGCHLD 等机制也进一步体现了信号在进程控制与资源回收中的重要作用至此我们已经完成了 Linux 信号机制的整体学习。从信号产生、递达、阻塞到内核中的三张表再到异步环境下的代码安全问题我们已经逐步建立起了对 异步执行流 的完整认知而进一步思考会发现相比偶尔打断的信号机制线程则是一种真正意义上的并发执行流。它们共享地址空间、共享资源也因此会带来更加复杂的竞争与同步问题在下一篇中我们将正式进入线程的世界