信号量
- 几个基本概念
- 临界资源
- 临界区
- 原子性
- 互斥
- 信号量
- 后台进程
- 前台进程
- 信号
- 储存信号
- 处理信号(信号捕捉)
- 发送信号
- 1、键盘产生:
- 2、系统调用接口发送信号
- 3、由软件条件产生信号
- 4、硬件异常发送信号
- 内核中的信号量
- **信号量在内核中的数据结构**
- **信号集操作函数**
- 信号的检查和处理
- 可重入函数
- volatile关键字
几个基本概念
临界资源
临界资源:被多个进程能够看到的资源
如果没有对临界资源进行任何保护,对于临界资源的访问,双方进程在进行访问的时候,就都是乱序的,可能会因为读写交叉而导致的各种乱码、废弃数据、访问控制访问的问题
临界区
临界区:对多个进程而言,访问临界资源的代码
我们写的进程的代码中,有大量的代码,只有一部分代码,会访问临界资源
原子性
原子性:一件事情要么不做,要么做完了,没有中间状态
互斥
互斥:任何时刻,只允许一个进程,访问临界资源
信号量
信号量的本质就是计数器,且这个计数器的操作是原子性的
信号量对应的操作:
申请资源:P操作
释放资源:V操作
共享内存不具有访问控制,但可以通过信号量进行对资源的保护
共享内存
shmget //创建
shmctl //删除
shmat //关联
shmdt //去关联
消息队列
msgget
msgctl
msgsnd
msgrcv
信号量
semget
semctl
semop +1 P //申请资源
semop -1 V //释放资源
查看
ipcs -m/-q/-s //共享内存/消息队列/信号量
删除
ipcrm -m/-q/-s //共享内存/消息队列/信号量
共享内存、消息队列、信号量的生命周期都是随内核(操作系统)的
管道文件的生命周期都是随进程的
对于进程来讲,即便信号还没有产生,进程已经具有识别和处理这个信号的能力了。
后台进程
./myproc &
后台进程运行时,可以使用bash
进程,后台进程不能使用ctrl+c
终止,前台进程可以使用ctrl+c
终止
jobs //查看后台进程
fg 作业号 //把后台进程提到前台
前台进程
./myproc
//前台任务,运行时不能使用bash进程
kill -l //查看信号
man 7 'singal' //查看信号详细信息
其中131为普通信号,3464为实时信号
可以同时运行一个前台进程和若干个后台进程
信号
因为信号产生是异步的(信号随时都有可能产生),当信号产生的时候,对应的进程可能正在做更重要的事情,我们进程可以暂时不处理这个信号,进程暂时不处理信号,就需要先将信号先储存起来
储存信号
那么信号如何储存?
使用位图记录信号量(在进程的
task_struct
中)1、有没有产生【比特位的内容1/0】
2、什么信号产生【比特位的位置】
我们要对信号进行存储就需要对进程的
task_struct
中记录信号量的位图进行修改,而task_struct
是在内核空间中的,那么只有os
可以对task_struct
做修改,无论信号如何产生,都是os
帮我们进行设置的处理信号(信号捕捉)
处理信号有三种动作
1、默认动作
2、忽略
3、自定义动作
sighandler_t signal(int signum, sighandler_t handler); //设置信号的自定义方法
void handler(int signum) { cout<<"捕捉到"<<signum<<"号信号"<<endl; } int main() { signal(SIGINT,handler); while(1) { cout<<"hello linux"<<endl; sleep(1); } return 0; }
ctrl+c
就是给前台进程发送2号信号(终止自己)注意:
1、无法对9号和19号信号设置自定义动作,忽略,阻塞
2、6号信号虽然可以设置自定义动作,但执行完自定义动作后依旧会执行默认动作
发送信号
用户层产生信号方式
1、键盘产生:
ctrl+c
:发送2号信号
ctrl+\
:发送3号信号我们在之前说过,无论信号如何产生,都是由
os
来发送的,本质上发送信号就是修改task_struct
中的位图2、系统调用接口发送信号
int kill(pid_t pid,int sig) //给任意进程发送任意信号
void handler(int signum) { cout<<"捕捉到"<<signum<<"号信号"<<endl; exit(1); } int main() { signal(SIGINT,handler); cout<<"进程运行中 pid:"<<getpid()<<endl; cout<<"等待3秒后,发送2号信号,进程退出"<<endl; sleep(3); kill(getpid(),SIGINT); //给当前进程发送2号信号 return 0; }
int raise(int sig) //给自己发送任意信号
void handler(int signum) { cout<<"捕捉到"<<signum<<"号信号"<<endl; exit(1); } int main() { signal(SIGINT,handler); cout<<"进程运行中 pid:"<<getpid()<<endl; cout<<"等待3秒后,发送2号信号,进程退出"<<endl; sleep(3); raise(SIGINT); //给当前进程发送2号信号 return 0; }
void abort(void) //向自己发送SIGABRT信号(终止进程)
void handler(int signum) { cout<<"捕捉到"<<signum<<"号信号"<<endl; exit(1); } int main() { signal(SIGABRT,handler); cout<<"进程运行中 pid:"<<getpid()<<endl; cout<<"等待3秒后,发送6号信号,进程退出"<<endl; sleep(3); abort(); return 0; }
3、由软件条件产生信号
unsigned int alarm(unsigned int seconds); //调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号 //该信号的默认处理动作是终止当前进程
int main() { cout<<"进程运行中 pid:"<<getpid()<<endl; cout<<"等待3秒后,发送14号信号,进程退出"<<endl; alarm(3); sleep(1000); return 0; }
4、硬件异常发送信号
首先我们需要知道进程崩溃的本质就是该进程收到了异常信号
一般情况,导致进程崩溃主要是除零错误和越界野指针问题
①除零错误
void handler(int signum) { cout<<"捕获到"<<signum<<"号信号"<<endl; exit(1); } int main() { for(int i=1;i<32;i++) { signal(i,handler); } int num=10/0; return 0; }
②越界野指针问题
野指针
void handler(int signum) { cout<<"捕获到"<<signum<<"号信号"<<endl; exit(1); } int main() { for(int i=1;i<32;i++) { signal(i,handler); } int* num=nullptr; *num=1000; return 0; }
越界
void handler(int signum) { cout<<"捕获到"<<signum<<"号信号"<<endl; exit(1); } int main() { for(int i=1;i<32;i++) { signal(i,handler); } int arr[10]; for(int i=10;i<10000;i++) { arr[i]=100; cout<<i<<endl; } return 0; }
**注意:**我们的进程发生崩溃退出,是因为操作系统给进程发信号,进程合适的时候对于这个信号做出默认动作,终止进程,如果我们对信号设置(不终止进程的)自定义动作,这个进程就不会终止
那么进程发生崩溃时,是如何收到异常信号的?
①除零
在计算机中,运算都是在
CPU
中进行的,在CPU
的内部有一个状态寄存器(硬件),这个状态寄存器的作用是检查计算是否出错当
CPU
进行计算时,发生除零错误,CPU
内部的状态寄存器就会被设置为:有报错,浮点数错误
OS
就会根据这个状态寄存器得知CPU
内有报错,OS
就会构建信号,并把这个信号发送给出错的这个进程,进程会在合适的时候处理这个信号,终止进程②越界&&野指针
我们在语言层面使用的地址(指针)都是虚拟地址,我们使用的地址都是通过虚拟地址经过页表映射到物理地址,再通过物理地址找到物理内存,再读取对应的数据和代码的
虚拟地址转换到物理地址的工作是由(
MMU
(硬件)+页表(软件))来完成的,如果虚拟地址有问题,地址转化过程就会引起问题,表现在硬件MMU
上,OS
发现硬件出现问题,OS
会构建信号,向出错的进程发送信号,目标进程会在合适的时候处理该信号,终止进程补充:core_dump
某些信号的默认动作是
Core
,这些信号基本都是因为代码出现的问题导致的
Core
动作会将core_dump置为1,会产生一个大文件core.进程pid
那么这个
core_dump
在在哪里,什么作用?在父进程等待子进程时,
waitpid(pid_t pid, int *status, int options)
,status
是一个输出型参数,从子进程的pcb
中获取,我们只取低16位,其中次低8位为子进程退出码,低七位为终止信号,剩下1位为core_dump
core_dump
会把进程在运行中,对应的异常上下文数据,core_dump
到磁盘上,方便调试在云服务器上,默认把
core file size
设置为0,无法生成core
文件,我们需要ulimit
命令修改core file size
代码
int main() { pid_t id=fork(); if(id==0) { cout<<"子进程 pid"<<getpid()<<endl; int* num=nullptr; *num=1000; cout<<"子进程 pid"<<getpid()<<endl; exit(1); } else { int status=0; waitpid(id,&status,0); cout<<"子进程退出码:"<<((status>>8)&0xFF) <<"终止信号:"<<(status&0X7F) <<"core_dump:"<<((status>>7)&0x1)<<endl; } return 0; }
使用
core
文件进行调试:上图中,红线中的调试信息,11号信号终止进程,段错误,错误定位在第14行,
*num=1000;
内核中的信号量
信号量在内核中的数据结构
信号存储在进程的
task_struct
中,task_struct
中有三个表,block
表(阻塞信号集),pending
表(未决信号集),handler
表,其中
pending
表就是发送信号给进程,存储信号的位图
block
表也是一个位图,这个位图上表示的是哪些信号被阻塞,信号被阻塞表示进程依旧可以收到这些信号,但是不会递达(处理)这些信号
hanlder
表是一个函数指针数组,处理信号使用信号编号为数组下标(对应的处理方法默认动作或自定义方法或忽略)
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号
产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,
SIGHUP
信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT
信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT
信号未产生过,一旦产生SIGQUIT
信号将被阻塞,它的处理动作是用户自定义函数sighandler
。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
POSIX.1
允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,也就是如果解除该信号的阻塞,只会处理一次该信号
sigset_t
专门为信号量设计的类型
信号集操作函数
虽然
block
表和pending
表都是位图,但是不同系统的实现不同,位图的内部实现可能数组,所以不能直接使用位操作,需要使用特定的信号集操作函数int sigemptyset(sigset_t *set); //函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号
int sigfillset(sigset_t *set); //函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置1,表示 该信号集的有效信号包括系统支持的所有信号
int sigaddset(sigset_t *set,int signo); //向set所指向的信号集添加signo对应的信号
int sigdelset(sigset_t *set,int signo); //向set所指向的信号集删除signo对应的信号
int sigismember(const sigset_t *set,int signo); //检查set所指向的信号集,是否包含signo对应的信号
- 注意,在使用
sigset_t
类型的变量之前,一定要调用sigemptyset
或sigfillset
做初始化,使信号集处于确定的状态。初始化sigset_t
变量之后就可以在调用sigaddset
和sigdelset
在该信号集中添加或删除某种有效信号- 前四个函数都是成功返回0,出错返回-1;
sigismember
是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1
sigprocmask
int sigprocmask(int how, const sigset_t *restrict set,sigset_t *restrict oset); //调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集) //若成功返回0,失败返回1
如果
oset
是非空指针,则读取进程的当前信号屏蔽字通过oset
参数传出。如果
set
是非空指针,则 更改进程的信号屏蔽字,参数how
指示如何更改。如果
oset
和set
都是非空指针,则先将原来的信号屏蔽字备份到oset
里,然后根据set
和how
参数更改信号屏蔽字。
how
的取值
SIG_BLOCK
:set包含了我们希望添加到当前信号屏蔽字
SIG_UNBLOCK
:set包含了我们希望从当前信号屏蔽字中解除阻塞的信号
STG_SETMASK
:设置当前信号屏蔽字为set所指向的值如果调用
sigprocmask
解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号抵达
sigpending
int sigpending(sigset_t *set); //读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
代码
void printpending(sigset_t* p) { for(int i=1;i<32;i++) { int ret = sigismember(p,i); if(ret==1) { cout<<"1"; } else { cout<<"0"; } } cout<<endl; } int main() { sigset_t s,p; sigemptyset(&s); sigaddset(&s,SIGINT); sigprocmask(SIG_BLOCK,&s,NULL); while(1) { sigpending(&p); printpending(&p); sleep(1); } return 0; }
信号的检查和处理
在前面,都在说进程会在合适的时候处理信号,那么什么时候是合适的时候?
合适的时候,就是进程从内核态切换到用户态时会对信号做检测和处理
我们来讲一下用户态和内核态
在进程的虚拟地址空间
0~3G
为用户空间,3~4G
为内核空间,所有进程的内核空间都相同,所有进程使用同一个内核空间,当我们的进程访问内核空间时,进程处于内核态,进程访问用户空间时,进程处于用户态。那么进程如何访问内核空间
我们知道进程通过页表进行虚拟地址和物理地址的转换,访问用户代码和数据,那这个页表叫做用户级页表,用户级页表不能访问内核空间,除了用户级页表还有一个内核级页表,内核具有访问所有空间的权限(内核级空间和用户级空间),
用户态切换到内核态切换的时机
当进程的时间片到了,需要进行进程间切换时,进程会切换到内核态,执行进程调度算法,等执行完内核的代码,会切换回用户态
进行系统调用,
进行信号的检查和处理
在进程由于某些原因(进程切换,系统调用)切换到内核态后,再切换回用户态时,回进行信号的检测和处理
当进程在内核态执行完内核的代码(比如:系统调用等),准备返回,要从内核态切换回用户态时,会检查和处理信号,
下图中,会查看pending表中为1的信号量是否被阻塞,如果没被阻塞,执行
handler
表中的动作
- 如果handler表中信号量编号下标对应的动作是
SIG_DFL
(默认动作),当前进程正处于内核态,执行默认动作- 如果handler表中信号量编号下标对应的动作是
SIG_IGN
(忽略动作),当前进程正处于内核态,直接把pending表中对应的信号量置为0- 如果handler表中信号量编号下标对应的动作是自定义动作(函数指针),进程就会从内核态切换到用户态执行用户代码,执行完用户的自定义动作代码后,再从用户态切换回内核态,在内核态中,再使用特定的系统调用返回,切换到用户态。再执行接下来的用户代码。
- 如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号
sigaction
int sigaction(int sig, const struct sigaction *restrict act,struct sigaction *restrict oact); //sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1
signo
是指定信号的编号。若act
指针非空,则根据act修改该信号的处理动作。若oact
指针非 空,则通过oact
传出该信号原来的处理动作。act
和oact
指向sigaction
结构体:
可重入函数
一个链表头插节点函数,同时设置了一个自定义动作函数,也是链表头插
node node1,node2; node* head; void insert(node* p) { p->next = head; head = p; } int sighandler(int signo) { insert(&node2); } int main() { insert(&node1); ………… }
如果在主函数中进行链表头插时,当
p->next = head;
这句代码后,如果该进程的时间片到了,要进行进程间切换,由用户态切换到内核态,执行调度算法,执行完调度算法后,需要从内核态切换回用户态,这时会进行信号检测和处理,如果该进程收到信号,那么这时执行进程的自定义动作方法,进行链表头插节点,就会出现下图这种情况,导致node2
丢失,内存泄漏像上例这样,
insert
函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称
为重入,insert
函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,
如果一个函数只访问自己的局部变量或参数,则称为可重入函数如果一个函数符合以下条件之一则是不可重入的
- 调用了
malloc
或free,因为malloc
也是用全局链表来管理堆的。- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile关键字
一个死循环
int flag=1; void handler(int signo) { flag=0; } int main() { signal(SIGINT,handler); while(flag) { cout<<"hello world"<<endl; sleep(1); } return 0; }
flag
该为0,死循环停止使用
GCC
优化选项-O2
,cpu
就不会从内存中取值,一直使用CPU中的flag值循环不会结束使用
volatile
保持内存的可见性,死循环可以退出