信号
作用:
信号是进程间传递信息的一种方式,是一个软中断,用来通知进程发生了异步事件。
比如:下课铃声就是一个信号,它提醒你该中断学习的状态去娱乐放松干一些其他的事情。
以下就是 Linux 下的所有信号:
进程的分类:
1 ~ 31 号信号: 每一个都具有对应的系统事件(非可靠信号,会丢失事件)。
34 ~ 64 号信号: 后期扩充的信号,无对应的系统事件(可靠信号)。
32 ~ 33 号信号存在但是我们无法看到。
进程的生命周期
主要流程:
产生信号 -> 在进程中注册信号 -> 在进程中注销信号 -> 处理信号对应的事件
1. 信号的产生
硬件: ctrl + c / ctrl + z / ctrl + /
软件: int kill(pid_t pid, int sig) //pid 进程号 sig 信号 / 给任意进程发送任意信号
int raise(int sig) //sig 信号 / 给当前进程发送指定的信号(自己-> 自己)
void abort(void); //自己给自己发送 6 号信号(终止信号)
kill(gitpid(),sig) == raise(sig) //二者作用一致
返回值: 成 0 败 -1
2. 信号在进程中的注册:
在 pcb -> struct sigpending -> struct sigser_t 这个结构体中存在一个位图,这个位图专门用来保存那些收到但是还未处理的信号。
非可靠信号的注册:
若注册时位图对应位为 0,则创建一个 sigqueue 节点添加到 PCB 的 sigqueue(信号队列) 链表中去,若位图对应位为 1, 则说明已经注册过该信号,故不在进行操作(这样就会导致一次或者多次事件的丢失)。
可靠信号的注册:
不论位图对应位是否为 1,都会去创建一个 sigqueue 节点添加到 PCB的 sigqueue 链表中去。
注意:
位图只有 0/1, 所以无法表示收到信息的数量。
3. 信号在进程中的注销
为保障每一个信号都只被处理一次,故先注销信号后处理信号。
即先在 PCB 中删除信号的信息, 再删除信号对应的 sigqueue 节点,最后将位图中的对应位置置 0。
非可靠信号的注销:
因非可靠信号只有一个节点,故删除节点后将位图 直接置 0。
可靠信号的注销:
因为可靠信号可能存在多个节点,所以删除节点后,还需判断是否还有相同的节点,如果没有相同的其他节点后才能把位图置 0。
4. 信号的处理及其相关的操作函数
1. 信号的捕捉 ---- signal 函数
作用:
当收到指定的信号到达后,就会跳转到参数 handler 指定的函数执行。所以,虽然 signal 函数既可以用来屏蔽信号,也可以用来捕捉信号。
创建格式
#include<signal.h>
sighandler_t signal(int signum, sighandler_t handler);
signum: 要捕捉的信号
handler: 有3个选项
SIG_DFL(系统默认处理)
SIG_IGN(忽视信号并不做任何处理)
自定义函数(当信号来了转去调用自己定义的函数)
2. 信号定时器 — alarm函数
作用:
相当于一个定时器,通过设置定时器的间隔时间,驱使内核在指定时间结束后发送 SIGALRM 信号到调用该函数的进程。
创建格式
#include<unistd.h>
unsigned int alarm(unsigned int seconds);
seconds: 定时器间隔事件(单位为 s)
若参数为 0 则取消定时器,若在调用 alarm函数前,进程已经设置了闹钟时间,则返回上一个闹钟时间的剩余时间,否则返回 0
3. 进程阻塞 — sigprocmask函数
作用:
阻塞信号的处理,但是信号依然能够产生,只是收到信号后不立即进行处理,而是阻塞等待,此时这些信号又被称为未决信号,一旦解除阻塞后会继续执行未决信号。
创建格式
#include<signal.h>
int sgprocmask(int how, const sigset_t* set, sigset_t* oset)
set: 为指向信号集的指针,在此专指新设的信号集,若只想读取现在的屏蔽值可将其置NULL
oset: 也为指向信号集的指针,在此存放原来的信号集,
how 的取值 | 含义 |
---|---|
SIG_BLOCK | 将 set 信号集中的信号添加到内核中的 block 阻塞集合中,使用 oset 集合来保存原来的阻塞信息以便于将来还原 set 集合 — 阻塞 set 集合中的信号(set 和 block 中的所有信号) |
SIG_UNBLOCK | 将 set 信号集中的信号从内核中的 block 阻塞集合中移除 ---- 对 set 集合中的信号解除阻塞 |
SIG_SETMASK | 将内核中的 block 阻塞集合的内容设置成为 set 信号集中的信息 — 阻塞新 set 集合中的信号(只有 set 中的信号,没有 block 中的信号) |
SIG_BLOCK ->(等价于) set | block;
SIG_UNBLOCK ->(等价于) ~set & block
SIG_SETMASK ->(等价于) block = set
信号阻塞和信号忽略的区别
- 信号阻塞只是收到信号后不立即进行处理,而是阻塞等待,直到阻塞解除后才继续进行处理。
- 信号忽略是收到信号后直接忽略,现在包括以后都不会在处理
配套的信号集操作函数
#include <signal.h>
int sigemptyset(sigset_t *set); 把信号集清空
int sigfillset(sigset_t *set); 把信号集全部置成1
int sigaddset(sigset_t *set, int signum); 根据signum,把信号集中的对应为置成1
int sigdelset(sigset_t *set, int signum); 根据signum,把信号集中的对应为置成0
int sigismember(const sigset_t *set, int signum);//判断signum是否在信号集中
未决状态 和 未决信号
- 信号产生和传递之间的时间间隔内,称此信号是未决的;
- 简单来说就是一个已经产生的信号,但是还没有传递给任何进程,此时该信号的状态就称为未决状态。
- 未决状态的信号又被称为未决信号
- 未决信号的产生主要是因为进程对此信号的阻塞。例如为进程产生一个选择为阻塞的信号,而且对该信号的动作是系统默认动作或捕捉该信号,则为该进程将此信号保持为未决状态,直到该进程对此信号解除了阻塞或者对此信号的动作改为忽略。
信号的常用实例场景
- 子进程先于父进程退出,若父进程没有关注子进程的状态则子进程会变成僵尸态,对系统资源会造成浪费。但是为什么会造成这一问题呢?
- 因为在子进程退出的时候会向父进程发送 SIGCHLD 信号通知父进程自己的状态已经发生了改变,但是因为系统对于 SIGCHLD 信号的默认处理方式是信号忽略,所以父进程无法知道子进程的状态改变
- 解决僵尸进程的一种方法是进程等待,但是进程等待的资源利用率低下,这时候我们就可以使用信号来解决这个问题
- 设置信号捕捉,当捕捉到 子进程的 SIGCHLD 信号,就跳转到自定义函数去获取子进程的当前状态,既避免了僵尸进程也提高了资源利用率。
函数的可重入和不可重入
- 可重入和不可重入是线程安全的一个要素。
- 函数重入: 在多个执行流中,同一个函数被多个执行流进行调用。(这是不安全的)
- 可重入的函数简单来说就是可以被中断的函数,也就是说,可以在这个函数执行的任何时刻中断它,转入OS调度下去执行另外一段代码,而返回控制时不会造成数据二义或者逻辑混乱;、
- 而不可重入的函数由于使用了一些系统资源,比如全局变量区,中断向量表等,所以它如果被中断的话,可能会出现数据二义或者逻辑混乱,这类函数是不能运行在多任务环境下的。
- 是否可以重入的判断基准:这个函数是否对全局变量进行了非原子操作 是,则不可重入
int a = 1,b = 1;
int A()
{
++a,++b;
printf("%d\n",a + b);
sleep(5);
return a + b;
}
int B()
{
--a,--b;
}
int main()
{
int ret = A();
B(); //可以看到在 A运行时会先睡5s在返回,而在这过程中a,b会被 B 更改
printf("%d\n",ret); //这里就可以发现 A 中的printf 和 main 中的 printf 输出的不一致,这就造成了数据二义
}
常见问题
- 一个函数没有对全局变量进行操作,则肯定是可重入的。 (√,因为局部变量不同的函数调用会生成不同的栈,所以不会造成数据二义或者逻辑混乱)
- 若函数对全局变量进行了操作,但操作是原子操作,则也是可重入的(√)
volatile 关键字
- cpu 在处理一个数据时通常会从内存中将数据加载到寄存器再进行处理
- g++ 编译器中,如果使用代码优化 (Olevel), 则会将某些频繁使用的变量值直接设置成某个寄存器的值,下次再使用该变量时就不会从内存中去获取而是直接从设置对应值的寄存器中获取,这样就提高了效率。但是,因为每次都是从寄存器中取值,而非内存,一旦了内存中该变量的值发生了改变,寄存器中的值却仍旧是初始值不会随着改变,这就会导致逻辑混乱。
- volatile 的作用就是修饰一个变量,保持变量的内存可见性 (保证 cpu 在处理该变量时每次都是从内存中获取),主要是为了防止编译器过度优化。
// 该文件为 volatile.c
#include<stdio.h>
#include<unistd.h>
#include<signal.h>
long long a = 1; // volatile long long a = 1;
void sigcb(int N)
{
a = 0;
printf("a = %d\n",a);
}
int main()
{
signal(SIGINT,sigcb); //当收到 SIGINT 信号(Ctrl + C) 就会退出循环
while(a) {}
printf("existed, a = %d\n",a);
}
gcc -O2 volatile.c -o volatile
// -O0(缺省参数,默认不进行代码优化)
// -O1(一级代码优化)
// -O2(二级代码优化)
// -O3(三级代码优化)
./volatile //这时你按 Ctrl + C 不会退出循环,为什么?因为使用二级代码优化会把 a = 1 直接存入寄存器中,下次访问还是访问的寄存器中的 a,内存中 a 的改变对寄存器中的 a 没有影响,所以 每次 a 都是 1,也就死循环了
而使用 volatile 后就能正常运行了
原子操作
- 原子操作指的是不会被线程调度机制打断的操作
- 这种操作一旦开始,就一直运行到结束 (要么不做,要做就一次性完成)
CAS
CAS是 compare and swap 的缩写,即比较交换。CAS是乐观锁,它实现了原子操作。
乐观锁
- 乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
悲观锁
- 悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
CAS的优缺点
- 优点:并发量不是很高时 CAS 会提高效率
- 缺点: CPU消耗大,CAS线程会不停自旋,如果并发量大的话,将会不停重试,还不释放CPU,极端情况下会耗光资源。
只能保证某个/单个变量的原子操作,一旦涉及到多个变量,就无法使用了
如何选择
在乐观锁与悲观锁的选择上面,主要看下两者的区别以及适用场景就可以了。
- 当竞争不激烈 (出现并发冲突的概率小)时,乐观锁更有优势,因为悲观锁会锁住代码块或数据,其他线程无法同时访问,影响并发,而且加锁和释放锁都需要消耗额外的资源。
- 当竞争激烈(出现并发冲突的概率大)时,悲观锁更有优势,因为乐观锁在执行更新时频繁失败,需要不断重试,浪费CPU资源。
- 而随着互联网三高架构(高并发、高性能、高可用)的提出,悲观锁已经越来越少的被应用到生产环境中了,尤其是并发量比较大的业务场景。