进程信号

一. 信号入门

1. 生活中的信号

信号处理方式和我们取快递的方式特别像

  • 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”
  • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。
  • 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
  • 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动
    作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
  • 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

在Linux 中,是进程来处理信号

信号其实也是一种进程间通信的方式。只不过,信号是传递事件,而进程间通信是传递数据。

2. 技术应用角度的信号

用户输入命令,在 shell 下启动一个前台进程

  • 用户按 ctrl+c,这个键盘输入产生一个硬件中断,被 OS 获取,解释成信号,发送给了目标前台
  • 前台进程因为收到了信号,进而引起进程退出

注:

1. ctrl+c发送SIGINT(2)信号。ctrl+c只有前台接收到

2. 在一个bash中,只允许有一个前台进程,前台进程运行时不能输入其他的shell命令

3. 进程运行后加 & 在后台运行,此时可执行其他的shell命令

4. Linux中用fg命令让后台进程在前台运行(fg %n将编号为n的任务转向前台)

5. jobs -l 查看任务,返回任务号和进程号

信号没来时,但进程知道怎么做。

3. 信号概念

信号是进程之间事件异步通知的一种方式,属于软中断

kill -l 命令查看系统定义信号列表

共有62个信号
1-31是普通信号    34-64是实时信号
我们一般关心1-31的普通信号

在这里插入图片描述

4. 自定义信号

#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum,sighandler_t handler);

注册一个对特定信号的处理动作,当来了这个信号时,就运行这个指针指向的函数

参数:
1. 对应信号的编号(1-31),可改变信号的处理方式。(9号信号不能自定义,不能被捕捉)
2. 捕捉动作,处理信号函数的指针

5. 注:

  • shell 可同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 ctrl+c 这种控制键产生的信号
  • 信号相对于进程的控制流程来说是 异步 的(信号的产生与进程的执行是不相干的)

6. 处理信号的常见方式

  • 忽略此信号
  • 执行该信号的默认处理动作
  • 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个函数,这个方式称为:捕捉(catch)一个信号

Linux 中,9号信号不能被捕捉,不能被自定义

信号产生之后不是立即被执行的

在这里插入图片描述

...
int arr[100] = {0};
...
cout <<  arr[120] << endl;  // 1
cout << "run here" << endl; // 2
...

当运行到1时发生段错误,但2语句还是可能执行。因为信号的传递需要时间(产生异常->处理异常有时间窗口)

二. 产生信号

1. 通过终端按键产生信号(也就是键盘)

SIGINT(2) 的默认处理动作是终止进程,SIGQUIT(3)(ctrl+\ ) 的默认处理动作是终止进程并且Core Dump

Core Dump --核心转储

首先解释什么是Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做core dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存在PCB中)。默认是不允许产生core文件的。因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。首先用ulimit命令改变Resource Limit,允许core文件最大为1024K:$ ulimit -c 1024。

ulimit -c 0 关闭Core Dump

形成 core.异常进程pid 文件名的文件。core文件是给调试器看的

为什么要有Core Dump

因为其可以保存错误原因给调试器看,我们也可通过gdb去查看,进行Linux的调试

不是所有信号都需要core dump

2. 调用系统函数向进程发信号

kill
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid,int sig);

参数:
1. 发给哪个进程
2. 发几号信号 

返回值:
成功返回0
失败返回-1
raise
#include <signal.h>

int raise(int signo);  //给自己发信号

参数:发几号信号

返回值:
成功返回0
失败返回-1
abort

