【知识讲解】 Linux信号与可重入函数的认识


目录


前言

Linux下对于信号和可重入函数的了解是非常重要的,接下来来跟随小编的视角来看看吧。


let's go!!!!!!!!


Part1. 进程虚拟地址空间

Linux 每个进程都拥有独立的虚拟地址空间,32位系统经典划分:0~3G 用户空间,3G~4G 内核空间(64位只是划分范围变大,思想一致)。

• 内核区存放操作系统代码、内核数据、页表、驱动等;

• 内核区是所有进程共享映射:每个进程的页表里面都映射了同一份内核地址,但是用户态代码不能直接访问内核地址。

为什么不能直接访问?
CPU 通过 CS 寄存器的两位标记区分当前特权级:

• 00:内核态(Ring0),权限最高,可以访问所有内存、执行特权指令

• 11:用户态(Ring3),权限受限

用户态代码想要使用内核能力,不能直接读写内核内存,只能通过系统调用(syscall),主动陷入内核,由内核代为执行操作。

信号的处理、sigprocmask、sigaction 底层全部都是系统调用,走这个陷入内核的流程。


Part2. sigaction

函数原型

cpp 复制代码
int 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 关键字

问题场景

cpp 复制代码
int 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,直到没有已经退出的子进程

cpp 复制代码
void 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相关的知识,敬请期待~
最后,祝大家可以:春风得意马蹄疾,一日看尽长安花!

最后的最后,要是觉得本文还可以的话,可以点点赞,关注小编一波,谢谢大家!~

相关推荐
cynicism??1 小时前
Linux 与 ZXISOS 驱动开发架构对比
linux·驱动开发·架构
pikapika~~~1 小时前
计算机网络八股
服务器·网络·计算机网络
Sayai1 小时前
Kafka 跨数据中心日志同步实战:MirrorMaker 2 选型对比 + Flink 中继任务落地(双中心互备与消费者组隔离避坑)
大数据·运维·分布式·flink·kafka·消息队列·高可用
djjjx.1 小时前
Linux进程篇(十二):从零构建属于你的自定义 Shell 解释器
linux·运维·服务器
云川之下2 小时前
[ensp] 交换机入门配置例子(带vlan)
运维·服务器·网络
承渊政道2 小时前
Linux网络学习【数据链路层、ARP工作过程与ARP欺骗详解】
linux·网络·学习·数据链路层·arp
CS_Zero2 小时前
安卓开发:Linux的android工具命令
android·linux·运维
何中应3 小时前
Grafana如何安装插件
运维·grafana·可观测