信号
互斥等4个概念
都能看见的资源:公共资源
- 互斥:如何一个时刻,都只允许一个执行流在进行共享资源的访问—加锁
- 任何一个时刻都只允许一个执行流在进行访问的共享资源,叫做临界资源
- 临界资源是要通过代码访问的,凡是访问临界资源的代码,叫做临界区
- 原子性:要么不做,要么做完。只有两种确定状态的属性。
信号量
例如:看电影买票,买了票才能进去看电影,且确保电影票不会超过座位资源数量而导致冲突,如果放映厅是顶级VIP放映厅,只有一个座位,就形成互斥。
信号量/信号灯本质就是一个计数器int count=? ?
,描述资源数量的计数器
如果这个计数器是1,则是二元信号量,互斥功能:将临界资源独立使用!
因为不同进程可以看到同一个计数器count(资源),所以信号量被归类到了进程间通信
任何一个执行流,想访问临界资源中的一个子资源的时候,不能直接访问,进程要通过执行代码来申请,所有的进程得先看见信号量!
P操作: 先申请信号量资源,if(count>0)count- -;else 挂起阻塞
只要申请成功,就一定能拿到一个子资源。
然后进入自己的临界区,访问对应的临界资源。
V操作: 释放信号量资源,count++
计数器增加,则表示对应的资源进行了归还
对于计数器count的加减操作一定是原子的!
接口
查看信号量ipcs -s
删除信号量ipcrm -s
公共拥有的结构体ipc_perm
理解IPC
每个ds结构中都有一个ipc_perm结构体,通过ipc_perm指针数组形成多态
什么是信号
1、程序员设计进程的时候,早就已经设计了对信号的识别能力,进程在没有收到信号的时候就能辨别一个信号如何被处理了。
2、当一个信号产生时,进程可能在做优先级更高的事情,无法立即处理这个信号,需要在合适的时候处理信号
3、所以一个进程收到信号时,如果没有立即处理这个信号,需要进程具有记录信号的能力:信号产生———>**时间窗口(保存信号)**———>信号处理
4、发送信号本质就是写入信号,直接修改特定进程的信号位图中的特定比特位0->1:
task_struct
数据内核结构必定存在一个位图结构来管理信号,且只能由OS进行修改,无论后面有多少种信号产生方式,最终都必须让OS来完成最后的发送过程!
1~31个信号是只有保存无产生的信号,用int32位二进制表示;其余都是实时信号
5、信号的产生对于程序来讲是异步的
处理信号的方式:1、默认动作 2、忽略信号 3、用户自定义捕捉(handler)
信号的产生
信号的产生:键盘、系统调用、指令、软件条件、硬件异常
在输入的时候,键盘被按下,键盘通过硬件中断的方式,通知OS键盘被按下
系统调用产生信号
1.signal—— 对指定的信号设定自定义处理动作
signal(int sig, void (*func)(int))
参数:
sig:要发送的信号
func:自定义操作的函数
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <iostream>
using namespace std;
void handler(int signo)//自定义操作
{
printf("catch a signal : %d\n", signo);
}
int main()
{
signal(2, handler);
while(1)
{
cout<<"我的PID是:"<<getpid()<<endl;
sleep(2);
}
return 0;
}
Ctrl+C执行的是2号信号,我们自定义2号信号为handler自定义操作,当我们键盘产生信号时就会执行自定义操作,但是没有给Ctrl+Z自定义操作,所以可以退出。
- raise——给进程自己发送信号
int raise(int sig);
参数:
sig:要发送的信号
返回值:
成功返回0,失败返回-1
和kill比较:
raise函数相当于kill(getpid(), sig)
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <iostream>
using namespace std;
void handler(int signo)
{
printf("catch a signal : %d\n", signo);
}
int main()
{
signal(2, handler);
while(1){
raise(2);
sleep(1);
}
return 0;
}
3.abort——给自己发送指定的信号(发送6号信号)
void abort(void);
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <iostream>
using namespace std;
int main()
{
while(1){
abort();
}
return 0;
}
3.通过软件条件产生
管道如果读端不读了,存储系统会发生SIGPIPE 信号给写端进程,终止进程。这个信号就是由一种软件条件产生的,这里再介绍一种由软件条件产生的信号SIGALRM(时钟信号)
alarm——设定一个闹钟,操作系统会在闹钟到了时送SIGALRM信号给进程,默认处理动作是终止进程
#include <unistd.h>
unsigned alarm(unsigned seconds);
参数:
second:设置时间,单位是s
返回值:
0或者此前设定的闹钟时间还余下的秒数
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <iostream>
using namespace std;
int main()
{
alarm(1);
int cnt=0;
while(1)
{
cnt++;
cout << cnt << endl;
}
return 0;
}
“闹钟“就是一个软件实现的,任意一个进程都可以通过alarm系统调用在内核中设置闹钟,OS内可能存在很多的闹钟,操作系统需要管理这些闹钟,即先描述,再组织
4.通过硬件产生的异常(CPU异常和MMU异常)
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int main()
{
// 由软件条件产生信号 alarm函数和SIGPIPE
// CPU运算单元产生异常,内核将这个异常处理为SIGFPE信号发送给进程
int a = 10;
int b = 0;
printf("%d", a/b);
return 0;
}
CPU产生异常:发生除零错误,CPU运行单元会产生异常,内核将这个异常解释为信号,最后OS发送SIGFPE信号给进程:8) SIGFPE
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int main()
{
// MMU硬件产生异常,内核将这个异常处理为SIGSEGV信号发送给进程
int* p = NULL;
printf("%d\n", *p);
return 0;
}
MMU产生异常:当进程访问非法地址时,mmu想通过页表映射来将虚拟转换为物理地址,此时发现页表中不存在该虚拟地址,就会产生异常,OS将异常解释为信号,然后发送给进程:
11) SIGSEGV
Tips:所有信号的产生都是借助OS向目标进程发送信号,即向目标进程PCB写入信号位图
核心转储文件 core.pid
Term:就是直接终止,没有多余动作
Core:终止前会先进行核心转储,然后再终止进程
使用ulimit -a
查看所有资源限定设置,可以看到,核心转储文件默认是关闭的。
核心转储文件:OS可以将该进程在异常的时候,核心代码部分进行核心转储,将内存中进程的相关数据,全部dump到磁盘中,方便异常后进行调试,一般在云服务器上看不到,默认是关闭的。打开指令:
ulimit -c 1024
gdb时,输入核心转储文件即可自动定位问题代码:**(gdb) core-file core.pid**
为什么核心转储文件一直是被关闭的呢?
就算一个程序就几行代码,它的转储文件大小都有几百KB,如果是一个更大的程序,挂掉后又被重新挂起,就会反复生成巨大的转储文件,导致把硬盘空间挤爆。
关闭指令:ulimit -c 0
core dump标志位
int status = 0;
waitpid(id, &status, 0);
cout << "exit code: " << ((status>>8) & 0xFF) << endl;//退出码
cout << "exit signal: " << (status & 0x7F) << endl; //退出信号
cout << "core dump flag: " << ((status>>7 & 0x1)) << endl;
当开启时,报错后core dump为1,反之为0
信号的保存
概念
- 实际执行信号的处理动作称为信号递达
- 信号递达的三种方式:默认、忽略和自定义捕捉
- 信号从产生到递达之间的状态(保存起来),称为信号未决(Pending)。
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作
注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号在内核图中的表示
OS发生信号给一个进程,此信号不是立即被处理的,那么这个时间窗口中,信号就需要被记录保存下来,那么信号是如何在内核中保存和表示的呢?
pending表:如果该位为1,代表收到该信号,处于未决状态,为0代表还没收到该信号或者收到信号已经被递达了;它是一个32位无符号整数。
uint32_t pending=0;pending |=(1<<(signo-1))
block表:每个信号对应1位,如果该位为1,那么代表该信号被阻塞,为0代表不被阻塞
handler表:代表对该信号递达动作,默认(SIG_DFL)、忽略(SIG_IGN)、自定义捕捉,其中自定义捕捉就是用户自定义的函数。handler表本质其实是函数指针数组,存放的是用户自定义函数的指针,该数组的下标,表示信号编号。
信号集
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
}__sigset_t
sigset_t: 未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,也被定义
一种数据类型。这个类型可以表示每个信号状态处于何种状态(是否被阻塞,是否处于未决状态)
阻塞信号集也叫做当前进程的信号屏蔽字,这里的“屏蔽”应该理解为阻塞而不是忽略。
例如:修改127位置的状态,首先127/(sizeof(unsigned long int)*8)
定位下标,这里的unsigned long int
以4字节为标准,得到下标为3,然后对val进行增删查改操作:
XXX->__val[3] & (1<<(127%(sizeof(unsigned long int)*8)))
如需删除,&
与上~
取反即可
信号集操作函数的原型
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
- sigemptyset: 初始化set指向的信号集,将所有比特位置0
- sigfillset: 初始化set指向的信号集,将所有比特位置1
- sigaddset: 把set指向的信号集中signum信号对应的比特位置1
- sigdelset: 把set指向的信号集中signum信号对应的比特位置0
- sigismember: 判断signum信号是否存在set指向的信号集中(本质是信号判断对应位是否为1)
Tips:在实现这些函数之前,需要使用sigemptyset或sigfillset对信号集进行初始化。前四个函数的返回值是成功返回0,失败返回-1。最后一个函数的返回值是真返回0,假返回-1
1.sigprocmask——阻塞信号集操作函数
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
功能:
读取或更改进程的信号屏蔽字
参数:
how:三个选项:
SIG_BLOCK
:把set中的信号屏蔽字添加到进程的信号屏蔽字中,mask = mask|set
SIG_UNBLOCK
:把set中的信号屏蔽字在进程信号屏蔽字的那些去掉,mask = mask&~set
SIG_SETMASK
:设置当前进程的信号屏蔽字为set,mask = set
set:如果为非空指针,则根据how参数更改进程的信号屏蔽字
oset:如果为非空指针,将进程原来的信号屏蔽字备份留在oset中
返回值:
成功返回0,失败返回-1
2.sigpending——未决信号集操作函数
int sigpending(sigset_t *set);
功能:
读取进程的未决信号集
参数:
set:读取当前进程的信号屏蔽字到set指向的信号屏蔽中
返回值:
成功返回0,失败返回-1
实例演示:把进程中信号屏蔽字2号信号进行阻塞,然后隔1s对未决信号集进行打印
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <iostream>
using namespace std;
static void show_pending(const sigset_t& pending)
{
for(int signo=31;signo>=1;--signo)
{
//判断signum信号是否存在set指向的信号集中(本质是信号判断对应位是否为1)
if(sigismember(&pending,signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
int main()
{
// 1. 先尝试屏蔽指定的信号
sigset_t set,oset;
sigset_t pending;
//1.1使用系统函数对信号集进行初始化
sigemptyset(&set);
sigemptyset(&oset);
sigemptyset(&pending);
//阻塞2号信号
//1.2添加要屏蔽的信号
sigaddset(&set,2);
//1.3开始屏蔽,设置进内核, 前面的代码没有影响当前进程,从这段开始影响
//oset保存原来的信号
sigprocmask(SIG_BLOCK,&set,&oset);
//2.遍历打印pending信号集
while(1)
{
//2.1初始化
sigemptyset(&pending);
//2.2获取它
sigpending(&pending);
//2.3打印
show_pending(pending);
sleep(1);
}
}
进程收到2号信号时,且该信号被阻塞,处于未决状态,没有被递达,未决信号集中2号信号对应的比特位由0置1,所以代码一直运行
然后我们在进行运行10s后,我们将信号屏蔽字中2号信号解除屏蔽
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <iostream>
using namespace std;
static void show_pending(const sigset_t& pending)
{
for(int signo=31;signo>=1;--signo)
{
//判断signum信号是否存在set指向的信号集中(本质是信号判断对应位是否为1)
if(sigismember(&pending,signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
int main()
{
// 1. 先尝试屏蔽指定的信号
sigset_t set,oset;
sigset_t pending;
//1.1使用系统函数对信号集进行初始化
sigemptyset(&set);
sigemptyset(&oset);
sigemptyset(&pending);
//阻塞2号信号
//1.2添加要屏蔽的信号
sigaddset(&set,2);
//1.3开始屏蔽,设置进内核, 前面的代码没有影响当前进程,从这段开始影响
//oset保存原来的信号
sigprocmask(SIG_BLOCK,&set,&oset);
//2.遍历打印pending信号集
int cnt=10;
while(1)
{
//2.1初始化
sigemptyset(&pending);
//2.2获取它
sigpending(&pending);
//2.3打印
show_pending(pending);
sleep(1);
//解除屏蔽
**cnt--;
if(cnt==0)
{
cout << "屏蔽信号解除" << endl;
sigprocmask(SIG_UNBLOCK,&set,&oset);
}**
}
}
2号信号解除阻塞后,信号被递达了,进程终止
信号的处理
信号处理可以不是立即处理的,而是在“合适”的时候,如果一个信号之前被阻塞(block),当它解除block的时候,对应的信号会被立即递达!
为什么信号是在”合适“的时候处理的呢?
信号的产生是异步的,当前进程可能正在做更重要的事情!当进程从内核态切换回用户态的时候,进程在OS的指导下,才进行信号的检测与处理:默认、忽略、自定义捕捉。
- 用户态: 处于⽤户态的 CPU 只能受限的访问内存,用户的代码,并且不允许访问外围设备,权限比较低
- 内核态: 处于内核态的 CPU 可以访问任意的数据,包括外围设备,⽐如⽹卡、硬盘等,权限比较高
CR3寄存器
操作系统中有一个cr寄存器来记录当前进程处于何种状态
0 表示正在运行的进程执行级别是内核态
3 表示正在运行的基础执行级别是用户态
用户无法直接更改执行级别,OS提供的所有系统调用,内部在正式执行调用逻辑的时候,会去修改执行级别
内核级页表
进程空间分为用户空间和内核空间。之前的页表都是指用户级页表,其实还有内核级页表。
进程的用户空间是通过用户级页表映射到物理内存上,内核空间是通过内核级页表映射到物理内存上
上面的图主要说明:进程处于用户态访问的是用户空间的代码和数据,进程处于内核态,访问的是内核空间的代码和数据。
1、所有进程的[0,2]GB是不同的,每一个进程都要有自己的用户级页表
2、所有进程的[3,4]GB是相同的,每一个进程都可以看到同一张内核级页表,所有进程都可以通过统一的窗口看到同一个OS
3、OS运行的本质:其实都是在进程的地址空间内运行的!
4、所谓的系统调用的本质,其实就如同调用.so中的方法,在自己的地址空间中进行函数跳转并返回即可!
进程有不同的用户空间,但是只有一个内核空间,不同进程的用户空间的代码和数据是不一样的,但是内核空间的代码和数据是一样的。
进程是如何调度的?
- OS是软件,本质是一个死循环
- OS时钟硬件,每隔很短的时间向OS发送时钟中断
时钟中断——OS要执行对应的中断处理方法:检测当前进程的时间片schedule()
,进程被调度,就是时间片到了,然后讲进程对应的上下文等进行保存并切换,选择其他合适的进程。
信号捕捉的过程
从上面的图可以看出,进程是在返回用户态之前对信号进行检测,检测pending位图,根据信号处理动作,来对信号进行处理。这个处理动作是在内核态返回用户态后进行执行的
如果信号处理动作是用户自定义的函数,简写如下:
其中4个绿点是4次状态切换,4个红点对应上图的1、2、4、5执行步骤,3则是信号检测过程
sigaction—信号捕捉
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
功能:
可以读取和修改与指定信号相关联的处理动作
参数:
signum: 要操作的信号
act:一个结构体
sa_handler:SIG_DFT、SIG_IGN和handler(用户自定义处理函数)
sa_sigaction:实时信号处理的函数,我们不关心
sa_mask:一个信号屏蔽字,里面有需要额外屏蔽的的信号
sa_flags:包含一下选项,这里我们给0
sa_restorer:我们这里不使用
act:如果不为空,根据act修改信号处理动作
oact: 如果不为空,备份原来的信号处理动作给oact
返回值:
成功返回0,失败返回-1
act结构体如下:
struct sigaction {
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
实例演示:
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
printf("catch a signal: %d\n", signo);
}
int main()
{
struct sigaction act, oact;
act.sa_flags = 0;// 选项 设置为0
sigfillset(&act.sa_mask);
act.sa_handler = handler;
// 对2号信号修改处理动作
sigaction(2, &act, &oact);
while (1){
raise(2);
sleep(1);
}
return 0;
}
可重入函数
main函数调用insert()
向head中插入节点node1,插入操作分为两步,刚做完第一步时,因为硬件中断使进程切换到内核,再次返回用户态之前检查到有信号待处理,于是切换到sighandler()
,sighandler()
也调用insert()
向同一个head中插入节点node2,插入操作的两步都做完之后从sighandler()
返回内核态,再次回到用户态就从main函数调用的insert()
中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler()
先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了
像上例这样,insert()
被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert()
访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为
“不可重入函数”,反之,如果一个函数只访问自己的局部变量或参数,则称为“可重入(Reentrant) 函数”。
为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
在多线程中,每个线程虽然是资源共享,但是他们的栈却是独有的,所以说局部变量不会造成错乱
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile关键字
#include <stdio.h>
#include <signal.h>
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
using namespace std;
int quit=0;
void handler(int signo)
{
cout << signo << "号信号,正在被捕捉!" << endl;
cout << "quit:" << quit ;
quit=1;
cout << "->" << quit << endl ;
}
int main()
{
signal(2,handler);
while(!quit) ;
cout << "注意,我是正常退出的" << endl;
return 0;
}
正常编译都是使用g++ -o $@ $^ -std=c++11
得到的结果如下:输入ctrl+C
但如果编译的时候带上O3级别的优化呢?g++ -o $@ $^ -O3
再次运行如下:
改变了也不会退出,这是为什么呢?
quit存在内存中,优化过后quit在main中没有变化,编译器就把quit放入寄存器cache中,此后都是读取cache中的quit,再写入内存中的quit,但循环判断条件的quit是在cache中一直不变为0的
volatile:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
加上这个关键字后volatile int quit=0;
,就能正常运行了
SIGCHILD信号
子进程在死亡的时候,会向父进程发送SIGCHILD信号,不过父进程默认是忽略的,使用man 7 signal
查看
用以上知识检查看看是不是17号信号
#include <stdio.h>
#include <signal.h>
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
printf("pid: %d, %d 号信号,正在被捕捉!\n", getpid(), signo);
}
void Count(int cnt)
{
while (cnt)
{
printf("cnt: %2d\r", cnt);
fflush(stdout);
cnt--;
sleep(1);
}
printf("\n");
}
int main()
{
signal(17,handler);
printf("我是父进程, %d, ppid: %d\n", getpid(), getppid());
pid_t id=fork();
if(id==0)
{
printf("我是子进程, %d, ppid: %d,我要退出啦\n", getpid(), getppid());
sleep(20);
exit(1);
}
//保持父进程在运行
while (1)
sleep(1);
return 0;
}
运行结果如下
这样的意义在于,以前父进程被动式的等待,例如阻塞等待子进程,或者主动去“问问子进程”,即非阻塞式等待,现在我们可以让子进程叫我们了!
所以我们可以把handler写成如下形式
void handler(int signo)
{
// 1. 我有非常多的子进程,在同一个时刻退出了 【只需要循环处理】
// 2. 我有非常多的子进程,在同一个时刻只有一部分退出了 【必须非阻塞式等待,因为操作系统不知道你有多少个子进程要退出
//如果你没退出,在这里就会造成死循环】
//waitpid第一个参数是pid,这里是多个子进程,所以设置-1,意思是会等待任意一个子进程
printf("捕捉到一个信号: %d, who: %d\n", signo, getpid());
sleep(5);
// 5个退出,5个没退
while (1)
{
pid_t res = waitpid(-1, NULL, WNOHANG);
if (res > 0)
{
printf("wait success, res: %d, id: %d\n", res, id);
}
else break; // 如果没有子进程了?
}
}
由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程
signal(17,SIG_IGN);
这里的手动设置的IGN和之前默认的IGN是不一样的