当前位置: 首页 > news >正文

【Linux———信号精讲】

你是怎么做到的,给了她想要的爱............................................................................................

文章目录

前言

一、【信号入门】

1.1、【生活角度的信号】

1.2、【ctrl+ c与z】

1.3、【信号的发送与记录】

1.4、【信号处理常见方式概述】

二、【信号的产生】

2.1、【通过终端按键产生信号】

2.2、【核心转储】

1、【引入】

2、【作用】

3、【使用】

4、【core dump标志】

2.3、【通过系统函数向进程发信号】

1、【kill函数】

2、【raise函数】

3、【abort函数】

2.4、【异常导致信号产生】

1、【 除0异常】

2、【段错误异常 】

2.5、【软件条件产生信号】

1、【SIGPIPE信号】

2、【SIGALRM信号】

三、【信号的保存】

3.1、【阻塞信号】

1、【信号相关常见概念】

2、【内核中标识信号的三张表】

3.2、【介绍sigset_t】

3.3、【信号集操作函数】

3.4、【sigprocmask函数】

3.5、【sigpending函数】

四、【信号的处理】

4.1、【信号的捕捉】

4.2、【内核态与用户态】

1、内核态和用户态之间是进行如何切换的?

2、那么内核如何实现信号的捕捉呢?

4.3、【sigaction】

四、【可重入函数】

五、【volatile】

六、【SIGCHLD信号】

七、【Linux中的31个普通信号】

1、【各普通信号的含义】

2、【注意】

总结


前言

本篇博客主要介绍了,Linux中有关信号的知识,包括信号是什么,如何产生的,有什么作用,请耐心观看!


一、【信号入门】

1.1、【生活角度的信号】

  • 你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递还没有到来,你也知道快递到了的时候应该怎么处理快递,也就是你能“识别快递”。
  • 当快递到达目的地了,你收到了快递到来的通知,但是你不一定要马上下楼取快递,也就是说取快递的行为并不是一定要立即执行,可以理解成在“在合适的时候去取”。
  • 在你收到快递到达的通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间内你并没有拿到快递,但是你知道快递已经到了,本质上是你“记住了有一个快递要去取”。
  • 当你时间合适,顺利拿到快递之后,就要开始处理快递了,而处理快递的方式有三种:1、执行默认动作(打开快递,使用商品)2、执行自定义动作(快递是帮别人买的,你要将快递交给他)3、忽略(拿到快递后,放在一边继续做自己的事)。
  • 快递到来的整个过程,对你来讲是异步的,你不能确定你的快递什么时候到。
  • 生活中,我们也经常遇到信号,比如早八的闹钟、红绿灯、鲁大师在电脑里面抽烟。在信号还没有产生的时候,我们已经知道什么样的信号需要如何去处理,比如早八的闹钟,在闹钟响之前,我们就知道早上要么起床去上课,要么把闹钟一关继续睡觉。

  • 信号产生了,我们不一定要立即处理他,而是在合适的时候处理。比如有人找你出去,但是游戏已经开了,没有办法,相信你也会和我一样打完游戏再和朋友出去,那么在游戏的过程中我们实际上已经收到了“要出去的信号,并将它记在了脑子里”,因此我们不仅能够识别信号还要对信号进行暂时保存

小总结:

  • 我们能识别信号,同时知道该如何处理
  • 信号是异步的
  • 可以对信号进行暂时保存
  • 在合适的时候处理信号

1.2、【ctrl+ c与z】

编写以下程序并运行:

#include <stdio.h>
#include <unistd.h>int main()
{while (1){printf("hello signal!\n");sleep(1);}return 0;
}

我们知道该程序的运行结果就是死循环地进行打印,而对于死循环来说,最好的方式就是使用Ctrl+C对其进行终止。

那么这里就有一个问题即为什么使用Ctrl+C后,该进程就终止了?

进程在运行时,一般都是前台进程,如下代码,./mytest运行起来,我们输入命令没用反应,但我们运行时输入ctrl+c可以终止掉进程。

如果我们执行的时候在后面加上 & ,就让进程在后台运行,此时输入命令可以反应,但是进程不能被ctrl+c终止。 

这是因为前台进程只能有一个,后台进程可以有多个,由于shell也是一个进程,因此当进程在前台运行时,shell需要退入到后台进程中去,因此无法接受指令。而你现在输入ctrl+c终止进程,就是让前台进程终止,自然进程就结束了。

而当进程以后台方式启动时,前台进程是shell,因此输入指令可以有结果,如果你输入ctrl+c,shell自己会保护自己,无法被ctrl+c终止,后台进程并没有收到ctrl+c,因此无法终止后台进程。

特别说明一下:

好了,现在可以回到我们实际的问题了,ctrl+c到底是如何让我们的进程终止的,实际上当用户按Ctrl+C时,这个键盘输入会产生一个硬中断,被操作系统获取并解释成信号(Ctrl+C被解释成2号信号),然后操作系统将2号信号发送给目标前台进程,当前台进程收到2号信号后就会退出。

我们可以使用signal函数对2号信号进行捕捉,证明当我们按Ctrl+C时进程确实是收到了2号信号。使用signal函数时,我们需要传入两个参数,第一个是需要捕捉的信号编号,第二个是对捕捉信号的处理方法,该处理方法的参数是int,返回值是void。

例如,下面的代码中将2号信号进行了捕捉,当该进程运行起来后,若该进程收到了2号信号就会打印出收到信号的信号编号。

#include <stdio.h>
#include <signal.h>
#include <unistd.h>void handler(int sig)
{printf("get a signal:%d\n", sig);
}int main()
{signal(2, handler); //注册2号信号while (1){printf("hello signal!\n");sleep(1);}return 0;
}

此时当该进程收到2号信号后,就会执行我们给出的handler方法,而不会像之前一样直接退出了,因为此时我们已经将2号信号的处理方式由默认改为了自定义了。

由此也证明了,当我们按Ctrl+C时进程确实是收到了2号信号。

意:

  1. Ctrl+C产生的信号只能发送给前台进程。在一个命令后面加个&就可以将其放到后台运行,这样Shell就不必等待进程结束就可以接收新的命令,启动新的进程。
  2. Shell可以同时运行一个前台进程和任意多个后台进程,但是只有前台进程才能接到像Ctrl+C这种控制键产生的信号。
  3. 前台进程在运行过程中,用户随时可能按下Ctrl+C而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都可能收到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步的。
  4. 信号是进程之间事件异步通知的一种方式,属于软中断。

实际上,除了ctrl+c我们还可以输入ctrl+z用来暂停前台进程,但是被暂停的进程会自动放到后台,也就是我输入指令命令行能接受了。

现在我们输入 bg+number ,就可以将暂停的后台进程继续运行。

小总结:

  • ./mytest & 让进程在后台运行
  • jobs 查看后台进程
  • fg + number 将后台进程放到前台
  • ctrl+z 暂停前台进程并放到后台
  • bg + number 将暂停的后台进程继续执行    、

1.3、【信号的发送与记录】

我们使用kill -l命令可以查看Linux当中的信号列表。

其中1~31号信号是普通信号,34~64号信号是实时信号,普通信号和实时信号各自都有31个,每个信号都有一个编号和一个宏定义名称:

那么信号是如何记录的?

实际上,当一个进程接收到某种信号后,该信号是被记录在该进程的进程控制块当中的。我们都知道进程控制块本质上就是一个结构体变量task_struct,而对于信号来说我们主要就是记录某种信号是否产生,因此,我们可以用一个32位的位图来记录信号是否产生。

其中比特位的位置代表信号的编号,而比特位的内容就代表是否收到对应信号,比如第6个比特位是1就表明收到了6号信号。

信号是如何产生的?

一个进程收到信号,本质就是该进程内的信号位图被修改了,也就是该进程的数据被修改了,而只有操作系统才有资格修改进程的数据,因为操作系统是进程的管理者。也就是说,信号的产生本质上就是操作系统直接去修改目标进程的task_struct中的信号位图。

注意: 信号只能由操作系统发送,但信号发送的方式有多种。