使当前进程接收到信号而终止(自己给自己发六号信号 SIGABRT

#include <stdlib.h>

void abort(void);

如同 exit 函数一样,abort 函数总会成功,所以没有返回值
该信号被自定义后,运行完信号,该程序也终止

3. 由软件条件产生信号

如:SIGPIPE 是一种由软件条件产生的信号。

alarm
#include <unistd.h>

unsigned int alarm(unsigned int seconds);

设定一个闹钟,也就是告诉 内核seconds 秒后,给当前进程发SIGALRM(12)信号,该信号默认处理动作是终止当前进程

4. 由硬件异常产生信号

I/O是影响效率最大的因素,当我们程序中I/O越少,效率越高

*
int* p = NULL;
*p = 10;
//会报错:Segmentation fault

一般虚拟内存地址映射到物理地址,而野指针没有虚拟地址,页表会发现该错误,OS会察觉到,发送信号,将其杀死

野指针导致程序崩溃,是因为出现野指针会产生错误,被OS捕捉到,发送11号SIGSEGV信号给它,导致系统崩溃

*
int a = 1/0;
//会报错:Floating point exception

状态寄存器(CPU)硬件里面记录的 a 溢出了,OS 管理硬件,发现该进程中除0了,向这个进程发送 8号SIGFPE 信号,终止它的运行

注:我们自定义 8号信号 后,如果自定义打印,它会一直打印,OS会一直给它发8号信号,因为它没有处理错误。知道ctrl+c终止

5. 总

  • 所有的信号,都必须经过OS的手发出,为什么?
    我们给进程发信号,是想让进程挂掉或者处理某些事情,而只有OS能对进程指手画脚,而且,OS是进程的管理者
  • 刚才四个都是信号产生的条件OS去发送信号
  • 信号的处理不是立即被处理,而是合适的时候。它会被暂时保存起来
  • 一个进程没有收到信号的时候,它知道自己应对合法信号该如何处理

三. 信号保存

保存信号我们要注意以下两个问题:

  • 是谁(哪一个信号)
  • 是否?(该信号是否被接收到)

1. 简述

位图 去保存信号,其存储在 进程PCB
我们给进程发信号,就是去它的PCB中,找到信号位图,去修改其中该信号对应的位

怎么表示不同的信号

通过比特位的位置代表是哪一个信号。

00000000000000000000000000000000 第一个位置代表信号1 第二个位置代表信号2
怎么表示该信号是否被接收到

同样用01的方式表示该信号是否接收到

00000000000000000000000000000001 代表信号1被接收到
00000000000000000000000000000010 代表信号2被接收到
信号是OS发送,OS如何发送信号

在其位图中找到信号对应位置修改即可
所以OS给进程“发送”信号,更像是给进程“写”信号

2. 阻塞信号

信号其他相关常见概念
  • 实际执行信号的处理动作称为:信号递达(Delivery)(默认,忽略,捕捉自定义)
  • 信号从产生到递达之间的状态,称为:信号未决(Pending)(不止阻塞在未决)
  • 进程可以选择阻塞(Block)某个信号。(所以我们没收到该信号可能时该信号被阻塞了)
  • 被阻塞的信号产生时保持在未决状态,直到进程解除对信号的阻塞,才执行递达动作
  • 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达;而忽略是信号递达之后可选的一种处理方式

3. 信号在内核中

在这里插入图片描述

block记录信号是否被阻塞,哪个信号被阻塞(它是位图结构,位置代表是谁,内容代表是否被阻塞)。block表也可称为信号屏蔽字

pending保存信号,位置代表是哪一个信号,内容代表是否接收到该信号

handler表是一个函数指针数组,其中的内容都是一个个指向信号处理方式的函数指针。所以signal函数修改的是递达方式,实际修改的是handler表

所以进程创建地址空间应该包含PCB,虚拟地址空间,页表,file_struct,信号(block,pending,handler)

例:
1位置上,block为0,pending为0,当前进程没有收到一号信号,一号信号也没有被阻塞,如果收到一号信号,其处理动作为SIG_DFL

如2位置上,block为1,在pending中为1,说明当前进程收到了2号信号;被阻塞了,处于未决状态。该信号递达时,处理动作为SIG_IGN

在3位置上,block为1,pending为0,则说明该信号没有收到,但被阻塞了。万一解除阻塞,收到该信号,递达的处理方式为自定义函数
注:
  • 在内核中,sign_struct是实时信号,不属于我们现在的结构
  • 每个信号都有两个标志位,分别表示阻塞(block)与未决(pending),还有一个函数指针表示处理动作
  • 如果在信号阻塞时产生多次(在Linux中,常规信号在递达前产生多次只计一次,实时信号产生多次依次放在一个队列中)

4. sigset_t

一张位图

从之前信号内核的图来看,每个信号只有一个 bit 的未决标志,非0即1,不记录该信号产生多少次,阻塞标志也是这样,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

一般我们不能直接 & |来修改,一般必须使用特定的接口。因为在不同的平台,sigset_t 的底层定义不同,所以不建议使用位图的操作进行。

5. 信号集操作函数

#include <signal.h>
sigemptyset

初始化 set 所指向的信号集,使其中所有信号的对应 bit 清0。意味着其中不包含任何有效信号。

#include <signal.h>

int sigemptyset(sigset_t* set);
sigfillset

将其中所有信号的对应 bit 设置为1。其中所有信号都是有效的。

#include <signal.h>

int sigfillset(sigset_t* set);
sigaddset

signo 信号的 bit 设为1,相当于添加该信号

#include <signal.h>

int sigaddset(sigset_t* set,int signo);
sigdelset

signo信号由1设置为0,相当于删除该信号

#include <signal.h>

int sigdelset(sigset_t* set,int signo);
sigismember

判断 signo 信号是否在当前信号集中

#include <signal.h>

int sigismember(const sigset_t* set,int signo);
注:
  • 前四个函数都是成功返回0,出错返回-1
  • sigismember,包含返回1,不包含返回0,出错返回-1
  • 在使用sigset_t类型的变量之前,一定要调用 sigemptyset or sigfillset 做初始化,使信号集处于确定的状态
6. sigprocmask

对进程的信号屏蔽字(block)进行修改

#include <signal.h>

int sigprocmask(int how,const sigset_t* set,sigset_t* oset);

返回值:
成功返回0,失败返回-1

参数:
1. SIG_BLOCK set包含了我们希望添加到信号屏蔽字的信号,相当于mask = mask | set
   SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字中清除的信号,相当于 mask & ~set
   SIG_SETMASK 设置当前的信号屏蔽字为set所指向的值,相当于mask = set
2. 第二个参数配合第一个去使用
3. 输出型参数,在我修改之前,先把老的屏蔽字保存一下

注:

oset如果为空指针,则无法读取
       为非空指针,才能保存老的屏蔽字
set如果为空指针,则无法修改
      为非空指针,才能修改
sigpending

获取当前进程的 pending 信号集

#include <signal.h>

int sigpending(sigset_t* set);

参数:输出型参数,用它将pending信号集拿出来
返回值:成功返回0,失败返回-1

四. 信号的捕捉

1. 用户态和内核态

我们的程序分为内核态和用户态
用户态:权限小,内核态:权限大
当我们调用系统调用时,因为用户没有权限执行OS代码,此时角色变为OS来执行代码

所以我们的程序肯定是在用户态和内核态切换执行

进程怎么一会用户态,一会内核态

在这里插入图片描述

内核的代码与数据在不同进程中,虚拟地址空间分区一样,映射到物理内存中也一样

用户的代码与数据在不同进程中,虚拟地址空间分区一样,但映射到物理内存是不一样的

所以 CPU 在执行代码时,此时在用户态,执行用户区的代码。在内核态时,执行内核的代码。

怎么判断此时在用户态还是内核态?

CPU 中有一个 cr寄存器,里面有1个值,表示此时为用户态还是内核态

在返回时,怎么知道要检查哪个信号?

在返回时,此时在内核态,是能去找到进程PCB,找到block,handler,pending的。然后对信号进行检测,选择对信号进行处理。
在这里插入图片描述

为什么执行自定义函数时要返回用户态

因为如果该动作,该函数为非法的,OS权限高,一旦出了什么问题,不好管理,所以要切换回用户态去调用。

2. 内核如何实现捕捉

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

从内核态->用户态时,不仅仅要做信号的捕捉处理,线程和进程的切换也在这个时候检测

sigaction

作用与定位与 signal 一样

#include <signal.h>
int sigaction(int signum,const struc###t sigaction* act,struct sigaction* oldact);

参数:
1. signum  信号
2. 你收到该信号后,你想做什么动作
3. 该信号默认情况下的动作(老的方法)
struct 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);
}

