目录
前言
Linux下对于信号和可重入函数的了解是非常重要的,接下来来跟随小编的视角来看看吧。
let's go!!!!!!!!
Part1. 进程虚拟地址空间
Linux 每个进程都拥有独立的虚拟地址空间,32位系统经典划分:0~3G 用户空间,3G~4G 内核空间(64位只是划分范围变大,思想一致)。
• 内核区存放操作系统代码、内核数据、页表、驱动等;
• 内核区是所有进程共享映射:每个进程的页表里面都映射了同一份内核地址,但是用户态代码不能直接访问内核地址。
为什么不能直接访问?
CPU 通过 CS 寄存器的两位标记区分当前特权级:• 00:内核态(Ring0),权限最高,可以访问所有内存、执行特权指令
• 11:用户态(Ring3),权限受限
用户态代码想要使用内核能力,不能直接读写内核内存,只能通过系统调用(syscall),主动陷入内核,由内核代为执行操作。
信号的处理、sigprocmask、sigaction 底层全部都是系统调用,走这个陷入内核的流程。
Part2. sigaction
函数原型
cppint sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);• signum:要捕获的信号编号
• act:入参,我们配置的信号捕获动作(回调、掩码、标志)
• oldact:出参,保存这个信号之前旧的配置,方便后续恢复信号行为
对比 signal():signal 是简化版、兼容性差,不同Unix系行为不一样;sigaction 是 POSIX 标准,功能完备,生产代码一律优先 sigaction。
cpp核心结构体 struct sigaction struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); };重点:sa_mask 是什么?
sa_mask:当正在执行这个信号的处理函数的时候,自动追加屏蔽的信号集合。注意区分两个掩码:
1. sigprocmask:进程全局的信号屏蔽字,进程全程生效
2. sa_mask:仅在当前信号handler执行期间临时生效,handler执行完毕,这个掩码自动撤销。
举例子:
我们注册 SIGINT 的handler,并且给 sa_mask 添加 SIGQUIT、SIGUSR1、SIGUSR2。
流程:1. 收到SIGINT,进入handler
2. 内核自动把 SIGQUIT、SIGUSR1、SIGUSR2 加入阻塞掩码
3. handler运行期间,就算来了SIGQUIT,只会放到pending,不会打断当前handler
4. handler函数return退出之后,sa_mask自动失效,恢复到进入handler之前的掩码状态
默认行为:信号本身会被自动屏蔽。比如处理SIGINT的时候,再来一个SIGINT,默认会阻塞,不会递归触发handler。
sa_mask 用来额外增加其他需要屏蔽的信号。
Part3. 可重入函数
什么是可重入函数:函数可以被异步打断,中途再次进入调用,多次执行不会出现数据错乱、未定义行为。
信号handler是异步回调:主线程正在跑业务,随时可能被信号打断,跳转去执行handler。
如果handler里面调用了不可重入函数,就会引发灾难:
典型不可重入函数:malloc / free / printf / std::string 相关操作
原因:malloc 内部维护全局堆链表,printf 维护全局缓冲区。主线程正在执行malloc,半路被信号打断,handler里面又调用malloc,链表状态被破坏,直接内存损坏、程序崩溃。信号handler里推荐:
• 只做极简操作:设置全局标记变量、write写简单日志(write是可重入的系统调用)
禁止:动态内存分配、C++ STL容器、printf、复杂业务逻辑一句话:信号处理函数中,只能调用异步信号安全(可重入)的函数。
Part4. volatile 关键字
问题场景
cppint g_flag = 0; void handler(int sig) { g_flag = 1; } int main() { signal(SIGINT, handler); while(g_flag == 0) { // 循环等待 } printf("exit\n"); return 0; }开启编译器优化(-O2)之后会出现bug:
编译器发现main循环里面没有修改g_flag,优化逻辑:把g_flag读到寄存器,之后循环只读取寄存器的值,不再去内存读取。handler修改的是内存中的g_flag,但是main循环看不见内存的更新,循环永远卡死!
volatile 作用
volatile int g_flag = 0;
告诉编译器:这个变量的值可能被外部异步修改(信号、硬件寄存器),不要优化缓存到寄存器,每次读取都必须从内存读取。
注意:volatile 不保证原子性!只是阻止编译器优化,多线程/信号场景下,需要的时候还需要同步原语。
Part5. SIGCHLD
信号含义
当子进程终止、停止的时候,内核会给父进程发送 SIGCHLD 信号。
僵尸进程的来源:子进程退出,父进程没有调用 wait / waitpid 读取子进程退出状态,子进程PCB残留,变成僵尸。
坑1:普通信号不排队
SIGCHLD属于普通信号,pending只是位图。
如果短时间内多个子进程同时退出,连续多次发送SIGCHLD,pending位图只会置1一次,handler只会触发一次。
如果handler里面只wait一次,会漏掉大量子进程,残留僵尸。正确写法:handler里面循环非阻塞waitpid,直到没有已经退出的子进程
cppvoid sigchld_handler(int sig) { pid_t pid; // WNOHANG:非阻塞,没有已退出子进程立刻返回0,不阻塞卡死 while( (pid = waitpid(-1, nullptr, WNOHANG)) > 0 ) { printf("reaped child pid = %d\n", pid); } }解释:
• waitpid(-1, ...):等待任意子进程
• WNOHANG:非阻塞标志,没有就绪子进程返回0,不会卡住handler
• while循环:一轮handler里面,把所有已经退出的子进程一次性全部回收干净、
坑2:SIG_IGN 特殊特性
signal(SIGCHLD, SIG_IGN);
在Linux下,将SIGCHLD设置为忽略,内核会自动回收退出的子进程,不会产生僵尸进程。注意:这个行为不是标准POSIX规定,属于Linux扩展,跨平台代码不推荐依赖这个写法,生产可控方案依然是手动注册handler循环waitpid。
Part6. 结语
这篇文章我们深入的知道了信号和可重入函数的相关知识,接下来,小编还会带来更多Linux相关的知识,敬请期待~
最后,祝大家可以:春风得意马蹄疾,一日看尽长安花!最后的最后,要是觉得本文还可以的话,可以点点赞,关注小编一波,谢谢大家!~