就像我们上面ctrl+c的例子:

  • 我们在键盘中输入ctrl+c,由于CPU上会存在特定的引脚,会识别我们的输入,ctrl+c最终会被操作系统接收,因为操作系统是进程的管理者,他判断出你输入的内容是终止信号,同时是输入给当前运行的前台进程的。那么操作系统肯定需要告诉进程,信号到来了,你赶紧处理。
  • 在进程的角度来看,进程一定要通过存储来表示自己是否收到信号,收到了哪种信号。这完全可以通过位图来实现,比特位的位置代表收到的哪种信号,比特为的内容0/1代表是否收到信号。由于普通信号只有1-31个,因此用32位的整形变量就可以判断了。
  • 我们之前提到,在信号没有到来时,进程就已经知道该如何处理相关信号,因此进程的结构体中还得有一张自己的函数指针数组,数组的下标与信号的编号相关,于是,当发现信号位图中有信号的到来,知道是哪一张信号,然后会调用信号相对应的函数进行执行。

  • 操作系统向目标进程发送信号,其实本质上是写信号,将该进程信号位图的相关位置设置为1​

1.4、【信号处理常见方式概述】

  1. 执行该信号的默认处理动作。
  2. 提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。
  3. 忽略该信号。
 man 7 signal

二、【信号的产生】

2.1、【通过终端按键产生信号】

当面对下面的死循环程序时,我们都知道可以按Ctrl+C可以终止该进程。

#include <stdio.h>
#include <unistd.h>int main()
{while (1){printf("hello signal!\n");sleep(1);}return 0;
}

但实际上除了按Ctrl+C之外,按Ctrl+\也可以终止该进程。

而ctrl+/的原理与ctrl+c的原理相同,我们可以通过以下代码,将1~31号信号全部进行捕捉,将收到信号后的默认处理动作改为打印收到信号的编号。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>void handler(int signal)
{printf("get a signal:%d\n", signal);
}
int main()
{int signo;for (signo = 1; signo <= 31; signo++){signal(signo, handler);}while (1){sleep(1);}return 0;
}

此时,当我们按下组合按键Ctrl+C、Ctrl+\、Ctrl+Z后,便可以得知这些组合按键分别是向前台进程发送几号信号了。

但如果我们此时向该进程发送9号信号,该进程并不会打印收到了9号信号,而是执行收到9号信号后的默认处理动作,即被终止。

说明: 有些信号是不能被捕捉的,比如9号信号。因为如果所有信号都能被捕捉的话,那么进程就可以将所有信号全部进行捕捉并将动作设置为忽略,此时该进程将无法被杀死,即便是操作系统。也就是说9号信号不可被自定义捕捉,因为操作系统得有能力杀死任意进程,如果某个进程是恶意进程,一直在读取你的数据,或者对操作系统做破坏,那么该进程捕捉了所有信号,岂不是杀不掉了,因此操作系统设置9号信号不可被捕捉。

2.2、【核心转储】

1、【引入】

那么让我们思考这样一个问题,按Ctrl+C终止进程和按Ctrl+\终止进程,有什么区别?

按Ctrl+C实际上是向进程发送2号信号SIGINT,而按Ctrl+\实际上是向进程发送3号信号SIGQUIT。查看这两个信号的默认处理动作,可以看到这两个信号的Action是不一样的,2号信号是Term,而3号信号是Core。

Term和Core都代表着终止进程,但是Core在终止进程的时候会进行一个动作,那就是核心转储。

那么什么是核心转储?

在云服务器中,核心转储是默认被关掉的,我们可以通过使用ulimit -a命令查看当前资源限制的设定。

其中,第一行显示core文件的大小为0,即表示核心转储是被关闭的。

我们可以通过ulimit -c size命令来设置core文件的大小。

core文件的大小设置完毕后,就相当于将核心转储功能打开了。此时如果我们再使用Ctrl+\对进程进行终止,就会发现终止进程后会显示core dumped

并且会在当前路径下生成一个core文件,该文件以一串数字为后缀,而这一串数字实际上就是发生这一次核心转储的进程的PID。

说明一下: ulimit命令改变的是Shell进程的Resource Limit,但myproc进程的PCB是由Shell进程复制而来的,所以也具有和Shell进程相同的Resource Limit值。

2、【作用】

当我们的代码出错了,我们最关心的是我们的代码是什么原因出错的。如果我们的代码运行结束了,那么我们可以通过退出码来判断代码出错的原因,而如果一个代码是在运行过程中出错的,那么我们也要有办法判断代码是什么原因出错的。

当我们的程序在运行过程中崩溃了,我们一般会通过调试来进行逐步查找程序崩溃的原因。而在某些特殊情况下,我们会用到核心转储,核心转储指的是操作系统在进程收到某些信号而终止运行时,将该进程地址空间的内容以及有关进程状态的其他信息转而存储到一个磁盘文件当中,这个磁盘文件也叫做核心转储文件,一般命名为core.pid。而核心转储的目的就是为了在调试时,方便问题的定位。

3、【使用】

我们用下面这段代码进行演示:

很明显,该代码当中出现了除0错误,该程序运行3秒后便会崩溃。

此时我们便可以在当前目录下看到核心转储时生成的core文件。

使用gdb对当前可执行程序进行调试,然后直接使用core-file core文件命令加载core文件,即可判断出该程序在终止时收到了8号信号,并且定位到了产生该错误的具体代码。

说明一下: 事后用调试器检查core文件以查清错误原因,这种调试方式叫做事后调试。

4、【core dump标志】

还记得进程等待函数waitpid函数的第二个参数吗:

pid_t waitpid(pid_t pid, int *status, int options);

waitpid函数的第二个参数status是一个输出型参数,用于获取子进程的退出状态。status是一个整型变量,但status不能简单的当作整型来看待,status的不同比特位所代表的信息不同,具体细节如下(只关注status低16位比特位):

若进程是正常终止的,那么status的次低8位就表示进程的退出状态,即退出码。若进程是被信号所杀,那么status的低7位表示终止信号,而第8位比特位是core dump标志,即进程终止时是否进行了核心转储。

