目录
前言
1. 信号的默认行为
2. 信号的保存
信号集操作函数
sigprocmask
sigpending
3. 信号的处理
信号的处理过程
思考
4. sigaction
5. SIGCHLD信号
6. 可重入函数
7. volatile
总结
前言
上文介绍了信号,以及信号的产生,本文继续来聊一聊信号,信号的保存与处理;
1. 信号的默认行为
使用命令查看:
man 7 signal
可以查看每种信号默认的行为是什么;但是在使用的时候,好像没发现什么区别啊?
出现异常就直接终止,Core、Term、Ign、Cont又是什么?
Term 信号会让进程有机会在收到信号后进行清理工作,保存数据等操作,然后正常退出;
Core又是什么?
先来看一个简单示例:信号的详细介绍中,除零错误就是一种Core操作的信号;
Core 会让进程退出时生成一个以进程 core.pid 的文件在进程运行的目录下,里边会将进程异常退出时核心的进程上下文数据记录下来,转储到磁盘中;
但是进程出现除零错误时也没有core文件? ulimit -a 查看当前shell会话的资源限制;
云服务器默认的大小都是0;
core file大小限制设置为0会导致系统禁止生成core转储文件;设置core文件大小:
ulimit -c【大小】
# 比如:
ulimit -c unlimited # 没有大小限制
ulimit -c 5000 # 限制大小为5KB
注意:设置之后仅限于当前shell会话;
再次运行一下除零错误的程序,查看就会有core文件了,观察会发现,core文件相较于测试代码来说还是比较大的;我在Ubuntu系统下测试了一下发现没有,centos 7是有的;
如果想要在Ubuntu系统下也可以测试,可以进行以下操作暂时修改:
执行以下命令以查看当前的核心转储设置:
cat /proc/sys/kernel/core_pattern
我的输出结果:
|/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -- %E
# 核心转储文件的生成已经被配置为通过 apport 程序进行处理;
# apport 是 Ubuntu 中的一个错误报告工具,它负责捕获崩溃的信息并生成报告,而不是直接生成核心文件
临时修改核心模式
echo "core" | sudo tee /proc/sys/kernel/core_pattern
解除限制:
ulimit -c unlimited
测试完恢复默认配置(注意结合实际情况进行修改):
echo "|/usr/share/apport/apport -p%p -s%s -c%c -d%d -P%P -u%u -g%g -- %E" | sudo tee /proc/sys/kernel/core_pattern
每次运行出现问题都产生一个core文件;那也是一个较大的存储开销;如果在服务器中,一直运行这样的程序,就会导致磁盘被打满,进而导致计算机挂掉;所以在服务器中一般是不允许生成core文件,core文件可以帮助开发人员分析问题并找出导致进程崩溃的原因;
如何使用core(核心转储)文件?
- 编译时添加-g选项,用于debug
- 通过gdb打开可执行程序
- 命令行输入“core file”
Stop:默认行为是暂停(停止)进程。
Cont:默认行为是继续执行当前已暂停的进程。
Ign:默认行为是忽略该信号。
2. 信号的保存
在此之前先来了解一下,信号处理过程中的状态;
实际执行信号的处理动作称为信号递达(Delivery);
信号从产生到递达之间的状态,称为信号未决(Pending);
进程可以选择阻塞(Block )某个信号;未决之后,暂时不递达,直到解除对信号的阻塞;
阻塞是已经收到信号,但一直不处理,让它保持未决状态,是根本就没有处理这个信号;直到解除阻塞;忽略就是一种信号的处理方式(递达);
有三种处理方式:
- 信号的忽略
- 信号的默认操作
- 信号的自定义捕捉
没有收到信号时,可以设置对信号的处理动作吗?当然可以使用signal就是设置信号处理方式
没有收到信号,也可以设置对信号是否阻塞/解除阻塞;
信号的保存与处理机制:
常规信号在递达之前产生多次只计数一次,而实时信号在递达之前产生多次可以依次放在一个队列里;
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。 因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t 称为信号集,这个类型可以表示每个信号 的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有 效”和“无效”的含义是该信号是否处于未决状态。下一节将详细介绍信号集的各种操作。 阻塞信号集也叫做当 前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。怎么去理解?
内部其实就是一个数组;
信号集操作函数
int sigemptyset(sigset t *set);
// 功能:对指定的信号集进行清空,对比特位进行清零
int sigfillset(sigset t *set);
// 功能:对指定信号集位图全部置 1
int sigaddset (sigset t *set, int signo);
// 功能:把指定信号添加到指定信号集中
int sigdelset(sigset t *set, int signo);
// 功能:删除信号集中指定信号(把指定定位置的bit位置为0)
int sigismember (const sigset t *set, int signo))
// 功能:判断一个信号是否在一个信号集中,包含返回1,不包含返回0,出错返回-1
使用sigset_t类型的变量之前,一定要调 用sigemptyset 或 sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t 变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信;
这些接口有什么用?可以让一个进程批量的设置对信号的处理;主要用于设置进程对信号的屏蔽机制。这些函数允许你指定哪些信号可以被接收和处理,哪些信号应当被屏蔽;
sigprocmask
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
// 返回值:若成功则为0,若出错则为-1
如果 oset 是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字;如果 oset 和set 都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。
how的参数:
假设信号集为mask;
how的这么多操作,都是对阻塞位图的修改,如果想要恢复到原位图怎么办?
oset是一个输出型参数,它返回的就是原位图的数据;
实验示例:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int main() {
sigset_t newset, oldset;
// 初始化新的信号集合
sigemptyset(&newset);
sigaddset(&newset, SIGINT); // 添加 SIGINT 到集合
sigaddset(&newset, SIGTERM); // 添加 SIGTERM 到集合
// 尝试同时添加和移除信号
if (sigprocmask(SIG_BLOCK, &newset, &oldset) == -1) {
perror("Failed to block signals");
}
printf("SIGINT and SIGTERM are now blocked. Press Ctrl+C to try...\n");
// 暂停进程,等待信号
sleep(10);
// 恢复之前的信号屏蔽字
if (sigprocmask(SIG_SETMASK, &oldset, NULL) == -1) {
perror("Failed to restore signals");
}
printf("Signals are unblocked now.\n");
// 继续运行
sleep(5);
return 0;
}
sigpending
#include <signal.h>
int sigpending(sigset_t *set);
// 读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
3. 信号的处理
信号在合适的时候被处理——什么时候是合适的时候?
进程从内核态返回到用户态的时候,会进行信号的检查和处理;
用户态是一种受控状态,能访问的资源有限,内核态是OS的工作状态,能访问大部分系统资源系统调用就包含身份的变换,用户调用系统调用接口时,OS就会从用户态转变为内核态,执行结束后,把内核态在转变为用户态并返回;
如果进程想要访问0S的代码怎么快速访问?
前边介绍时都是用户空间,还有一部分内核空间没有介绍;OS空间也有一张页表,系统级页表,用于映射OS在物理内存中的代码和数据;进程想要调用系统调用时,只需使用虚拟地址访问内核空间就可以了;
每个进程使用的OS代码和数据都是一样的,在OS中只需要一份内核页表就够了;
在该体系中,身份又是如何转变的呢?
在CPU中存在一个叫CS的寄存器,寄存器中有两个bit位,主要使用它的两个数字:
- 1.表示内核、
- 3.表示用户
当进程调用系统调用时,是在进程地址空间内进行跳转,默认情况下,是不允许访问内
核空间;调用系统调用时,OS内部需要将CS中的两个bit位由3改为 1;这两个bit位进程是不可以随意修改的,进程调用系统调用时,系统调用会自动将这两个bit位进行修改,调用结束时会进行还原;这样设计,进程所有的代码执行,都可以在自己的地址空间内通过跳转的方式,进行调用和返回;就和之前的库函数调用一样;
CPU中还存在着其他的寄存器比如:CR1、CR3
CR3保存的就是页表的信息,它存储的是页表的物理地址,进程访问一个虚拟地址时,数据可能并没有在内存中,没有虚拟地址的映射,就会引发缺页中断,OS会把需要访问的数据加载到内存,然后建立映射,此时需要找到缺页中断的的虚拟地址,而CR1存储的就是这个虚拟地址;
信号的处理过程
使用signal方法捕捉信号,去执行自定义的方法,执行这个方法时是以用户态还是内核态执行?
用户态,自定义的信号处理方法可能存在非法的操作,为了保证系统安全,在执行时会转换成用户态执行;执行完之后需要返回到内核态;系统调用执行完后继续返回到用户态;
思考
我们平时写的代码不一定就访问内核资源,比如写了一个死循环,不需要访问内核资源
发生Ctrl +C时进程怎么检查处理信号?
进程在执行时都需要被CPU轮转式调度,进程被调度时时间片到了OS就会把进程从CPU中剥离下来,放到过期队列中,下次被CPU调度时OS需要把进程的上下文数据重新放入到CPU寄存器当中,把数据恢复,让CPU继续执行后续操作;在这个过程中OS需要不停的从内核态转换到用户态去执行进程代码;所以即使不使用系统调用,也会进行多次的状态切换,也就会有很多次机会进行的信号捕捉处理;
4. sigaction
sigaction函数可以读取和修改与指定信号相关联的处理动作;
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传出该信号原来的处理动作。act和oact指向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_handler:
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
sa_mask:在信号处理期间需要被阻塞的信号集合;当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止;
sa_flags字段包含一些选项,本文的代码都把sa_flags设为0;
sa_sigaction是实时信号的处理函数,这里不进行操作,感兴趣可以去研究一下;
#include <iostream>
#include <csignal>
#include <unistd.h>
void Print(const sigset_t& pending)
{
for (int signo = 31; signo > 0; signo--) // 从 31 循环到 1
{
if (sigismember(&pending, signo)) // 判断信号 signo 是否存在于信号集中
{
std::cout << "1"; // 如果存在,打印 1
}
else
{
std::cout << "0"; // 如果不存在,打印 0
}
}
std::cout << std::endl; // 最后换行
}
void handler(int signo)
{
std::cout << "get a sig: " << signo << std::endl;
while(1)
{
sigset_t pending; // 创建一个信号集,用于存储未决信号
sigpending(&pending); // 获取当前进程未决信号集合
Print(pending); // 假设这是一个函数,用于打印未决信号
sleep(1); // 每秒钟打印一次
}
}
int main()
{
std::cout << "pid: " << getpid() << std::endl;
struct sigaction act, oact;
// 配置信号处理结构
act.sa_handler = handler; // 设置处理函数
sigemptyset(&act.sa_mask); // 初始化信号集,清空
sigaddset(&act.sa_mask, 3); // 将信号 3 (SIGQUIT) 加入阻塞信号集
sigaction(2, &act, &oact); // 将处理动作设置为对 SIGINT 的处理
while(1) sleep(1); // 持续运行
return 0;
}
捕获2号信号,然后打印该进程的未决信号集,现象是收到一次2号信号后,后续再收到就会被阻塞;(终止进程可以使用kill命令,在新终端中执行)2号信号正在被处理,但是没有屏蔽其他信号,接收到其他信号时,仍然会处理;不想处理其他信号怎么办?这时候就可以使用sa_mask;
示例中我也添加了3号信号,ctrl + \发送3号信号;
5. SIGCHLD信号
子进程在退出时需要给父进程发送信号(SIGCHLD);他的默认处理行为是:终止父进程后回收子进程;
验证程序:
void handler(int signo) {
std::cout << "signo: " << signo << std::endl;
}
int main() {
signal(SIGCHLD, handler); // 设置 SIGCHLD 信号的处理程序
pid_t id = fork(); // 创建子进程
if (id == 0) {
std::cout << "child is running" << std::endl;
sleep(5); // 子进程休眠5秒钟
exit(10); // 子进程正常退出,返回状态10
}
// 主循环,每秒打印一次计数
int cnt = 10;
while (cnt) {
std::cout << "cnt: " << cnt << std::endl;
sleep(1); // 主进程休眠1秒,计数减少
cnt--;
}
wait(NULL); // 父进程等待子进程结束
return 0;
}
前5秒子进程正常运行,5秒后退出子进程变僵尸进程;父进程会收到17号信号;
也可以利用子进程退出给父进程发送信号的方法来进行子进程的回收,在父进程不退出的情况下;
#include <iostream>
#include <csignal>
#include <sys/wait.h>
#include <unistd.h>
void handler(int signo) {
std::cout << "signo: " << signo << std::endl;
waitpid(-1, nullptr, 0); // 阻塞等待任意一个进程结束
}
int main() {
signal(SIGCHLD, handler); // 设置 SIGCHLD 信号的处理函数
pid_t id = fork(); // 创建一个子进程
if (id == 0) {
std::cout << "child is running" << std::endl;
sleep(5); // 子进程休眠5秒
exit(10); // 子进程正常退出
}
while (true) sleep(1); // 父进程持续运行
return 0;
}
问题:如果父进程有对应的多个子进程怎么回收?前边提到过,在信号处理时如果同时收到多个相同的信号时,pending表就只会记录一次;那么子进程回收就只会回收一次喽,剩余进程不就会变成僵尸进程吗?如何设计解决?
其实很简单,一直循环等待即可:
void handler(int signo)
{
std::cout << "signo: " << signo << std::endl;
pid_t id = 0;
while((id = waitpid(-1, nullptr, 0))) //阻塞等待
{
if(id < 0) break;
std::cout << "回收进程:" << id << std::endl;
}
}
int main()
{
signal(SIGCHLD, handler);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
std::cout << "child is running" << std::endl;
sleep(5);
exit(10);
}
}
while (true) sleep(1);
return 0;
}
这样设计依然存在缺陷,比如:10个进程退出了8个,还有2个不退出,waitpid 在 while 循环中执行到第9次时依然会等待未退出的子进程,这里设置的是阻塞等待,那父进程就会一直阻塞等待子进程;还可以继续优化一下:
// 非阻塞等待
void handler(int signo)
{
std::cout << "signo: " << signo << std::endl;
pid_t id = 0;
while ((id = waitpid(-1, nullptr, WNOHANG)) > 0)
{
if(id < 0) break;
std::cout << "回收进程:" << id << std::endl;
}
}
避免产生僵尸进程的方式有很多,Linux支持手动忽略SIGCHLD,所有的子进程都不需要父进程进行等待了!!退出自动回收就不会产生僵尸进程了,同时,也不会通知父进程;
手动忽略,就可以自动回收子进程,那还要信号处理等待回收干嘛?
等待不仅仅是回收僵尸的子进程,还要获取子进程的退出信息;获取子进程退出信息时,就可以使用信号处理时等待回收;
对SIGCHILD设置忽略,此方法对于Linux可用,但不保证在其它UNIX系统上都可用;
6. 可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱。像这样的函数称为 不可重入函数;反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
7. volatile
volatile修饰变量,每次使用被修饰的变量都需要到内存中去拿;
volatile int flag = 0;
void handler(int signo)
{
std::cout << "signo: " << signo << std::endl;
flag = 1;
std::cout << "change flag to: " << flag << std::endl;
}
int main()
{
signal(2, handler); // 设定对SIGINT信号的处理程序
std::cout << "getpid: " << getpid() << std::endl; // 打印当前进程ID
while(!flag); // 循环等待,直到flag被设置为1
std::cout << "quit normal!" << std::endl;
}
标准情况下,键入CTRL-C,2号信号被捕捉,执行自定义动作,修改flag=1,while条件不满足,退出循环,进程退出;
优化情况下,键入CTRL-C,2号信号被捕捉,执行自定义动作,修改flag=1,但是while条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?while循环检查的flag,并不是内存中最新的flag,这就存在了数据二异性的问题。while检测的flag其实已经优化,被放在了CPU寄存器当中。如何解决呢?很明显需要volatile;
volatile作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作;
总结
以上便是本文的全部内容,希望对你有所帮助或启发,感谢阅读!