sigset_t sa_mask作用
如果我们在执行2号信号时,我们再给一个2号信号,再给一个系统调用,我们去处理2号信号的同时,又有2号信号要处理。为了让我们不再重复执行2号。所以sa_mask中记录了我们在执行信号时,想要屏蔽的信号。

所以我们在运用该函数时,我们需构建struct sigaction结构体,构建一个处理动作的函数,将flags设为0,设置sa_mask。

3. volatile

对于handler
void handler(int sig){
	switch(sig){
		case 1:
		......
}

多个信号可以调用一个handler,所以我们可用case对不同信号进行自定义处理

例:
#include <stdio.h>
#include <signal.h>

int quit = 0;

void handler(int sig){
	quit = 1;
	printf("quit is already set to !\n");
}

int main(){
	signal(2,handler);
	write(!quit);
	printf("end process\n");
}

//此时该程序中有死循环,我们ctrl+c停止。

但如果在编译时,加上 -o2去优化它
ctrl+c后,一直打印quit is already set to!,不会终止程序。
为什么呢?

因为main不会修改quit,而handler与main不属于同一执行流,并且会一直拿quit做判断。
而加上-o2选项后,编译器会将quit优化到寄存器上,而我们handler修改的是内存里的quit,此时造成数据不一致
而while循环只在意寄存器的值,所以ctrl+c不会终止程序。
为了解决这样的问题,我们用volatile修饰变量

因为 volatile 修饰的变量不能被优化,只能去找实际存储位置的值

volatile的作用

保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行

五. SIGCHLD信号

进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可 用。请编写程序验证这样做不会产生僵尸进程。

六. 注

  1. **SIGKILL(9)**和 SIGTOP(19) 信号无法被阻塞,无法被自定义,无法被忽略
  2. 一个进程无法被 (kill 信号) 杀死的情况:
信号被阻塞
用户自定义该信号
进程可能是僵尸进程
进程可能处于停止状态
  1. 信号被阻塞,给该信号依然可以加入未决信号集中
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值