打开Linux的核心转储功能,并编写下列代码。代码中父进程使用fork函数创建了一个子进程,子进程所执行的代码当中存在野指针问题,当子进程执行到*p = 100时,必然会被操作系统所终止并在终止时进行核心转储。此时父进程使用waitpid函数便可获取到子进程退出时的状态,根据status的第7个比特位便可得知子进程在被终止时是否进行了核心转储。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>int main()
{if (fork() == 0){//childprintf("I am running...\n");int *p = NULL;*p = 100;exit(0);}//fatherint status = 0;waitpid(-1, &status, 0);printf("exitCode:%d, coreDump:%d, signal:%d\n",(status >> 8) & 0xff, (status >> 7) & 1, status & 0x7f);return 0;
}

可以看到,所获取的status的第7个比特位为1,即可说明子进程在被终止时进行了核心转储。

因此,core dump标志实际上就是用于表示程序崩溃的时候是否进行了核心转储。

2.3、【通过系统函数向进程发信号】

1、【kill函数】

当我们要使用kill命令向一个进程发送信号时,我们可以以kill -信号名 进程ID的形式进行发送。

也可以以kill -信号编号 进程ID的形式进行发送。

实际上kill命令是通过调用kill函数实现的,kill函数可以给指定的进程发送指定的信号,kill函数的函数原型如下:

int kill(pid_t pid, int sig);

kill函数用于向进程ID为pid的进程发送sig号信号,如果信号发送成功,则返回0,否则返回-1。

我们可以用kill函数模拟实现一个kill命令,实现逻辑如下:

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>void Usage(char* proc)
{printf("Usage: %s pid signo\n", proc);
}
int main(int argc, char* argv[])
{if (argc != 3){Usage(argv[0]);return 1;}pid_t pid = atoi(argv[1]);int signo = atoi(argv[2]);kill(pid, signo);return 0;
}

为了让生成的可执行程序在执行时不用带上路径,我们可以将当前路径导入环境变量PATH当中。

此时我们便模拟实现了一个kill命令,该命令的使用方式为mykill 进程ID 信号编号

2、【raise函数】

raise函数可以给当前进程发送指定信号,即自己给自己发送信号,raise函数的函数原型如下:

int raise(int sig);

raise函数用于给当前进程发送sig号信号,如果信号发送成功,则返回0,否则返回一个非零值。

例如,下列代码当中用raise函数每隔一秒向自己发送一个2号信号。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>void handler(int signo)
{printf("get a signal:%d\n", signo);
}
int main()
{signal(2, handler);while (1){sleep(1);raise(2);}return 0;
}

运行结果就是该进程每隔一秒收到一个2号信号。

3、【abort函数】

raise函数可以给当前进程发送SIGABRT信号,使得当前进程异常终止,abort函数的函数原型如下:

void abort(void);

abort函数是一个无参数无返回值的函数。

例如,下列代码当中每隔一秒向当前进程发送一个SIGABRT信号。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>void handler(int signo)
{printf("get a signal:%d\n", signo);
}
int main()
{signal(6, handler);while (1){sleep(1);abort();}return 0;
}

与之前不同的是,虽然我们对SIGABRT信号进行了捕捉,并且在收到SIGABRT信号后执行了我们给出的自定义方法,但是当前进程依然是异常终止了。

说明一下: abort函数的作用是异常终止进程,也就是说abort函数发送的信号使得当前进程异常了,所以操作系统处于安全考虑也会将该进程终止,exit函数的作用是正常终止进程,而abort本质是通过向当前进程发送SIGABRT信号而终止进程的,因此使用exit函数终止进程可能会失败,但使用abort函数终止进程总是成功的,那么下面我们来看看异常和信号之间会有什么关系。

2.4、【异常导致信号产生】

C++我们学过异常,发生异常了,代码并不会再往后面执行,而是直接终止,除非你捕获了异常。不妨思考一下,下面的问题:

当我们程序当中出现类似于除0、野指针、越界之类的错误时,为什么程序会崩溃?本质上是因为进程在运行过程中收到了操作系统发来的信号进而被终止,那操作系统是如何识别到一个进程触发了某种问题的呢?

1、【 除0异常】

如下代码,你发生了除0错误,编译的时候就会警告,当你执行的时候,告诉你浮点数异常。这是8号信号 SIGFPE。

我们将信号捕捉,运行,我们并没有写循环,按道理只会打印一次,这里发现进程在一直打印消息。

【解释】

这是因为进程在运行的时候,会将进程中的数据放到CPU里面的寄存器去运行。我们知道,CPU当中有一堆的寄存器,当我们需要对两个数进行算术运算时,我们是先将这两个操作数分别放到两个寄存器当中,然后进行算术运算并把结果写回寄存器当中,然后进行算术运算并把结果写回寄存器当中。此外,CPU当中还有一组寄存器叫做状态寄存器,它可以用来标记当前指令执行结果的各种状态信息,如有无进位、有无溢出等等。而操作系统是软硬件资源的管理者,在程序运行过程中,若操作系统发现CPU内的某个状态标志位被置位,而这次置位就是因为出现了某种除0错误而导致的,那么此时操作系统就会马上识别到当前是哪个进程导致的该错误,并将所识别到的硬件错误包装成信号发送给目标进程,本质就是操作系统去直接找到这个进程的task_struct,并向该进程的位图中写入8信号,写入8号信号后这个进程就会在合适的时候被终止。

现在CPU运算发现了除0错误,会给相应的标志位写上1,记录你的错误,操作系统就知道进程的错误了(因为操作系统是软硬件资源的管理者),那么操作系统需要发送对应信号干掉目标进程。

但是你自己的操作,将信号自定义捕捉了,杀死不了,而后进程就切换了,当进程又切换回来时,操作系统又发现你对应的标志位为1,因此又给你发送信号,从此往复,会一直发送信号,却又杀不掉你。
 

2、【段错误异常 】

那对于下面的野指针问题,或者越界访问的问题时,操作系统又是如何识别到的呢?

运行结果:

【解释】

首先我们必须知道的是,当我们要访问一个变量时,一定要先经过页表的映射,将虚拟地址转换成物理地址,然后才能进行相应的访问操作。

其中页表属于一种软件映射关系,而实际上在从虚拟地址到物理地址映射的时候还有一个硬件叫做MMU,它是一种负责处理CPU的内存访问请求的计算机硬件,因此映射工作不是由CPU做的,而是由MMU做的,但现在MMU已经集成到CPU当中了。

当需要进行虚拟地址到物理地址的映射时,我们先将页表的左侧的虚拟地址导给MMU,然后MMU会计算出对应的物理地址,我们再通过这个物理地址进行相应的访问。

而MMU既然是硬件单元,那么它当然也有相应的状态信息,当我们要访问不属于我们的虚拟地址时,MMU在进行虚拟地址到物理地址的转换时就会出现错误,然后将对应的错误写入到自己的状态信息当中,这时硬件上面的信息也会立马被操作系统识别到,进而将对应进程发送SIGSEGV信号。

总结一下:
C/C++程序会崩溃,是因为程序当中出现的各种错误最终一定会在硬件层面上有所表现,进而会被操作系统识别到,然后操作系统就会发送相应的信号将当前的进程终止。

2.5、【软件条件产生信号】

1、【SIGPIPE信号】

学习之前先搞清楚什么是软件条件:

我们之前在学习管道的时候,当我们把读端关闭的时候,写端会直接终止,因为操作系统给写端发送SIGPIPE信号,告诉写端,兄弟别舔了,她已经把你拉黑了。此时我们并没有涉及到寄存器或者其他硬件设备,虽说有内存,但是内存空间都是我自己开辟的呀,我自己的东西我爱咋用咋用,操作系统不会无缘无故给你发信号,这就叫软件条件。

这里我们再来了解一下SIGPIPE信号:

SIGPIPE信号实际上就是一种由软件条件产生的信号,当进程在使用管道进行通信时,读端进程将读端关闭,而写端进程还在一直向管道写入数据,那么此时写端进程就会收到SIGPIPE信号进而被操作系统终止。

例如,下面代码当中,创建匿名管道进行父子进程之间的通信,其中父进程是读端进程,子进程是写端进程,但是一开始通信父进程就将读端关闭了,那么此时子进程在向管道写入数据时就会收到SIGPIPE信号,进而被终止。

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{int fd[2] = { 0 };if (pipe(fd) < 0){ //使用pipe创建匿名管道perror("pipe");return 1;}pid_t id = fork(); //使用fork创建子进程if (id == 0){//childclose(fd[0]); //子进程关闭读端//子进程向管道写入数据const char* msg = "hello father, I am child...";int count = 10;while (count--){write(fd[1], msg, strlen(msg));sleep(1);}close(fd[1]); //子进程写入完毕,关闭文件exit(0);}//fatherclose(fd[1]); //父进程关闭写端close(fd[0]); //父进程直接关闭读端(导致子进程被操作系统杀掉)int status = 0;waitpid(id, &status, 0);printf("child get signal:%d\n", status & 0x7F); //打印子进程收到的信号return 0;
}

运行代码后,即可发现子进程在退出时收到的是13号信号,即SIGPIPE信号。

2、【SIGALRM信号】

调用alarm函数可以设定一个闹钟,也就是告诉操作系统在若干时间后发送SIGALRM信号给当前进程,alarm函数的函数原型如下:

unsigned int alarm(unsigned int seconds);

alarm函数的作用就是,让操作系统在seconds秒之后给当前进程发送SIGALRM信号,SIGALRM信号的默认处理动作是终止进程。

alarm函数的返回值:

  • 若调用alarm函数前,进程已经设置了闹钟,则返回上一个闹钟时间的剩余时间,并且本次闹钟的设置会覆盖上一次闹钟的设置。
  • 如果调用alarm函数前,进程没有设置闹钟,则返回值为0。

例如,我们可以用下面的代码,测试自己的云服务器一秒时间内可以将一个变量累加到多大。

#include <stdio.h>
#include <signal.h>
#include <unistd.h>int main()
{int count = 0;alarm(1);while (1){count++;printf("count: %d\n", count);}return 0;
}

运行代码后,可以发现我当前的云服务器在一秒内可以将一个变量累加到两万左右。

主要原因有两个,首先,由于我们每进行一次累加就进行了一次打印操作,而与外设之间的IO操作所需的时间要比累加操作的时间更长,其次,由于我当前使用的是云服务器,因此在累加操作后还需要将累加结果通过网络传输将服务器上的数据发送过来,因此最终显示的结果要比实际一秒内可累加的次数小得多。

为了尽可能避免上述问题,我们可以先让count变量一直执行累加操作,直到一秒后进程收到SIGALRM信号后再打印累加后的数据。

#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>int count = 0;
void handler(int signo)
{printf("get a signal: %d\n", signo);printf("count: %d\n", count);exit(1);
}
int main()
{signal(SIGALRM, handler);alarm(1);while (1){count++;}return 0;
}

此时可以看到,count变量在一秒内被累加的次数变成了五亿多,由此也证明了,与计算机单纯的计算相比较,计算机与外设进行IO时的速度是非常慢的。

我们也可以利用alarm函数来设计一个闹钟:

综上,产生信号的方式有很多,但是发送信号只能由操作系统来完成!!!也就是向进程PCB中的信号位图写入。

总结

  • 信号的产生,最终都要由操作系统来进行执行,这是因为操作系统是进程的管理者。
  • 进程在信号到来不会立即处理,而是在合适的时候处理。
  • 由于信号不会被立即处理,因此信号需要暂时被进程记录下来,记录在信号位图中。
  • 信号在没有收到信号的时候,已经知道自己该如何处理信号。
  • 操作系统向进程发送信号,本质是向目标进程的信号位图中写信号(置1)。

三、【信号的保存】

3.1、【阻塞信号】

1、【信号相关常见概念】

  • 实际执行信号的处理动作,称为信号递达(Delivery)。
  • 信号从产生到递达之间的状态,称为信号未决(pending)。
  • 进程可以选择阻塞(Block)某个信号。
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
  • 需要注意的是,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后的一种处理动作。

2、【内核中标识信号的三张表】

信号在内核中的表示示意图如下:

  • 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),hander表中还会存在一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志(存在为1,否则为0),直到信号递达才清除该标志。在上图中,SIGHUP(1)信号未阻塞也未产生过,当它递达时执行默认处理动作(SIG_DFL),这里提一下图中hander表中的SIG_DFL,SIG_IGN两个标志本质上是#define定义的宏分别对应0和1,其中SIG_DFL表示执行默认处理动作而SIG_IGN表示忽略,这里我们要知道进程对信号进行忽略其实也是一种处理动作。
  • SIGINT(2)信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略(SIG_IGN),但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再解除阻塞。
  • SIGQUIT(3)信号未产生过,但一旦产生SIGQUIT信号,该信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前,这种信号产生过多次,POSIX.1允许系统递达该信号一次或多次。Linux是这样实现的:普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里,这里只讨论普通信号。

总结一下:

  1. 在block位图中,比特位的位置代表某一个信号,比特位的内容代表该信号是否被阻塞。
  2. 在pending位图中,比特位的位置代表某一个信号,比特位的内容代表是否收到该信号,还记得我们之前在信号产生里说的发送信号的本质是操作系统往task_struct的位图里写入信号,这里的位图指的就是pending表。
  3. handler表本质上是一个函数指针数组,数组的下标代表某一个信号,数组的内容代表该信号递达时的处理动作,处理动作包括默认、忽略以及自定义。
  4. block、pending和handler这三张表的每一个位置是一一对应的,并且我们的进程在收到某个信号之前已经具有识别该信号的能力。
  5. SIG_DFL和SIG_IGN的本质就是被函数指针强转的0和1,主要用于判断而不是函数的地址,因为用户传递的地址是非常大的,0和1地址处是代码区,用户是没有设置权限的,因此不用担心传递的自定义函数地址是0和1,如果是0和1,编译器就会特殊处理,知道你想要的是默认还是忽略,并且忽略也是一种处理方式

3.2、【介绍sigset_t】

根据信号在内核中的表示方法,每个信号的未决标志只有一个比特位,非0即1,如果不记录该信号产生了多少次,那么阻塞标志也只有一个比特位

因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储。在我当前的云服务中,sigset_t类型的定义如下:(不同操作系统实现sigset_t的方案可能不同)

#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;typedef __sigset_t sigset_t;

sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态。

  • 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞。
  • 在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。

阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

3.3、【信号集操作函数】
 

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”,至于这个类型内部如何存储这些bit则依赖于系统的实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。

我们可以来看一下这些函数:

#include <signal.h>int sigemptyset(sigset_t *set);
//sigemptyset函数:初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。int sigfillset(sigset_t *set);
//sigfillset函数:初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。int sigaddset(sigset_t *set, int signum);
//sigaddset函数:在set所指向的信号集中添加某种有效信号。int sigdelset(sigset_t *set, int signum);
//sigdelset函数:在set所指向的信号集中删除某种有效信号。//注:sigemptyset、sigfillset、sigaddset和sigdelset函数都是成功返回0,出错返回-1。int sigismember(const sigset_t *set, int signum);  
//sigismember函数:判断在set所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用失败返回-1。

注意: 在使用sigset_t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号处于确定的状态。

例如,我们可以按照如下方式使用这些函数:

#include <stdio.h>
#include <signal.h>int main()
{sigset_t s; //用户空间定义的变量sigemptyset(&s);sigfillset(&s);sigaddset(&s, SIGINT);//添加2号信号sigdelset(&s, SIGINT);sigismember(&s, SIGINT);return 0;
}

注意: 代码中定义的sigset_t类型的变量s,与我们平常定义的变量一样都是在用户空间定义的变量,所以后面我们用信号集操作函数对变量s的操作实际上只是对用户空间的变量s做了修改,并不会影响进程的任何行为。因此,我们还需要通过系统调用,才能将变量s的数据设置进操作系统。

3.4、【sigprocmask函数】

sigprocmask函数可以用于读取或更改进程的信号屏蔽字(阻塞信号集),该函数的函数原型如下:

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

参数说明:

  • 如果oset是非空指针,则读取进程当前的信号屏蔽字通过oset参数传出。
  • 如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。
  • 如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。

假设当前的信号屏蔽字为mask,下表说明了how参数的可选值及其含义:

返回值说明:

  • sigprocmask函数调用成功返回0,出错返回-1。

注意: 如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。

下面我们做一个实验,我们让进程2号信号阻塞,如果没阻塞,肯定会打印消息,阻塞了发送2号信号就没有反应。

现在无论我们是ctrl+c,还是发送2号信号,该进程都不会有反应,因为2号信号被阻塞了。

我们可以阻塞绝大部分信号,但是进程无法阻塞9号信号。9号信号是管理员信号,操作系统发现恶意进程会直接发9号信号kill他,可不管你有没有屏蔽。

3.5、【sigpending函数】

sigpending函数可以用于读取进程的未决信号集,该函数的函数原型如下:

int sigpending(sigset_t *set);

sigpending函数读取当前进程的未决信号集,并通过set参数传出。该函数调用成功返回0,出错返回-1。

下面我们来做一个简单的实验,实验步骤如下:

  1. 先用上述的函数将2号信号进行屏蔽(阻塞)。
  2. 使用kill命令或组合按键向进程发送2号信号。
  3. 此时2号信号会一直被阻塞,并一直处于pending(未决)状态。
  4. 使用sigpending函数获取当前进程的pending信号集进行验证。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>void printPending(sigset_t *pending)
{int i = 1;for (i = 1; i <= 31; i++){if (sigismember(pending, i)){printf("1 ");}else{printf("0 ");}}printf("\n");
}
int main()
{sigset_t set, oset;sigemptyset(&set);sigemptyset(&oset);sigaddset(&set, 2); //SIGINTsigprocmask(SIG_SETMASK, &set, &oset); //阻塞2号信号sigset_t pending;sigemptyset(&pending);while (1){sigpending(&pending); //获取pendingprintPending(&pending); //打印pending位图(1表示未决)sleep(1);}return 0;
}

可以看到,程序刚刚运行时,因为没有收到任何信号,所以此时该进程的pending表一直是全0,而当我们使用kill命令向该进程发送2号信号后,由于2号信号是阻塞的,因此2号信号一直处于未决状态,所以我们看到pending表中的第二个数字一直是1。

为了看到2号信号递达后pending表的变化,我们可以设置一段时间后,自动解除2号信号的阻塞状态,解除2号信号的阻塞状态后2号信号就会立即被递达。因为2号信号的默认处理动作是终止进程,所以为了看到2号信号递达后的pending表,我们可以将2号信号进行捕捉,让2号信号递达时执行我们所给的自定义动作。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>void printPending(sigset_t *pending)
{int i = 1;for (i = 1; i <= 31; i++){if (sigismember(pending, i)){printf("1 ");}else{printf("0 ");}}printf("\n");
}
void handler(int signo)
{printf("handler signo:%d\n", signo);
}
int main()
{signal(2, handler);sigset_t set, oset;sigemptyset(&set);sigemptyset(&oset);sigaddset(&set, 2); //SIGINTsigprocmask(SIG_SETMASK, &set, &oset); //阻塞2号信号sigset_t pending;sigemptyset(&pending);int count = 0;while (1){sigpending(&pending); //获取pendingprintPending(&pending); //打印pending位图(1表示未决)sleep(1);count++;if (count == 20){sigprocmask(SIG_SETMASK, &oset, NULL); //恢复曾经的信号屏蔽字printf("恢复信号屏蔽字\n");}}return 0;
}

此时就可以看到,进程收到2号信号后,该信号在一段时间内处于未决状态,当解除2号信号的屏蔽后,2号信号就会立即递达,执行我们所给的自定义动作,而此时的pending表也变回了全0。

细节: 在解除2号信号后,2号信号的自定义动作是在打印“恢复信号屏蔽字”之前执行的。因为如果调用sigprocmask解除对当前若干个未决信号的阻塞,则在sigprocmask函数返回前,至少将其中一个信号递达。

下面我们来看这样一个问题:

我们知道,当进程收到信号,执行相关操作后,pending表对应的信号bit位会被置为0,那他是将处理操作做完再置0还是处理之前就置为0了呢?

我们写如下代码验证一下:

四、【信号的处理】

4.1、【信号的捕捉】

我们一直在说,信号会在合适的时候处理,那么具体什么时候是合适的时候?

其实是进程从内核态返回到用户态的时候,会进行信号的检测和信号的处理。

那么什么是内核态和用户态呢?

其中用户态是一种受控的状态,能够访问的资源是有限的,内核态是一种操作系统的工作状态,能访问大部分的系统资源。

在搞清楚这个问题之前,我们要先搞清楚一个问题,那就是之前我们在学习进程地址空间时图中存在的内核空间与用户空间到底是什么?

每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间组成:

  • 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
  • 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。

我们之前接触的页表都是用户级页表,这里我们看看内核级页表,内核级页表是一个全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。

需要注意的是,虽然每个进程都能够看到操作系统,但并不意味着每个进程都能够随时对其进行访问。

基于用户级空间和内核级空间,我们现在该如何理解进程切换?

  1. 在当前进程的进程地址空间中的内核空间,找到操作系统的代码和数据。
  2. 执行操作系统的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。

注意: 当你访问用户空间时你必须处于用户态,当你访问内核空间时你必须处于内核态。所以下面让我们来了解以下到底什么是用户态和内核态。

4.2、【内核态与用户态】

内核态与用户态:

  • 内核态通常用来执行操作系统的代码,是一种权限非常高的状态。
  • 用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。

进程收到信号之后,并不是立即处理信号,而是在合适的时候,这里所说的合适的时候实际上就是指,从内核态切换回用户态的时候。

1、内核态和用户态之间是进行如何切换的?

从用户态切换为内核态通常有如下几种情况:

  1. 需要进行系统调用时。
  2. 当前进程的时间片到了,导致进程切换。
  3. 产生异常、中断、陷阱等。

与之相对应,从内核态切换为用户态有如下几种情况:

  1. 系统调用返回时。
  2. 进程切换完毕。
  3. 异常、中断、陷阱等处理完毕。

其中,由用户态切换为内核态我们称之为陷入内核。每当我们需要陷入内核的时,本质上是因为我们需要执行操作系统的代码,比如系统调用函数是由操作系统实现的,我们要进行系统调用就必须先由用户态切换为内核态,这里的切换本质上其实是CPU中存在一个CS寄存器,寄存器中存在两个比特位来标识当前进程是处于用户态还是内核态,其中1标识内核态,3标识用户态。

2、那么内核如何实现信号的捕捉呢?

当我们在执行主控制流程的时候,可能因为某些情况而陷入内核,当内核处理完毕准备返回用户态时,就需要进行信号pending的检查。(此时仍处于内核态,有权力查看当前进程的pending位图)

在查看pending位图时,如果发现有未决信号,并且该信号没有被阻塞,那么此时就需要该信号进行处理。

如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行即可。

但如果待处理信号是自定义捕捉的,即该信号的处理动作是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用sigreturn再次陷入内核并清除对应的pending标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。

注意: sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。

当待处理信号是自定义捕捉时的情况比较复杂,可以借助下图进行记忆:

其中,该图形与直线有几个交点就代表在这期间有几次状态切换,而箭头的方向就代表着此次状态切换的方向,图形中间的圆点就代表着检查pending表。

2、下面我们再来看这样一个问题:

当识别到信号的处理动作是自定义时,能直接在内核态执行用户空间的代码吗?

理论上来说是可以的,因为内核态是一种权限非常高的状态,但是绝对不能这样设计

如果允许在内核态直接执行用户空间的代码,那么用户就可以在代码中设计一些非法操作,比如清空数据库等,虽然在用户态时没有足够的权限做到清空数据库,但是如果是在内核态时执行了这种非法代码,那么数据库就真的被清空了,因为内核态是有足够权限清空数据库的。

也就是说,不能让操作系统直接去执行用户的代码,因为操作系统无法保证用户的代码是合法代码,即操作系统不信任任何用户。

 下面再让我们来叙述一下:

我们所写的代码,如果涉及到了系统调用,那么就会将身份切换为内核,那么此时就可以访问内核中的数据结构或字段,当访问结束,准备返回时,并不会立刻返回,而是利用内核身份先看看进程block和pending是否有内容,发现有内容,就会做相应的处理。

就算你没有代码里写得很干净,确实没有系统调用,但是当进程切换的时候,进程也会在用户态和内核态转化,因此不用担心你的代码不会被终止。但这里也涉及到一些问题,这样可能会中断关键代码的执行,因此还需要合理的信号函数或者设置关键代码适当的同步机制来确保其原子性执行(原子性(Atomicity)是指一个操作在执行过程中不可分割,即这个操作要么全部执行,要么全部不执行,不会出现执行一半被其他操作打断的情况。原子操作是不可中断的,无论系统如何调度,原子操作总是能一次性执行完毕。)

如果检测的时候发现某个信号block没有被阻塞,同时pending表为1,就要根据handler表做相应的处理,此时身份一定要切换为用户,为了防止进程利用handler的内核身份去执行自己的恶意操作。那么执行完毕,需要返回时,如果直接返回到用户代码处,那么你怎么知道代码执行到哪一步了,该返回到那个地方,你仅仅是个函数呀!

那么你执行完sighandler函数后,仍然需要执行系统调用sigreturn回到内核(虽然我们代码中没写,但是操作系统会帮我们执行),之后就能访问到相关寄存器或者pc指针等等内容,直到当前进程执行到哪里了,回去继续执行代码。

4.3、【sigaction】

捕捉信号除了用前面用过的signal函数之外,我们还可以使用sigaction函数对信号进行捕捉,sigaction函数的函数原型如下:

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

sigaction函数可以读取和修改与指定信号相关联的处理动作,该函数调用成功返回0,出错返回-1。

参数说明:

  • signum代表指定信号的编号。
  • 若act指针非空,则根据act修改该信号的处理动作。
  • 若oldact指针非空,则通过oldact传出该信号原来的处理动作。

其中,参数act和oldact都是结构体指针变量,该结构体的定义如下:

struct sigaction {void(*sa_handler)(int);void(*sa_sigaction)(int, siginfo_t *, void *);sigset_t   sa_mask;int        sa_flags;void(*sa_restorer)(void);
};

结构体的第一个成员sa_handler:

  • 将sa_handler赋值为常数SIG_IGN传给sigaction函数,表示忽略信号。
  • 将sa_handler赋值为常数SIG_DFL传给sigaction函数,表示执行系统默认动作。
  • 将sa_handler赋值为一个函数指针,表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。

注意: 所注册的信号处理函数的返回值为void,参数为int,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然这是一个回调函数,不是被main函数调用,而是被系统所调用。

结构体的第二个成员sa_sigaction:

  • sa_sigaction是实时信号的处理函数。

结构体的第三个成员sa_mask:

首先需要说明的是,当某个信号的处理函数被调用,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时,自动恢复原来的信号屏蔽字。

结构体的第四个成员sa_flags:

sa_flags字段包含一些选项,这里直接将sa_flags设置为0即可。

结构体的第五个成员sa_restorer: 该参数没有使用。

例如,下面我们用sigaction函数对2号信号进行了捕捉,将2号信号的处理动作改为了自定义的打印动作,并在执行一次自定义动作后将2号信号的处理动作恢复为原来默认的处理动作。

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>struct sigaction act, oact;
void handler(int signo)
{printf("get a signal:%d\n", signo);sigaction(2, &oact, NULL);
}
int main()
{memset(&act, 0, sizeof(act));memset(&oact, 0, sizeof(oact));act.sa_handler = handler;act.sa_flags = 0;sigemptyset(&act.sa_mask);sigaction(2, &act, &oact);while (1){printf("I am a process...\n");sleep(1);}return 0;
}

运行代码后,第一次向进程发送2号信号,执行我们自定义的打印动作,当我们再次向进程发送2号信号,就执行该信号的默认处理动作了,即终止进程。

四、【可重入函数】

下面主函数中调用insert函数向链表中插入结点node1,某信号处理函数中也调用了insert函数向链表中插入结点node2,乍眼一看好像没什么问题。

下面我们来分析一下,对于下面这个链表。

上述例子中,各函数执行的先后顺序如下:

像上例这样,insert函数被不同的控制流调用(main函数和sighandler函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立的控制流程),有可能在第一次调用还没返回时就再次进入该函数,我们将这种现象称之为重入。

而insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数我们称之为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称之为可重入(Reentrant)函数

如果一个函数符合以下条件之一则是不可重入的:

  1. 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  2. 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

五、【volatile】

volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。

在下面的代码中,我们对2号信号进行了捕捉,当该进程收到2号信号时会将全局变量flag由0置1。也就是说,在进程收到2号信号之前,该进程会一直处于死循环状态,直到收到2号信号时将flag置1才能够正常退出。

#include <stdio.h>
#include <signal.h>int flag = 0;void handler(int signo)
{printf("get a signal:%d\n", signo);flag = 1;
}
int main()
{signal(2, handler);while (!flag);printf("Proc Normal Quit!\n");return 0;
}

运行结果如下:

该程序的运行过程好像都在我们的意料之中,但实际并非如此。代码中的main函数和handler函数是两个独立的执行流,而while循环是在main函数当中的,在编译器编译时只能检测到在main函数中对flag变量的使用。

此时编译器检测到在main函数中并没有对flag变量做修改操作,在编译器优化级别较高的时候,就有可能将flag设置进寄存器里面。

此时main函数在检测flag时只检测寄存器里面的值,而handler执行流只是将内存中flag的值置为1了,那么此时就算进程收到2号信号也不会跳出死循环。

在编译代码时携带-O3选项使得编译器的优化级别最高,此时再运行该代码,就算向进程发生2号信号,该进程也不会终止。

面对这种情况,我们就可以使用volatile关键字对flag变量进行修饰,告知编译器,对flag变量的任何操作都必须真实的在内存中进行,即保持了内存的可见性。

#include <stdio.h>
#include <signal.h>volatile int flag = 0;void handler(int signo)
{printf("get a signal:%d\n", signo);flag = 1;
}
int main()
{signal(2, handler);while (!flag);printf("Proc Normal Quit!\n");return 0;
}

此时就算我们编译代码时携带-O3选项,当进程收到2号信号将内存中的flag变量置1时,main函数执行流也能够检测到内存中flag变量的变化,进而跳出死循环正常退出。

六、【SIGCHLD信号】

为了避免出现僵尸进程,父进程需要使用wait或waitpid函数等待子进程结束,父进程可以阻塞等待子进程结束,也可以非阻塞地查询的是否有子进程结束等待清理,即轮询的方式。采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

其实,子进程在终止时会给父进程发生SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait或waitpid函数清理子进程即可。

例如,下面代码中对SIGCHLD信号进行了捕捉,并将在该信号的处理函数中调用了waitpid函数对子进程进行了清理。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>void handler(int signo)
{printf("get a signal: %d\n", signo);int ret = 0;while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){printf("wait child %d success\n", ret);}
}
int main()
{signal(SIGCHLD, handler);if (fork() == 0){//childprintf("child is running, begin dead: %d\n", getpid());sleep(3);exit(1);}//fatherwhile (1);return 0;
}

注意:

  1. SIGCHLD属于普通信号,记录该信号的pending位只有一个,如果在同一时刻有多个子进程同时退出,那么在handler函数当中实际上只清理了一个子进程,因此在使用waitpid函数清理子进程时需要使用while不断进行清理。
  2. 使用waitpid函数时,需要设置WNOHANG选项,即非阻塞式等待,否则当所有子进程都已经清理完毕时,由于while循环,会再次调用waitpid函数,此时就会在这里阻塞住。

此时父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时父进程收到SIGCHLD信号,会自动进行该信号的自定义处理动作,进而对子进程进行清理。

事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal或sigaction函数将SIGCHLD信号的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用signal或sigaction函数自定义的忽略通常是没有区别的,但这是一个特列。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>int main()
{signal(SIGCHLD, SIG_IGN);if (fork() == 0){//childprintf("child is running, child dead: %d\n", getpid());sleep(3);exit(1);}//fatherwhile (1);return 0;
}

此时子进程在终止时会自动被清理掉,不会产生僵尸进程,也不会通知父进程。

七、【Linux中的31个普通信号】

1、【各普通信号的含义】

2、【注意】

两种不能被忽略的信号

SIGKILL和SIGSTOP

两种不能被捕捉的信号

SIGKILL和SIGSTOP


总结

本篇博客到这里就结束了,感谢观看!

....................................................................为何心心念念要的爱,一念之间可以say goodbye

                                                                                                             ————《一念之间》

相关文章:

【Linux———信号精讲】

你是怎么做到的&#xff0c;给了她想要的爱............................................................................................ 文章目录 前言 一、【信号入门】 1.1、【生活角度的信号】 1.2、【ctrl c与z】 1.3、【信号的发送与记录】 1.4、【信号处理常见方式…...

Unity 文字高度自适应

期望 文字有字号限制&#xff0c;输入文字文字后先判断高度是否适用于限制字号&#xff0c;若处于最小字号时高度任不适用&#xff0c;则调整RectTransform 的高度。 核心代码 每次输入文字时先将字号设定为原始字号。 comp.fontSize fontSize; comp.text content; 拓展T…...

鸿蒙通过用户首选项实现数据持久化

鸿蒙通过用户首选项实现数据持久化 1.1 场景介绍 用户首选项为应用提供Key-Value键值型的数据处理能力&#xff0c;支持应用持久化轻量级数据&#xff0c;并对其修改和查询。当用户希望有一个全局唯一存储的地方&#xff0c;可以采用用户首选项来进行存储。Preferences会将该…...

数字图像相关(DIC)技术用于生物力学和生物材料测试

生物医学工程是一个跨学科科学领域&#xff0c;旨在改善人类健康和医疗护理。从工程的角度来看&#xff0c;生物材料、力生物学和生物制造与目标生物系统的相互作用&#xff0c;以实现各种医学治疗目的。数字图像相关(DIC)技术&#xff0c;作为一种非接触、精准高效、无损的全场…...

java8中young gc的垃圾回收器选型,您了解嘛

在 Java 8 的 Young GC&#xff08;新生代垃圾回收&#xff09;场景中&#xff0c;对于 ToC的场景&#xff0c;即需要尽可能减少垃圾回收停顿时间以满足业务响应要求的场景&#xff0c;以下几种收集器各有特点&#xff0c;通常 Parnew和 G1 young表现较为出色&#xff0c;下面详…...

【五.LangChain技术与应用】【13.LangChain与智普大模型接入:行业领先的AI整合】

当LangChain遇到智普大模型:拆解一个AI整合的超级方案 最近半年,我一直在跟几个创业团队合作搞AI落地项目,发现一个特别有意思的现象:现在企业想用大模型干点实事,最大的痛点反而不是模型本身的能力,而是怎么把模型"塞"进现有系统里,还要塞得优雅、塞得高效。…...

【 <一> 炼丹初探:JavaWeb 的起源与基础】之 Servlet 与 JSP 的协作:MVC 模式的雏形

<前文回顾> 点击此处查看 合集 https://blog.csdn.net/foyodesigner/category_12907601.html?fromshareblogcolumn&sharetypeblogcolumn&sharerId12907601&sharereferPC&sharesourceFoyoDesigner&sharefromfrom_link <今日更新> 一、Servl…...

NO1.C++语言基础|四种智能指针|内存分配情况|指针传擦和引用传参|const和static|c和c++的区别

1. 说⼀下你理解的 C 中的四种智能指针 智能指针的作用是管理指针&#xff0c;可以避免内存泄漏的发生。 智能指针就是一个类&#xff0c;当超出了类的作用域时&#xff0c;就会调用析构函数&#xff0c;这时就会自动释放资源。 所以智能指针作用的原理就是在函数结束时自动释…...

【第14节】C++设计模式(行为模式)-Strategy (策略)模式

一、问题的提出 Strategy 模式&#xff1a;算法实现与抽象接口的解耦 Strategy 模式和 Template 模式要解决的问题是相似的&#xff0c;都是为了将业务逻辑&#xff08;算法&#xff09;的具体实现与抽象接口解耦。Strategy 模式通过将算法封装到一个类&#xff08;Context&am…...

《挑战你的控制力!开源小游戏“保持平衡”开发解析:用HTML+JS+CSS实现物理平衡挑战》​

&#x1f4cc; 大家好&#xff0c;我是智界工具库&#xff0c;致力于分享好用实用且智能的软件以及在JAVA语言开发中遇到的问题&#xff0c;如果本篇文章对你有所帮助请帮我点个小赞小收藏吧&#xff0c;谢谢喲&#xff01;&#x1f618;&#x1f618;&#x1f618; 博主声…...

计算机数据库三级刷题总结(博主89分已过,总结的内容分享)

计算机数据库三级刷题总结&#xff08;博主89分已过&#xff0c;总结的内容分享&#xff09; 文章目录 计算机数据库三级刷题总结&#xff08;博主89分已过&#xff0c;总结的内容分享&#xff09;一、 数据库设计阶段二、事务相关三、数据库设计顺序四、数据库三级模式与二层映…...

鸿蒙HarmonyOS-Navagation基本用法

Navagation基本用法 Navigation组件是路由导航的根视图容器&#xff0c;一般作为Page页面的根容器使用&#xff0c;其内部默认包含了标题栏&#xff0c;内容栏和公工具栏&#xff0c;其中内容区默认首页显示导航内容&#xff08;Navigation的子组件&#xff09;或非首页显示&am…...

AD学习-最小系统板,双层

第一章 简单电阻容模型的创建 捕捉栅格在摆放器件时&#xff0c;一般设置成 10mil。移动器件时一般设置成100mil。 比如绘制电容的原理图库&#xff0c;直接就是两根线条竖着成电容&#xff0c; 按Tab键进行颜色变更&#xff0c;按shift键拖动会复制一个出来。 …...

【一.大模型认知与核心原理篇】【3. GPT解密:大模型背后的核心技术】

各位科技爱好者,今天咱们要干一票大的——把GPT这个AI界的当红顶流扒个底朝天。你以为ChatGPT会聊天就是它的全部能耐?Too young!这货肚子里藏的可是价值百亿美金的黑科技。咱们不整那些虚头巴脑的概念,直接上手拆解它的技术内脏,让你看看这个每天被调戏的聊天机器人,到底…...

说一下SpringBoot3新特新和JDK17新特性

JDK1.8&#xff08;Java8&#xff09;新特性 stream流式编程 流处理 Stream API 提供了对集合数据进行操作的一种高效、简洁的方式。它支持顺序和并行的聚合操作 如&#xff1a;过滤&#xff08;filter&#xff09;、排序&#xff08;sort&#xff09;、映射&#xff08;map&…...

二、Visual Studio2022配置OpenGL环境

文章目录 一、OpenGL库的下载二、OpenGL环境配置三、测试代码演示 一、OpenGL库的下载 OpenGL配置的库是GLFWGLAD &#xff0c;GLFW 主要用于创建 OpenGL 窗口和管理输入&#xff1b;GLAD 主要用于加载 OpenGL 函数 GLFW下载地址 下载Windows的32bit版本即可。 下载完成解压如…...

可视化编辑器选择

可视化页面编辑器选择 最近要开发一个低代码项目&#xff0c;一个低代码项目技术难点都在可视化编辑器上面&#xff0c;由于没有充足的时间&#xff0c;也没有可视化页面编辑器开发技术能力&#xff0c;因此找到一个靠谱的可视化编辑器&#xff0c;基本就成功了一半。 需求 …...

tomcat的安装与配置(包含在idea中配置tomcat)

Tomcat 是由 Apache 软件基金会开发的开源 Java Web 应用服务器&#xff0c;主要用于运行 Servlet 和 JSP&#xff08;JavaServer Pages&#xff09;程序。它属于轻量级应用服务器&#xff0c;适用于中小型系统及开发调试场景&#xff0c;尤其在处理动态内容&#xff08;如 Jav…...

【编写UI自动化测试集】Appium+Python+Unittest+HTMLRunner​

简介 获取AppPackage和AppActivity 定位UI控件的工具 脚本结构 PageObject分层管理 HTMLTestRunner生成测试报告 启动appium server服务 以python文件模式执行脚本生成测试报告 下载与安装 下载需要自动化测试的App并安装到手机 获取AppPackage和AppActivity 方法一 有源码的…...

递归入手三维动态规划

一和零 https://leetcode.cn/problems/ones-and-zeroes/description/ 题目解析 由递归入手, 就是一颗选和不选的树, 我们很好通过 dfs 来写出递归 题目解析 public class 一和零 {public static int zeros;public static int ones;public int findMaxForm(String[] strs, …...

Vue中实现大文件的切片并发下载和下载进度展示

Vue中实现大文件的切片下载 切片下载需要后端提供两个接口&#xff0c;第一个接口用来获取当前下载文件的总切片数&#xff0c;第二个接口用来获取具体某一个切片的内容。 界面展示 数据流展示 代码 接口 // 切片下载-获取文件的总切片数 export function getChunkDownload…...

行为模式---命令模式

概念 命令模式是一种行为设计模式&#xff0c;它的核心思想就是将请求封装为一个对象&#xff0c;此对象包含与请求相关的所有信息。可以用不同的请求对客户进行参数化。命令模式通过将请求的发送者和接收者解耦&#xff0c;支持请求的排队、记录、撤销等操作。 使用场景 1、…...

GNU Binutils 全工具指南:从编译到逆向的完整生态

1. GNU Binutils 全工具指南&#xff1a;从编译到逆向的完整生态 1. GNU Binutils 全工具指南&#xff1a;从编译到逆向的完整生态 1.1. 引言1.2. 工具分类速查表1.3. 核心工具详解 1.3.1. 编译与汇编工具 1.3.1.1. as&#xff08;汇编器&#xff09;1.3.1.2. gcc&#xff08;…...

【手撕算法】支持向量机(SVM)从入门到实战:数学推导与核技巧揭秘

摘要 支持向量机&#xff08;SVM&#xff09;是机器学习中的经典算法&#xff01;本文将深入解析最大间隔分类原理&#xff0c;手撕对偶问题推导过程&#xff0c;并实战实现非线性分类与图像识别。文中附《统计学习公式手册》及SVM调参指南&#xff0c;助力你掌握这一核心算法…...

[QT]开发全解析:从概念到实战

文章目录 Qt 框架入门与应用开发指南一、Qt 框架概述1.1 什么是 Qt1.2 Qt 的发展史1.3 Qt 支持的平台1.4 Qt 版本1.5 Qt 的优点1.6 Qt 的应用场景1.7 Qt 的成功案例 二、Qt 的开发工具概述Qt CreatorVisual StudioEclipse 三、认识 Qt Creator3.1 Qt Creator 概览3.2 使用 Qt C…...

进程控制 ─── linux第15课

目录 进程控制 1.进程创建 (fork前面讲过了) 写时拷贝 进程终止 进程退出场景 退出码 进程终止方法 进程控制 1.进程创建 (fork前面讲过了) 在linux中fork函数时非常重要的函数&#xff0c;它从已存在进程中创建一个新进程。新进程为子进程&#xff0c;而原进程为父…...

uniapp:小程序将base64图片字符串保存到手机相册

一、需求分析 用户提供的是Base64格式的图片数据&#xff0c;而小程序保存图片到本地通常需要的是临时文件路径。 第一步可能需要将Base64数据转换成临时文件&#xff0c;然后再保存到手机相册。 第二步查阅UniApp的文档&#xff0c;关于保存图片到相册的相关API&#xff0c;…...

宇树科技再落一子!天羿科技落地深圳,加速机器人创世纪

2025年3月5日&#xff0c;机器人行业龙头宇树科技&#xff08;Unitree&#xff09;在深圳再添新动作——全资子公司深圳天羿科技有限公司正式成立。这家注册资本10万元、法定代表人周昌慧的新公司&#xff0c;聚焦智能机器人研发与销售&#xff0c;标志着宇树科技在华南市场的战…...

个推助力小米米家全场景智能生活体验再升级

当AI如同水电煤一般融入日常&#xff0c;万物互联的图景正从想象照进现实。作为智能家居领域的领跑者&#xff0c;小米米家凭借开放的生态战略&#xff0c;已连接了超8.6亿台设备&#xff0c;构建起全球领先的消费级AIoT平台。如今&#xff0c;小米米家携手个推&#xff0c;通过…...

四款GIS工具箱软件解析:满足企业多样化空间数据需求

概述 随着地理信息系统&#xff08;GIS&#xff09;在城市规划、环境监测、资源管理等领域的广泛应用&#xff0c;各种GIS工具箱软件不断涌现&#xff0c;为用户提供了强大的数据处理、空间分析和地图制图功能。本文将为大家介绍4款GIS工具箱软件&#xff0c;这些软件各具特色…...

Deepseek×ComfyUI革命性工作流:AI图像3倍速精修实战指南

一、为什么选择深度优化的AI精修方案&#xff08;H2&#xff09; 核心关键词&#xff1a;AI图像优化工作流、ComfyUI加速方案、Deepseek节点配置 ▸ 传统Stable Diffusion耗时痛点分析&#xff08;渲染时间/显存占用&#xff09; ▸ 深度神经网络的并行计算优势对比&#xff…...

IntelliJ IDEA 2024.3.4 版本无法正常加载maven项目

报错如下&#xff1a; Possible solutions: - Check that Maven settings.xml does not contain http repositories - Check that Maven pom files do not contain http repository http://192.168.22.76:9003/repository/thirdparty/ - Check that Maven pom files do not co…...

为什么过滤器需要一个 Wrapper 来 extends HttpServletRequestWrapper

为什么过滤器需要一个 Wrapper 来 extends HttpServletRequestWrapper 1. HttpServletRequest 不可直接修改2. 能够修改请求参数和请求体3. 方便扩展与重用4. 处理请求参数和请求体5. 避免对原始请求做修改 典型使用场景举个例子总结 在 Java Web 开发中&#xff0c;过滤器&…...

SpaCy处理NLP的详细工作原理及工作原理框图

spaCy处理NLP的详细工作原理及工作原理框图 spaCy处理NLP的详细工作原理 spaCy是一个基于Python的开源自然语言处理&#xff08;NLP&#xff09;库&#xff0c;它提供了一系列高效且易用的工具&#xff0c;用于执行各种NLP任务&#xff0c;如文本预处理、文本解析、命名实体识…...

数学软件Matlab下载|支持Win+Mac网盘资源分享

如大家所了解的&#xff0c;Matlab与Maple、Mathematica并称为三大数学软件。Matlab应用广泛&#xff0c;常被用于数据分析、无线通信、深度学习、图像处理与计算机视觉、信号处理、量化金融与风险管理、机器人&#xff0c;控制系统等领域。 Matlab将数值分析、矩阵计算、科学…...

OCPP与ISO 15118集成:实现即插即充与车网互动(V2G)- 慧知开源充电桩平台

OCPP与ISO 15118集成&#xff1a;实现即插即充与车网互动&#xff08;V2G&#xff09; 引言 随着电动汽车&#xff08;EV&#xff09;与电网双向能量交互&#xff08;V2G&#xff09;技术的成熟&#xff0c;OCPP协议与ISO 15118标准的协同成为智能充电基础设施的核心挑战。本文…...

【C++设计模式】第七篇:桥接模式(Bridge)

注意&#xff1a;复现代码时&#xff0c;确保 VS2022 使用 C17/20 标准以支持现代特性。 抽象与实现的解耦之道 1. 模式定义与用途​​ 核心思想​ ​桥接模式&#xff1a;将抽象部分与实现部分分离&#xff0c;使二者可以独立变化。​关键用途&#xff1a; ​1.拆分复杂继承…...

【SA8155】AIS Camera流程简述

目录 1 AIS Camera流程之初始化 2 AIS Camera流程之opencamera 3 AIS Camera流程之request处理流程 高通QNX车载系统 + LA虚拟机下Camera框图如下: 高通车载系统中,如上述框图所述,模组驱动在QNX侧,Android侧需要通过HAB Socket通信获取相机的数据。ais_v4l2_proxy将获取…...

容器 /dev/shm 泄漏学习

容器 /dev/shm 泄漏的介绍 在容器环境中&#xff0c;/dev/shm 是一个基于 tmpfs 的共享内存文件系统&#xff0c;通常用于进程间通信&#xff08;IPC&#xff09;和临时数据存储。由于其内存特性&#xff0c;/dev/shm 的大小是有限的&#xff0c;默认情况下 Docker 容器的 /de…...

【Deepseek基础】--3.版本对比

目录 1.Deepseek-v1&#xff1a;起步与编码强劲 2. DeepSeek-V2&#xff1a;性能提升与开源生态 3.DeepSeek-V2.5系列&#xff1a;数学与网络搜索突破 4.DeepSeek-R1-Lite系列&#xff1a;推理模型预览版上线 5. DeepSeek-V3系列&#xff1a;大规模模型与推理速度提升 6…...

一周学会Flask3 Python Web开发-WTForms表单验证

锋哥原创的Flask3 Python Web开发 Flask3视频教程&#xff1a; 2025版 Flask3 Python web开发 视频教程(无废话版) 玩命更新中~_哔哩哔哩_bilibili 我们可以通过WTForms表单类属性的validators属性来实现表单验证。 常用的WTForms验证器 验证器说明DataRequired(messageNo…...

算法题笔记(自用)——Python

目录 一. 进制&位运算&ASCAII 二. format格式化输出 1. 基本用法 2. 位置参数 3. 格式化数字 4. 对齐和填充 5. 格式化二进制、八进制、十六进制 6. 格式化百分比 7. 格式化科学计数法 8. 格式化字符串字面量&#xff08;f-string&#xff09; 三. 字符串 使…...

Fiji图像处理

文章目录 一、Fiji —— 基于 imageJ 的免费且开源的图像处理软件1.1、工具安装&#xff08;免费&#xff09;1.2、源码下载&#xff08;免费&#xff09; 二、功能详解2.0、Fiji - ImageJ&#xff08;Web应用程序&#xff09;2.1、常用功能&#xff08;汇总&#xff09;2.2、F…...

【数据结构】堆和priority_queue

堆的定义 堆是什么&#xff1f;实际上堆是一种特殊的&#xff08;受限制的&#xff09;完全二叉树&#xff0c;它在完全二叉树的基础上要求每一个节点都要大于等于或者小于等于它的子树的所有节点。这个大于小于体现在节点的值或者权重。 如图所示&#xff1a; 根节点大于等于…...

Vue的简单入门 三

目录 侦听器 watch 注意 表单输入绑定 v-model v-model修饰符​编辑 lazy number Trim 模板引用 组件组成 组件引用三步走 组件的嵌套关系 header Main Aside Aritice Item App.vue组件引入三个子组件 组件的注册方式 全局注册组件的方法 (1) Vue 2 语…...

kotlin的val声明的变量是常量吗

kotlin的val声明的变量是常量吗 DeepSeek R1 思考 65 秒 思考过程 嗯&#xff0c;用户问的是Kotlin中的val声明的变量是不是常量。首先我需要回忆一下Kotlin的基础知识。记得在Kotlin里&#xff0c;val用来声明只读变量&#xff0c;也就是说一旦赋值之后就不能再重新赋值了。…...

C# | 委托 | 事件 | 异步

委托&#xff08;Delegate&#xff09;和事件&#xff08;Event&#xff09; 在C#和C中&#xff0c;委托&#xff08;Delegate&#xff09;与事件&#xff08;Event&#xff09;以及函数对象&#xff08;Function Object&#xff09;是实现回调机制或传递行为的重要工具。虽然…...

算法日记33:14届蓝桥C++B冶炼金属(二分答案)

一、题目&#xff1a; 二、题解&#xff1a; 1、思路解析&#xff1a; 1&#xff09;首先我们可以发现题目的样例数量为&#xff08; n < 1000 n<1000 n<1000&#xff09;&#xff0c;因此我们可以考虑 O ( n ∗ l o g n ) O(n*log^n) O(n∗logn)时间复杂度的算法 …...

【YOLO V5】目标检测 WSL2 AutoDL VScode SSH

【YOLO V5】目标检测 WSL2 AutoDL VScode SSH 前言整体思路理解向YOLO 目标检测完整流程 环境配置Anaconda 获取 YOLO 代码与预训练模型下载 YOLOv5 代码和预训练模型配置 YOLOV5 工程环境解压 YOLOv5 源代码 并 添加预训练模型调整依赖版本选择对应的 Python 解释器 数据集准备…...

前端基础之ajax

vue-cli配置代理服务器解决跨域问题 我们可以使用一个代理服务器8080&#xff0c;Vue项目8080发送请求向代理服务器8080发送请求&#xff0c;再由在理服务器转发给后端服务器 首先需要在vue.config.js中配置代理服务器 const { defineConfig } require(vue/cli-service) modul…...