文章目录
📖 前言
上一篇我们讲述了信号的基本概念和相应系统接口的使用,本章我们想更深入的学习信号发送的一系列过程,目标已经确定,接下来就要搬好小板凳,准备开讲了…🙆🙆🙆🙆
1. 阻塞信号
1.1 信号其他相关常见概念:
- 实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
pending
表可以理解为是一张位图,32个比特位。
- 比特位为0/1,表示哪一个信号是否收到。
handler
表可以理解为是一个函数指针数组。
- 对应的该信号也有对应的方法。
block
叫做阻塞信号集。阻塞信号集(Block Signal Set)和信号屏蔽字(Signal Mask)是指相同的概念。
- 拦不住发信号,但是可以拦得住递达这个信号。
- 有些信号我们不想处理,但是防不住别人发,这个
block
也是一个位图,位图结构和pending
位图是一模一样的。 - 第几个比特位就代表着是几号信号,不一样的是,比特位的内容,在
pending
表里代表的是,是否收到内容,在block
表中代表是否阻塞该信号。
block
位图对应的比特位,为1的时候会拦截对应的信号去执行对应的方法。即使pending
收到了该信号,只要是block
位图对应的比特位为1,那么这个信号就无法去递达。
阻塞和忽略有什么区别呢?
忽略信号是处理信号的一种,只不过处理的方式是忽略它。(就是什么都不做,将pending位图由1置0就完了)
补充:
- 在Linux中, 普通信号(非实时信号)多次发送并不会被记录多次。当同一个信号被多次发送给进程时,操作系统只会在进程的信号处理程序中记录次,而不会累积多个相同的信号。
- 当进程接收到一个信号时,操作系统会将该信号标记为已挂起,直到进程处理完当前正在处理的信号或者通过信号处理程序返回后,才会再次传递给进程。
- 这意味着,如果进程在处理信号期间接收到了多个相同的信号, 那么只有一 个信号会被记录和传递给进程的信号处理程序。
例如:多次发送二号信号,只有一个会被递达,多余发出的信号被丢弃掉了。
1.2 sigset_t:
sigset_t
是操作系统专门针对信号所构建的用户级的数据类型。
sigset_ t
类型称之为信号集。
- 可以表示每个信号的有或者无这样的概念。
- 在阻塞信号集中表示有没有被阻塞这样的概念。
- 在未决信号集中表示有或者没有被
pending
起来,或者未决起来。 sigset_t
不能手动修改进制位图,要用对应的接口。
1.2 - 1 信号集操作函数
#include <signal.h>
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);
在特定的信号集当中,将特定的信号去掉。
int sigismember(const sigset_t *set, int signo);
判断特定的一个信号,是否在该集合当中。
这一批接口,就是针对于位图结构天然设计好的各种各样的增删差改的操作。
1.3 sigprocmask:
sigprocmask
: signal
- 信号,process
- 进程,mask
- 掩码的意思。
- 可以更改或者获取特定调用进程的信号屏蔽字。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
// 返回值:若成功则为0,若出错则为-1
可以理解成old
或者是output
,将老的信号屏蔽字返回出来,目的是为了将来的恢复需求。
这个函数第一个参数就决定了要做什么操作:
1.4 sigpending:
- 获得当前进程的
pending
信号集。 - 读取当前进程的末决信号集通过
set
参数传出,调用成功则返回0,出错则返回-1。
综上几个接口,我们用代码演示一下:
#include <iostream>
#include <cstdio>
#include <cstdlib>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
void handler(int signo)
{
// sleep(1);
cout << "我是一个进程,刚刚获取了一个信号: " << signo << endl;
// exit(1);
}
static void showPending(sigset_t* pendings)
{
for (int sig = 1; sig <= 31; sig++)
{
// 检测这31个信号是否在这个集合里
if (sigismember(pendings, sig))
{
cout << '1';
}
else
{
cout << '0';
}
}
cout << endl;
}
int cnt = 0;
int main()
{
// 3. pending收到信号很快就递达了,所以先block,这样就能看到pending表里的信号了
// 屏蔽二号信号
sigset_t bsig, obsig;
sigemptyset(&bsig);
sigemptyset(&obsig);
// 3.1 添加2号信号到信号屏蔽字中
sigaddset(&bsig, 2);
// 3.2 设置用户级的信号屏蔽字到内核中,让当前进程屏蔽掉2号信号
sigprocmask(SIG_SETMASK, &bsig, &obsig);
// 2. signal将二号信号进行自定义捕捉
signal(2, handler);
// 1. 不断获取当前进程的pending信号集
// 表示当前进程的所有pending信号
sigset_t pendings;
while (true)
{
// 1.1 清空信号集
sigemptyset(&pendings);
// 1.2 获取当前进程(谁调用,获取谁)的pending信号集
if (sigpending(&pendings) == 0)
{
// 获取成功
// 1.3 打印一下当前进程的pending信号集
showPending(&pendings);
}
sleep(1);
// 先跑十秒钟,再解除屏蔽
cnt++;
if (cnt == 10)
{
cout << "解除对所有信号的block...." << endl;
sigset_t sigs;
sigemptyset(&sigs);
sigaddset(&sigs, 2);
// 只对2号解除屏蔽
// sigprocmask(SIG_UNBLOCK, &sigs, nullptr);
// 解除全部信号屏蔽
sigprocmask(SIG_SETMASK, &obsig, nullptr);
}
}
return 0;
}
演示结果:
2. 进程处理信号
2.1 内核页表和用户页表:
进程收到了信号该如何处理呢?
进程处理信号,不是立即处理的:
- 而是在合适的时候处理的。
信号可能不是立即处理的,可能当前进程做着更重要的事情。 是在合适的时候处理的。
具体在什么时候处理呢?
- 当当前进程从内核态,切换回用户态的时候,进行信号的检测与处理!
每一个进程都有一个内核空间,用于内核级页表的映射:
内核页表:
- 负责3G到4G之间数据的映射。
- 所有进程共享的,只有一份内核级页表。
- 前提是你得有权利访问!
用户级页表:
- 每一个进程,都有一份,而且大家的用户级页表都是不一样的!
2.2 内核态和用户态:
内核态vs用户态:
- 内核态可以访问所有的代码和数据 —— 具备更高权限
- 用户态只能访问自己的
OS在不在内存中被加载呢??在!
- 无论进程怎么切换,我们都可以找到内核的代码和数据,前提是你只要能够有权力访问!
当前进程如何具备权力,访问这个内核页表,乃至访问内核数据呢?
- 要进行身份切换。
- 进程如果是用户态的 —— 只能访问用户级页表。
- 进程如果是内核态的 —— 访问内核级和用户级的页表。
- 进程也有用户态和内核态的差别。
我怎么知道我是用户态的还是内核态的呢?
- CPU内部有对应的状态寄存器CR3,有比特位标识当前进程的状态:
0是内核态
,3是用户态
。
普通用户的身份是无法访问到操作系统中的任何数据的。
补充:
- 当我们想调用某些系统调用的时候,这些系统调用的代码,实际上在执行时,除了要跳转到目标函数之外,还要
陷入内核
就是通过计算机帮我们直接去执行某些寄存器操作,将CR3寄存器权限标志位由3 (用户态)改为0(内核态),操作系统当在进行身份认证的时候,发现是0就有权访问,否则就不能访问。
- 当把操作系统的代码执行完,准备返回的时候,返回时CPU内的级别再由0被改成了3再返回代码处继续执行。
达成的共识:
- 地址空间分为用户空间和内核空间,每个用户都有自己的私有页表,但共享所有的是内核页表。CPU内有寄存器用来识别标识用户身份的。
最终的认识:
- 无论进程再怎么切换,
3~4G
的内核空间是完全一样的,所以任何进程经过身份切换都可以,变成内核态去执行操作系统的代码。 - 内核态可以访问地址空间内的所有代码和数据,任意进程。
- 用户态只能访问自己的
0~3G
的数据,更高的访问不了。
补充:
我们的程序,会无数次直接或者间接的访问系统级软硬件资源(管理者是OS),本质上,你并没有自己去操作这些软硬件资源,而是必须通过OS -> 无数次的陷入内核(1.切换身份 2.切换页表) -> 调用内核的代码 -> 完成访问的动作 -> 结果返回给用户(1.切换身份 2.切换页表) -> 得到结果。
例如:
while(1); -> 必须有自己的时间片 -> 时间片到了的时候 -> 内核态,更换内核级页表 -> 保护上下文,执行调度算法 -> 选择了新的进程 -> 恢复新进程的上下文 -> 用户态,更换成用户级页表 -> CPU执行的就是新进程的代码!
什么是陷入内核:
- 在Linux中,"陷入内核"是指用户程序或进程进入内核空间执行的一种状态。当用户程序需要执行特权操作或需要访问受限资源时,例如打开文件、创建进程等,就会触发一个系统调用来请求内核的帮助。
- 当一个用户程序调用系统调用时,CPU会从用户态切换到内核态,进入内核空间执行相应的内核代码。在内核态下,用户程序可以访问受限资源并执行特权操作。这种切换是通过将用户程序的上下文保存起来,并加载内核的上下文来实现的。
- 一旦用户程序陷入内核,它会执行内核提供的相关功能,并等待内核完成请求的操作后返回结果。完成后,CPU会从内核态切换回用户态,并将结果返回给用户程序继续执行。
- 通过将用户程序和内核区分开来,Linux实现了安全性和稳定性的目标。用户程序无法直接访问和修改内核的数据结构,这样可以避免用户程序对系统造成破坏。同时,内核提供了一套系统调用接口,使得用户程序能够通过请求内核来获取系统资源和执行特权操作。
2.3 信号检测过程:
进程的生命周期中,会有很多次机会去陷入内核(中断,陷阱,系统调用,异常…),一定会存在很多次的机会进行内核态返回用户态
!
检查信号一些列的过程:
open调用一定会陷入内核~
调用接口执行open
的代码。
- 从磁盘当中读取文件的属性,在内核当中创建该文件的
sturct file
结构。 - 该文件匹配的
inode
,以及该文件所对应的路径信息全部都设置好。 - 将文件的地址填到文件描述符表的下标里,然后将下标返回进而就可以继续执行了。
实际上操作系统,在准备返回之前(open继续向后执行之前),其实不是简单的返回了,而是返回之前先查,查进程的信号列表。
- handler中如果执行的是自定义方法时:
为什么自定义方法只能用用户身份来执行?
- 是为了自定义方法是用户写的(用户提供的),是为了防止用户写了一段恶意代码,而内核身份权力又很大。
为什么不在自定义方法调用结束时直接返回?
- 如果直接返回了,open的返回值没有返回,也返回不了,所以不能直接由处理信号的逻辑直接跳转过去,是不允许的,也做不到。
- 系统调用要正常返回,是要把状态各方面要做切换,数据要返回,用户层到用户层无法做到这个工作,包括再用户层无法知道当时在哪被切到内核态的,不知道上下文数据,也没办法恢复,而且严重不推荐。
处理完走到内核当中,在内核里面特定的系统调用,特定的系统返回,把代码寄存器状态等方面恢复出来,让它继续跑到当前进程的代码里继续执行。
- handler中如果执行的是非自定义方法时:
pending和block都为0:
- 那么是没有信号要处理,直接返回到调用出正常运行了。
pending和block都是1:
- 操作系统做不了任何事情,无法被处理,无法被递达,操作系统照样返回。
pending为1,block是0:(默认)
- 去找handler表,如果是默认,一般指向操作系统中的默认处理方法,一般都是终止这个进程。
- 把这个代码不要在CPU上跑了,然后把代码全都释放掉。
- 设置好之后保留PCB,设置僵尸状态,将PCB内的信号编号填充成收到的信号编号。
- 此时退出码已设置,进程状态设置成Z状态,此时这个进程就退出了,也不需要返回了。
pending为1,block为0:(忽略)
- 去找handler表,发现handler表是
SIG_IGN
,就是忽略。 - 直接将该信号由1置为0,然后就处理完了这个信号,然后直接返回就好了。
- 返回对应处完成对应处理。
2.3 - 1 便捷记忆图
中间交点一定要在横线以下。
2.4 sigaction:
这个函数除了能处理普通信号,实时信号也能处理。
sigaction
函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1。
我们不考虑实时信号,所以有些字段我们不考虑:
基本使用:
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "获取到一个信号,信号编号是: " << signo << endl;
sigset_t pending;
// 永远都会正在处理2号信号
while (true)
{
cout << "." << endl;
sigpending(&pending);
for (int i = 1; i <= 31; i++)
{
if (sigismember(&pending, i))
cout << '1';
else
cout << '0';
}
cout << endl;
sleep(1);
}
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
// act.sa_handler = SIG_IGN;
// act.sa_handler = SIG_DFL;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
// 三号信号拦截
sigaddset(&act.sa_mask, 3);
// 对二号信号的捕捉
sigaction(2, &act, &oact);
// sigaction的更大意义在于,当我们在做信号处理时
// 操作系统不允许嵌套式的递归式的处理多个信号。
while (true)
{
cout << "main running" << endl;
sleep(1);
}
return 0;
}
- 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字。
- 这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
- 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用
sa_mask
字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
原因:
- 通过将当前信号加入信号屏蔽字,内核确保在信号处理函数执行期间,同一信号不会再次中断进程。
- 这种机制是必要的,因为信号处理函数是在异步上下文中执行的,即当信号发生时,处理函数会立即执行,而不管进程当前正在进行什么操作。
- 如果不使用信号屏蔽字来屏蔽同一信号的再次中断,就可能导致信号处理函数被递归调用,而且多个信号处理函数同时执行,可能会引起不可预测的行为或系统崩溃。
一直处理某个信号,查看pending表:
3. volatile关键字
看下面一段代码:
#include <stdio.h>
#include <signal.h>
// 保持内存的可见性,每次做检测必须从内存里拿
volatile int flags = 0;
void handler(int signo)
{
printf("更改flags: 0->1\n");
flags = 1;
}
int main()
{
signal(2, handler);
while (!flags);
printf("进程是正常退出的!\n");
return 0;
}
如果上述代码不带上volatile
,则不同编译器会有不同的结果:
用新一点的编译器:
gcc test.c -o test -O2
- 有的编译器发现在
main
执行流里发现没有对falgs
做任何修改。 - 高级别的编译器,会将这个
flags
值优化到寄存器里,从此往后再做while
循环检测时候,只做一件事,从这个寄存器里做数据读取,所以这个寄存器里的值永远不会被修改了。 - 编译器只能检测语法,不能检测逻辑。
volatile
关键字,告诉编译器,不准对flags
做任何优化,每次CPU计算的时候,拿内存中的数据,都必须在内存中拿!!
volatile
和const
可以同时修饰一个变量。
4. 子进程退出给父进程发信号
- 子进程退出的时候,不是同学们想的那样,默默的退出(X状态)
- 子进程退出的时候,自动给父进程发送
SIGCHLD
信号!!
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "子进程退出啦,我确实收到了信号: " << signo << " 我是: " << getpid() << endl;
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if (id == 0)
{
while (true)
{
cout << "我是子进程: " << getpid() << endl;
sleep(1);
}
exit(0);
}
// parent
while (true)
{
cout << "我是父进程: " << getpid() << endl;
sleep(1);
}
}
4.1 父进程回收多个子进程:
在之前的学习当中,我们知道子进程退出时,父进程等待有两种方式:阻塞等待和非阻塞等待。
一个是父进程阻塞住,直到等到子进程为止,一个父进程干自己的事情,轮询等。
我们又提供了一种方式,我们可以不像以前那样主动地去等待,因为子进程退出之后会给父进程发送SIGCHLD
信号,我们可以捕捉这个信号,并在自定义函数中来对子进程进行回收。
#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <signal.h>
#include <cassert>
using namespace std;
void FreeChld_(int signo)
{
assert(signo == SIGCHLD);
pid_t id = waitpid(-1, nullptr, 0);
if (id > 0)
{
cout << "父进程等待成功,chld pid: " << id << "信号为: " << signo << endl;
}
}
int main()
{
signal(SIGCHLD, FreeChld_);
pid_t id = fork();
if (id == 0)
{
// 子进程
int cnt = 5;
while (cnt)
{
cout << "我是子进程, pid: " << getpid() << " 当前的cnt: " << cnt-- << endl;
sleep(1);
}
cout << "子进程退出,进入僵尸状态" << endl;
exit(0);
}
// 不想非阻塞等待,就想让父进程做自己的事情
while (true)
{
cout << "我是父进程,我正在运行: " << getpid() << endl;
sleep(1);
}
// 父进程,都是要自己主动等待
// if(waitpid(id, nullptr, 0) > 0)
// {
// cout << "父进程等待子进程成功" << endl;
// }
return 0;
}
问题一:
但是上述回收子进程的方法还是有问题的,当我们有多个子进程同时要退出时,就会出现问题:
- 当父进程一直在创建子进程的时候,子进程将代码跑完每一个都退出了,而父进程一直在死循环。
- 当我们自定义的捕捉函数在等待的时候,我们只是等待了一个,如果是一批子进程同时退出,那么此时就是一次向父进程发送了好几个
SIGCHLD
信号。 - 但是Linux在设计信号捕捉的时候,当前正在处理的信号默认是被
block
的。 - 如果此时在
block
期间,也收到了很多子进程退出的信息,那么此时会有很多信号被丢失,进而导致回收子进程无法成功。 - 退出时,同时给父进程塞了好几个信号,当信号正在被处理时,剩下的那些信号被阻塞了。导致并没有把所有的僵尸进程全部回收。
for (int i = 0; i < 5; i++)
{
pid_t id = fork();
if (id == 0)
{
// 子进程
int cnt = 5;
while (cnt)
{
cout << "我是子进程, pid: " << getpid() << " 当前的cnt: " << cnt-- << endl;
sleep(1);
}
cout << "子进程退出,进入僵尸状态" << endl;
exit(0);
}
}
会有几个进程并没有被回收掉,成为僵尸进程:
解决办法是,我们可以将创建子进程的过程中间隔时间,间隔一秒中创建一个子进程,给足了自定义捕捉函数处理回收子进程的时间。
问题二:
如果,一批进程,前一部分进程是前5秒退出,后部分部进程20秒后再退出,这就会导致父进程回收第8个进程时,没有退出,而是阻塞住了,剩下3个子进程不退,父进程只能在那里等了。
因为HANG住了,我们要用非阻塞等待的方式来等待:
void FreeChld(int signo)
{
assert(signo == SIGCHLD);
while (true)
{
// waitpid 什么时候调用失败呢?如果你已经没有子进程了
// -1: 等待任意一个子进程
pid_t id = waitpid(-1, nullptr, WNOHANG);
if (id > 0)
{
cout << "父进程等待成功, chld pid: " << id << endl;
}
else if (id == 0)
{
// 还有子进程,但是现在没有退出
cout << "还有子进程,但是现在没有退出, 父进程要去忙自己的事情了" << endl;
break;
}
else
{
cout << "父进程等待所有子进程结束" << endl;
break;
}
}
}
我们来看一下waitpid
的返回值:
waitpid
第一个参数的情况:
4.2 父进程忽略子进程:
signal(SIGCHLD, SIG_IGN);
由于UNIX
的历史原因,要想不产生僵尸进程还有另外一种办法:
-
父进程调用
sigaction
将SIGCHLD
的处理动作置为SIG_IGN
,这样fork
出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。 -
系统默认的忽略动作和用户用
sigaction
函数自定义的忽略通常是没有区别的,但这是一个特例。 -
此方法对于
Linux
可用,但不保证在其它UNIX
系统上都可用。
子进程退出的时候,默认的信号处理就是忽略吗?
- 调用
signal/sigaction
,SIG_IGN
,意义在哪里呢? -
SIG_IGN
手动设置,让子进程退出,不要给父进程发送信号了,并且自动释放。
-
- 在系统层面上是不一样的,操作系统会识别到
SIGCHLD
信号是用户手动设置的,创建子进程属性时就设置好了,这个子进程退出的时候不用再给父进程发消息,并且自动释放。
- 在系统层面上是不一样的,操作系统会识别到
-
- 处理动作虽然是忽略,但是在系统层面上引起来的变化可不仅仅是主动忽略和自动忽略。
-
- 是函数在子进程创建时就设置好了。