一、基本概念
1、信号和信号量
就是类似有老婆和老婆饼之间的关系。— 毫无关联
2、基本结论
[!IMPORTANT]
- 信号可以随时产生。
- 进程能认识信号。
- 进程知道接收某个信号,怎么处理。
- 进程可能对信号进行延缓处理。(1、进程需要记录信号。2、进程需要在合适的时间进行处理信号)
- 信号:Linux系统中提供的一种以指定进程发送特定时间的方式。进程收到信号会做识别和处理。
- 信号的产生是异步的。
1~31号:普通信号。
34~64号:实时信号。
二、信号的产生
1、产生方式
[!IMPORTANT]
- 通过kill指令,向进程发送信号。— 指令
- 键盘可以给进程发送信号。— 外设
- 系统调用发送信号。—系统调用
- 软件条件。—特殊规则
- 异常。—异常
真正发送信号的是os。
2、系统调用
给指定进程发送指定信号
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
给当前进程发送指定信号
#include <signal.h>
int raise(int sig);
给当前进程发送终止信号
[!IMPORTANT]
注意点:
- abort不是系统调用,而是库函数。
- 终止进程,如果要是自定义捕捉信号的话,仍然会终止进程。给进程发送一个SIGABRD信号。
#include <stdlib.h>
void abort(void);
3、软件条件
管道
读关闭,写一直在进行 —> 操作系统会发送13号信号SIGPIPE给写进程。
闹钟
设置一个闹钟,当seconds时间后,操作系统会发送一个14号SIGALRM信号。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// alarm(0): 取消闹钟。
// 闹钟只触发一下。
// 返回值是上一个闹钟的剩余时间。
闹钟是一个结构体对象。
struct alarm
{
time_t expired;
pid_t pid;
func_t f;
}
4、异常
程序为什么会崩溃?
因为程序进行了非法访问等操作,导致os给进程发送信号,进程接收信号直接终止了程序。
程序崩溃了为什么会退出?
因为进程中,对该信号的默认处理方式就是退出进程。
程序崩溃了可以不退出吗?
可以不退出,前提是需要自己进行自定义捕捉该信号。
为什么自定义捕捉了,死循环?
进程不退出,进程要切换,所以要把cpu寄存器里面的上下文数据要被保存,过了一段时间后,该进程的上下文要恢复到cpu中,然后又报错,陷入死循环。
// 下述的例子中,描述了问题
#include <iostream>
#include <unistd.h>
#include <signal.h>
void excute()
{
int *a = nullptr;
*a = 10;
}
void func(int sig)
{
std::cout << "捕捉成功" << sig << "号信号" << std::endl;
sleep(1);
}
int main()
{
signal(SIGSEGV, func);
excute();
return 0;
}
为什么推荐终止进程?
释放进程的上下文数据,包括溢出标志数据或者其他异常数据。
cpu如何得知运算时正常的还是异常的?
cpu中eflag有个溢出标记位。
硬件上下文的保护和恢复
寄存器只有一套,但是寄存器里面的数据属于每一个进程。
三、信号的保存
1、概念
[!IMPORTANT]
- 信号递达:实际执行信号的处理动作。(1、默认,2、忽略,3、自定义捕捉)
- 信号未决:信号从产生到递达之间的状态。
- 信号阻塞:阻塞某一个信号。如果该信号产生了,将会一直处于未决状态(永不递达),直到解除阻塞。
一个信号阻塞和它有没有未决有关系吗?
无关。一个信号处于未决状态不能保证这个信号正在处于阻塞状态,也有可能是刚刚解除阻塞,但是还没递达。
阻塞 Vs 忽略
[!IMPORTANT]
阻塞:信号被阻塞就不会被递达。
忽略:信号递达之后的一种处理行为。
2、信号的管理
概念图
信号的管理就是通过这三张表来进行管理的。
系统调用
sigprocmask
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
// sigset_t是信号集,位图,标识有效、无效两种状态。
// block、pending都是sigset_t类型。
// sigset_t是Linux给用户提供的一个用户级类型。
sigpending
#include <signal.h>
int sigpending(sigset_t *set);
// 获取未决信号集
其余操作
解除屏蔽
[!IMPORTANT]
- 解除屏蔽,如果是pending(未决),一般会立即处理当前被解除的信号。
解除屏蔽,pending位图对应的信号也要被清零,在递达之前清零还是递达之后?
答:在递达之前进行清零。
3、如何理解信号的发送和保存?(浅)
进程在内核中有task_struct,在里面有很多成员变量,并且用位图来保存收到的信号!
发送1号信号,将第一个比特位由0置1。(由右到左,第1个比特位~第32位比特位)
发送信号:修改进程pcb中的信号的指定位图,0->1,写信号。
四、信号的处理
1、三种方式
[!IMPORTANT]
- 默认动作。进程处理信号一般都是默认动作 — 终止进程、暂停进程、忽略进程。
- 忽略动作。
- 自定义捕捉。
signal(signum, handler);// 自定义捕捉信号 signal(signum, SIG_IGN);// 忽略一个信号 signal(signum, SIG_DFL);// 信号的默认处理动作
下图的action就是每个信号的默认动作。Core、Term都是进行终止进程。
2、默认的处理方式
Core、Term
[!IMPORTANT]
- Term:异常终止。
- Core:异常终止,但是会形成一个debug文件。为什么平常的时候没有,因为默认是关闭的。debug文件存储的是进程退出时候的镜像数据(核心转储)
// core--是协助debug的文件。 // 查看core ulimit -a // 打开core,使云服务器在程序崩溃时自动生成debug文件 ulimit -c // 关闭core ulimit -c 0 // 用gdb来对core生成的debug文件进行解析,得到错误位置 core-file core
云服务器为什么为关闭Core形成debug文件的功能呢?
因为每次异常的时候,都会生成一个debug文件,假如遇到一个问题,电脑一直重启失败,磁盘中就会有大量的磁盘文件。这样就不仅仅会出现重启错误,还会使磁盘爆满。
Core dump标记位
[!IMPORTANT]
- 当形成Core dump文件(也就是core形成的debug文件)时,为1;没有生成时,为0。
- Term和这个标记位没有任何的联系,无论是不是Term处理方式,Core dump标记位始终为0。
3、自定义捕捉信号
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
// handler的参数就是signum。
// 含义是捕捉了signum信号,然后执行handler方法。
---------------------------------------------------------------------------------------------------
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
// act是一个输入型参数,oldact是一个输出型参数
// oldact输出的是之前的信号的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_mask:屏蔽的信号集。9号信号,禁止屏蔽
// sa_flags:一个标志字段,用于修改 sigaction 的行为。
// 1、SA_NODEFER:防止自动阻塞当前信号(即,即使设置了 sa_mask,当前信号也不会被阻塞)。
// 2、SA_RESETHAND:在信号处理函数返回后,将信号的处理方式重置为默认处理(即,sa_handler 被设置为 SIG_DFL)。
// 3、SA_RESTART:如果信号处理函数被中断的系统调用是自动重启的(如 read、write 等),则尝试重启该调用。
// 4、SA_SIGINFO:使用 sa_sigaction 而不是 sa_handler 作为信号处理函数。
如果我们对2号信号进行捕捉:
当前如果正在对2号信号进行处理,默认2号信号会被自动屏蔽。
对2号信号完成的时候,会自动解除对2号信号的屏蔽。
[!IMPORTANT]
- 9号信号不允许自定义捕捉。
- 自定义捕捉信号函数,只要捕捉一次,后续一直有效。
- 一直不产生signum,handler一直不执行。
- 可以对更多信号进行捕捉。
- 2号信号SIGINT:是ctrl + c,给目标进程发送二号进程,默认处理方式是终止进程。
4、信号处理的时间
信号不会被立刻处理,而是在合适的时间处理。
合适的时间是:进程从内核态返回到用户态的时候处理。
5、为什么进程要在内核和用户之间来回进行切换?
如果用户级函数在内核中进行处理的话,用户可能会借着内核执行越权的事情。所以要在内核和用户之间进行切换,防止用户越权操作。
6、信号的捕捉流程
五、内核态 Vs 用户态
1、地址空间
- 对于32位的机器而言,有2^32字节 = 4GB的地址空间(虚拟地址)。[0, 3]GB是用户空间([0, 3]是区间),(3, 4]GB是内核空间。
- 不管有多少个进程,内核级页表总是只有一份。(内核级页表是内核的虚拟地址和物理内存之间的映射关系)
- 我们访问os,其实还是在我们的地址空间中进行的,和我们访问库函数没有区别!
- os不相信任何用户,用户访问(3, 4]地址空间的时候,要受到一定的约束!不能直接访问操作系统的数据,只能通过系统调用。
2、键盘输入的过程
[!IMPORTANT]
上述的过程感觉和信号很相似!
我们学习的信号就是模拟中断实现的。
信号:纯软件。
中断:软件+硬件。
外部中断的目的?
让cpu内部寄存器形成一个中断号的数字。
3、如何理解系统调用?
在系统中,会有一张系统调用的表(函数指针类型)。 — sys_call_tablep[]。
执行系统调用:1.系统调用号(下标),2.系统调用函数指针数组
系统调用是cpu不产生中断号,而是在一个寄存器内直接生成一个地址(比如是0x80),进行中断并取出系统调用号,然后去中断向量表中寻找0x80,通过系统调用号,去执行系统调用。
4、os是如何运行的?
操作系统的本质就是一个死循环 + 时钟中断不断调度系统任务。
os不是不相信任何用户吗,用户如何直接跳转到(3,4]GB空间?
必须在特定条件下,才能跳转过去。
在cpu中有一个ecs的寄存器 — 代码区的范围。其中有两个比特位,如果是0则是cpu可以执行内核级代码,如果是3则是cpu可以执行用户级代码。所以,当这两个比特位由3–>0,cpu才允许访问外部((3, 4]GB)。
六、可重入函数
对于可重入、不可重入描述是函数的特征,而不是优缺点。
就比如说下图:当主函数正在执行对链表的插入时,进程接收到信号,启动对应的信号处理函数。假如该信号处理函数里面也调用了插入函数,完成了链表的插入。当返回原函数的时候,继续向下执行,这样会导致内存泄漏问题。对于类似于这种函数,叫做不可重入函数。
可重入函数是一种能够在多个任务或线程中安全调用的函数。在并发编程中,尤其是在多线程环境下,可重入性是一个重要的概念。如果一个函数在多线程环境中被同时调用时,能够正确地处理多个线程的输入,而不会导致数据竞争、死锁或其他并发问题,那么这个函数就是可重入的。
我们学过的大部分函数都是不可重入函数。
七、volatile关键字
保持内存的可见性。
// 编译器是有优化级别的
// 对于while(!gflag);
// 在底层,没有优化的话,运行逻辑应该是:cpu从内存中加载数据到cpu中,cpu进行逻辑运算,然后再根据运算结果,继续往下执行。
// 但是,如果编译器达到一定的优化程时,编译器发现,main函数中,没有修改gflag的代码,cpu就只会从内存中读取一遍,然后一直判断,这样的话,就算你接收到信号了,也不会退出循环。
// 不信的话,可以试试:g++ -o main.cc -O1
// 为了解决这样的问题,volatile int gflag = 0; 这样的话,无论优化级别是多少,cpu都会从内存中读取数据,不会只进行读取一次。
#include <iostream>
#include <unistd.h>
#include <signal.h>
int gflag = 0;
void changedata(int sig)
{
std::cout << "get a signo" << sig << ", change gflag 0->1"<<std::endl;
gflag = 1;
}
int main() // main 函数中没有对gflag进行修改
{
signal(2, changedata);
while (!gflag);
std::cout << "process quit normal" << std::endl;
return 0;
}
八、SIGCHLD信号
子进程退出时,会给父进程发送信号 — SIGCHLD信号。
1、父进程不用刻意的等待子进程,可以去执行别的任务。
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
void func(int sig)
{
pid_t rid = waitpid(-1, nullptr, 0);
if (rid > 0)
{
std::cout << "接收子进程成功" << std::endl;
}
}
void DoOtherThing()
{
std::cout << "父进程执行其他任务" << std::endl;
}
int main()
{
signal(SIGCHLD, func);
pid_t id = fork();
if (id == 0)
{
// child
sleep(3);
std::cout << "child excute" << std::endl;
exit(1);
}
// father
while (true)
{
DoOtherThing();
sleep(1);
}
return 0;
}
2、上述代码有问题
问题一:如果不止一个子进程的话,就会有问题
// 只需要循环等待即可
void func(int sig)
{
while (true)
{
pid_t rid = waitpid(-1, nullptr, 0);
if (rid > 0)
{
std::cout << "接收子进程成功" << std::endl;
}
else if (rid < 0)
{
std::cout << "处理完毕" << std::endl;
break;
}
}
}
问题二:如果有多个子进程,部分退出,部分不退出呢?
// waitpid使用非阻塞等待
void func(int sig)
{
while (true)
{
pid_t rid = waitpid(-1, nullptr, WNOHANG);
if (rid > 0)
{
std::cout << "接收子进程成功" << std::endl;
}
else if (rid < 0)
{
std::cout << "处理完毕" << std::endl;
break;
}
else // 部分退出的已经退出完了,也可以退出了
{
//...
break
}
}
}
“处理完毕” << std::endl;
break;
}
}
}
### 问题二:如果有多个子进程,部分退出,部分不退出呢?
```c++
// waitpid使用非阻塞等待
void func(int sig)
{
while (true)
{
pid_t rid = waitpid(-1, nullptr, WNOHANG);
if (rid > 0)
{
std::cout << "接收子进程成功" << std::endl;
}
else if (rid < 0)
{
std::cout << "处理完毕" << std::endl;
break;
}
else // 部分退出的已经退出完了,也可以退出了
{
//...
break
}
}
}