目录
了解信号
概念
信号是进程之间事件异步通知的一种方式,属于软中断;用于向某个进程通知一件事情,打断该进程当前的操作,去处理通知过来的事情;
-
举个例子来说明一下这个过程:
-
1.用户输入命令,然后再 shell 中启动一个前台程序;
2.用户按下ctrl + c
命令后,被操作系统获取到之后解释成信号,然后发送给目标前台进程;
3.进程接收到信号并处理,这是一个中断信号,所以处理结果为进程退出;
在内核中的表示
信号在内核中的表示示意图如下,这其中每一项的概念在后面都会解释的;
注意
- shell 可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像
ctrl + c
这种控制键产生的信号; - 像
ctrl + c
这种控制键产生的信号只能发给前台进程,一个命令后面加个&
可以放到后台运行,这样 shell 不必等待进程结束就可以接受新的命令,启动新的进程; - 前台进程在运行过程中用户随时可能按下
ctrl + c
而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT
信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的;
周期
- 生命周期:产生——注册——注销——处理,下面会详细讲解的;
信号产生
非代码中产生
硬件产生
ctrl + c
:中断信号,终止当前在前台运行的进程;ctrl + \
:退出信号,退出当前在前台运行的进程;ctrl + z
:停止信号,停止当前在前台运行的进程,将进程置为停止状态;- 异常处理:例如做除以 0 的操作,那么 CPU 中的运算单元会产生异常,然后将异常解释为 SIGFPE 信号发送给进程,再比如当前进程访问了非法内存地址,MMU 会产生异常,内核将这个异常解释为 SIGSEGV 信号发送给进程;
命令产生
kill -l
:查看所有系统定义的信号列表;- 非可靠信号:下表中的 1 ~ 31 号信号;
- 可靠信号:下表中的 34 ~ 64 号信号;
man 7 signal
:查看以上罗列出的这些信号各自在什么条件下产生,以及会进行怎样默认的处理动作;jobs
:显示当前作业信息;fg 数字
:继续运行某个停止状态的进程,这个数字是使用jobs
命令显示出来的作业前面的数字序号;kill pid
:杀死指定 pid 的进程,原理为给进程发送了终止信号;
- 停止进程是杀不死的,因为停止的进程是不会处理发送过来的信号的,等到变为运行状态了才会处理发送过来的信号;僵尸进程也是杀不死的,因为它本身就已经死了;
kill -信号名称前的数字/信号名称 pid
:向指定 pid 的进程发送指定信号;kill -9 pid
:强制杀死指定 pid 的进程,原理为给进程发送了强制终止 SIGKILL 信号;
- 可以强制杀死停止状态的进程,但是仍无法杀死僵尸进程,因为不能杀死一个已经死了的进程,所以我们一定要尽量避免僵尸进程的出现;
kill -19 pid
:强制停止指定 pid 的进程,原理为给进程发送了强制停止 SIGSTOP 信号;
由代码中产生的
int kill(pid_t pid, int sig);
:给指定 pid 的进程发送指定信号,成功返回 0,错误返回 -1;int raise(int sig);
:给当前进程自身发送一个指定信号,成功返回 0,错误返回 -1;unsigned int alarm(unsigned int seconds);
:在 seconds 秒之后给当前进程自身发送一个 SIGALRM 信号,默认动作为终止当前进程,返回上一次设置的闹钟剩余的秒数;- 举例:某人要小睡一觉,设定闹钟为 30 分钟之后响,20 分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为 15 分钟之后响,那么此时的返回值就是之前剩余的 10 分钟;
void abort(void);
:给进程自身发送一个 SIGABRT 信号,默认动作为使当前进程异常终止,无返回值;int sigqueue(pid_t pid, int sig, const union sigval value);
:给指定进程发送一个指定信号的同时携带一个数据过去;
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
int main (int argc, char *argv[]) {
//kill(进程ID, 信号值),给指定进程发送指定信号
kill(getpid(), SIGINT); //结果:终止当前进程
//给当前进程发送指定信号
raise(SIGINT); //结果:终止当前进程
//定时函数,时间到了执行信号 AIGALRM 的处理动作
alarm(3); //结果:3 秒后终止当前进程
while(1) {
printf("-------\n");
sleep(1);
}
return 0;
}
信号注册
注册相关概念
- 未决:信号从产生到处理之间的状态,称为信号未决 (Pending),由 pending 位图来表示;
- 注册:接收到信号后,经由 pending 位图表示的信号则就是已被注册的信号;
注册方式
- pending位图:在进程 pcb 中有一个 pending 位图,它的下标对应了每个信号的序号,如果接收到了某一个信号,则将信号序号对应的下标位置元素置为 1,如果没有接收到信号,那么该值默认为 0;
- sigqueue链表:一个双向链表,当接收到一个信号后,将会在该链表中记录信息节点;
注意:当某一个信号向进程发送了多次,可靠信号和非可靠信号的注册方式是有区别的:
- 非可靠信号:信号被发送了多次后,如果信号没有被注册,则先将 pending 位图修改为 1,然后将信号节点加入 sigqueue 链表中,如果被注册了,那么则什么也不用做,链表中不会出现多个相同信号的信息节点;
- 可靠信号:信号被发送了多次后,如果信号没有被注册,则先将 pending 位图修改为 1,然后将信号节点加入 sigqueue 链表中,如果被注册了,那么就在链表中添加多个相同信号的信息节点;
- 举例:看看可靠信号与非可靠信号发送多次之后有什么区别?(这里涉及到了一些信号阻塞的接口,下面会讲到,这里不需要过分纠结)
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void sigcb(int signo) {
printf("recv signal:%d\n", signo);
}
int main (int argc, char *argv[]) {
signal(SIGINT, sigcb);
signal(40, sigcb);
//阻塞信号的接口:sigprocmask(操作类型, 信号集合, 信号集合)
sigset_t set;
sigemptyset(&set);
sigfillset(&set);
sigprocmask(SIG_BLOCK, &set, NULL);
//这里让进程暂停一下,然后向进程发送信号
printf("回车后,继续运行\n");
getchar();
//解除信号阻塞,然后看看效果
printf("解除信号阻塞,查看结果\n");
sigprocmask(SIG_UNBLOCK, &set, NULL);
while(1) {
sleep(1);
}
return 0;
}
信号注销
- 操作:将信号信息从进程 pcb 中移除,所进行的操作为:修改位图、删除节点,对于可靠信息和非可靠信息也是有区别的:
- 非可靠信号:从 sigqueue 链表中删除节点,然后修改 pending 位图为 0;
- 可靠信号:从 sigqueue 链表中删除节点,然后检查链表中是否还有相同的结点,如果没有相同结点,那么就修改 pending 位图为 0;
信号处理
概念
- 递达:进程接收了信号之后,执行信号的处理动作称为信号递达 (Delivery),实际上就是打断进程当前操作,然后去执行接受到的信号对应的处理函数;
- handler表:在进程 pcb 中,每一个信号都有一个 handler 表,表中的内容表示下标所对应的信号被自定义的处理动作,当进程接收到一个信号时,先进行注册,等到处理时如果发现该信号被自定义过,那么就会以信号的序号为下标,去 handler 表中找到对应的处理方式,然后执行相应的动作;
处理方式
- 默认处理方式:就是系统原本定义好的信号处理方式;
- 忽略处理方式:它进行了处理,但是它处理的动作就是什么也不做;
- 自定义处理方式:进行信号捕捉,用户自己定义回调函数,然后使用相应的信号捕捉接口来改变信号的默认处理动作;
void(*sighandler)(int);
:回掉函数的格式,用户要按照这个格式来自己定义回调函数,一个包含一个整形参数的无返回值函数;sighandler_t signal(int signum, sighandler_t handler);
:信号捕捉接口;signum
:信号值,表示要修改哪个信号的处理方式;handler
:更改的信号的新处理方式,有三个选项;SIG_DFL
——信号原本的默认处理方式;SIG_IGN
—— 忽略处理方式,什么也不做就是它的处理方式;- 自定义函数的函数指针,必须按照规范来实现;
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
//对 SIGALRM 信号的回调函数
void sigcb(int signo) {
printf("recv signal:%d\n", signo);
printf("时间到了,吃一口红烧肉吧~\n");
//这里可以继续调用 SIGALRM 信号,这样可以持续进行定时
alarm(3);
}
int main (int argc, char *argv[]) {
//signal(信号, 处理方式),自定义信号的处理动作
signal(SIGALRM, sigcb);
//定时函数,时间到了执行信号 AIGALRM 的处理动作,由于该信号被自定义了,所以会执行自定义函数
alarm(3);
while(1) {
printf("-------\n");
sleep(1);
}
return 0;
}
- 信号捕捉的流程:
阻塞方式
概念
- 阻塞:进程可以选择阻塞 (Block) 某个信号,被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作;
- block位图:在进程 pcb 中除了 pending 位图、handler 位图之外,还有一个 block 位图,用来表示被阻塞信号的集合,它的下标对应了每个信号的序号,如果要阻塞某一个信号,则将信号序号对应的下标位置元素置为 1,如果不需要阻塞信号,那么该值默认为 0;
具体操作
int sigprocmask(int how, const sigset_t* set, sigset_t* oldset);
:对自己设置的 set 位图中的进程进行阻塞或者解除阻塞;how
:操作类型,主要分为以下三类:SIG_BLOCK
:在原来 block 位图的基础上,再阻塞 set 位图中的信号,执行的操作是:block位图 = block位图 | set位图;SIG_UNBLOCK
:解除 set 位图中信号的阻塞,执行的操作是:block位图 = block位图 & set位图取反;SIG_SETMASK
:将 block 位图设置为 set 位图的值,只阻塞当前设置的 set 位图,执行的操作是:block位图 = set位图;
set
:自己设置的位图,这其中为 1 的比特位代表了对应序号的信号是要被阻塞还是解除阻塞;oldset
:保存进行改变之前的 block 位图,以便后续进行恢复,如果设置为 NULL 则表示不需要保存;- 返回值:成功返回 0,失败返回 -1;
- 下面介绍一些有关 set 值设置的五个接口:
int sigemptyset(sigset_t* set);
:清空 set 集合,使其所有位全置位 0;int sigfillset(sigset_t* set);
:添加所有信号到 set 集合中;int sigaddset(sigset_t* set, int signum);
:添加指定的单个信号 signum 到 set 集合中;int sigdelset(sigset_t* set, int signum);
:从 set 集合中移除指定信号 signum;int sigismember(const sigset_t* set, int signum);
:判断信号 signum 是否在 set 集合中;
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
void sigcb(int signo) {
printf("recv signal:%d\n", signo);
}
int main (int argc, char *argv[]) {
signal(SIGINT, sigcb);
//sigprocmask(操作类型, 信号集合, 信号集合)
//定义 set 值
sigset_t set;
//先清空原始内容
sigemptyset(&set);
//将所有信号都放入set中
sigfillset(&set);
//然后将set中的信号全部阻塞
sigprocmask(SIG_BLOCK, &set, NULL);
//这里让进程暂停一下,然后向进程发送信号
printf("回车后,继续运行\n");
getchar();
//解除信号阻塞,然后看看效果
printf("解除信号阻塞,查看结果\n");
//解除set中信号的阻塞
sigprocmask(SIG_UNBLOCK, &set, NULL);
while(1) {
sleep(1);
}
return 0;
}
信号应用
两个特殊信号
- 下面这两个信号很特殊,是操作系统定义的不可被阻塞、不可被自定义、不可被忽略的信号,就是用来处理紧急情况的,目的在于防止让进程处于一种无法处理的状态;
SIGKILL
:9 号信号,强制杀死信号;SIGSTOP
:19 号信号,强制停止信号;
SIGCHLD
信号
- 概念:当一个子进程退出后,就会给父进程发送子进程状态改变的信号 SIGCHLD;
- 行为:这个信号的默认处理动作就是忽略,什么都不做;
- 结果:因为这个信号被接受之后就忽略了,所以我们需要在父进程中使用
wait
系统调用接口来阻塞等待子进程的退出,避免出现僵尸进程,但是阻塞等待会很浪费父进程的资源; - 解决:如果不想等待,那么我们可以使用信号捕捉
signal
接口来解决,我们可以自定义 SIGCHLD 信号的处理动作,这样父进程在接受到 SIGCHLD 信号就自动处理,不用在那傻等了;signal(SIGCHLD, 自定义回调函数);
:执行自己定义的处理函数,但是由于该信号是不可靠信号,可能会导致信号丢失;- 举例:在一个子进程退出后向父进程发送了一个
SIGCHLD
信号,不过并没有来得及处理这个信号,此时又有其他子进程退出并向父进程发送了该信号,那么后面发的这些信号将不会被父进程接受处理; - 处理:所以我们需要在一次
SIGCHLD
信号中处理完所有的子进程退出,具体做法为利用循环,然后进行wait
非阻塞等待,直到没有子进程退出了,才会将SIGCHLD
信号注销,后续如果又有子进程退出,那么就可以再次接收到SIGCHLD
信号;
- 举例:在一个子进程退出后向父进程发送了一个
signal(SIGCHLD, SIG_IGN);
:显式忽略,当子进程退出后自动释放资源,父进程不用去管它,所以并不会遇到上面的信号丢失问题;
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
//自定义SIGCHLD信号的处理动作
void sigcb(int no) {
printf("有子进程退出了\n");
waitpid(-1, NULL, 0);
}
int main (int argc, char *argv[]) {
//信号捕捉重定义
signal(SIGCHLD, sigcb);
pid_t pid = fork();
if (pid == 0) {
//如果没有信号捕捉操作,那么就需要父进程阻塞等待3秒,浪费资源
sleep(3);
exit(0);
}
while(1) {
printf("------\n");
sleep(1);
}
return 0;
}
SIGPIPE
信号
- 概念:当管道所有读端被关闭,如果此时再进行 write 操作,那么会触发异常对应的信号 SIGPIPE,该信号会使进程异常退出;
- 解决:有时候出现这样的异常我们并不想退出进程,那么我们也可以使用信号捕捉
signal
接口来自定义信号处理动作,是执行其他动作还是忽略都行;
其他
volatile
关键字
- 功能:保持内存可见性,意思就是让 CPU 每次访问变量时都从内存中重新访问数据;
- 目的:防止编译器过度优化,在代码完成后,一般编译器都会进行优化的,对于有些经常访问的变量的值都会在 CPU 的寄存器中保存,这样就不需要频繁访问内存去取元素,但是这样也会在当变量修改后出现问题,因此在变量前面加上这个关键字就可以避免过度优化问题出现;
- 举例:下面的代码中,在死循环时向进程发送一个 SIGINT 信号,此时 a 变为 0,
- 如果全局变量 a 被
volatile
修饰的话,那么 CPU 会从内存中重新获取变量,因为 a = 0,所以死循环就会结束; - 如果全局变量 a 没有被
volatile
修饰的话,那么 CPU 就不会从内存中重新获取变量,a 依然保持为 1,死循环持续进行,不会结束;
- 如果全局变量 a 被
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
//一个被volatile修饰的全局变量
//volatile int a = 1; *****11*****
//没有被volatile修饰的全局变量
//int a = 1; *****22*****
//自定义处理函数
void sigcb(int no) {
a = 0;
printf("%d\n", a);
}
int main (int argc, char *argv[]) {
//信号处理改变
signal(SIGINT, sigcb);
//死循环
while(a) { }
printf("程序正常退出\n");
return 0;
}
重入函数
- 概念:在不同的执行流程中,多个流程同时进入同一个函数开始执行;
- 不可重入函数:函数在被重入后,会造成数据的二义性或者逻辑混乱的函数;
- 调用了
malloc / free
函数,因为malloc
函数是用全局链表来管理堆的; - 调用了标准 I/O 库函数,标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构;
- 可重入函数体内使用了静态的数据结构;
- 调用了
- 可重入函数:函数在被重入后,不会出现问题的函数;
- 不使用全局变量或静态变量;
- 不使用用
malloc
或者new
开辟出的空间; - 不调用不可重入函数;
- 不返回静态或全局数据,所有数据都有函数的调用者提供;
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据;
- 举例:在下面的代码中,当程序执行后,按
ctrl + c
向进程发送信号,此时由于 test 函数是不可重入函数,所以打印出来的结果会出现问题;
出现问题原因:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <signal.h>
int a = 0, b = 0;
int test() {
a++;
sleep(2);
b++;
printf("%d + %d = %d\n", a, b, a+b);
}
//信号回调函数
void sigcb(int no) {
test();
}
int main (int argc, char *argv[]) {
//重定义信号处理
signal(SIGINT, sigcb);
test();
return 0;
}