目录
[1.1 信号的概念](#1.1 信号的概念)
[1.2 查看信号列表](#1.2 查看信号列表)
[1.3 信号处理常见方式](#1.3 信号处理常见方式)
[2.1 通过终端按键产生信号](#2.1 通过终端按键产生信号)
[2.2 通过系统调用](#2.2 通过系统调用)
[2.3 由软件条件产生信号](#2.3 由软件条件产生信号)
[2.4 异常](#2.4 异常)
[3.1 信号其他相关常见概念](#3.1 信号其他相关常见概念)
[3.2 信号在内核中的表示](#3.2 信号在内核中的表示)
[3.3 sigset_t](#3.3 sigset_t)
[3.4 信号集操作函数](#3.4 信号集操作函数)
[3.5 sigprocmask](#3.5 sigprocmask)
[3.6 sigpending](#3.6 sigpending)
[4.1 信号的捕捉](#4.1 信号的捕捉)
[4.2 sigaction](#4.2 sigaction)
[5.1 可重入函数](#5.1 可重入函数)
[5.2 SIGCHLD信号](#5.2 SIGCHLD信号)
一、信号的入门
1.1 信号的概念
生活中的信号有电话铃声、红绿灯、闹钟等,在信号还没有产生的时候,我们就已经能够知道该如何处理这些信号。我们不清楚信号具体什么时候到来,所以信号到来相对于我们正在做的工作,是异步产生的。信号产生了,我们也不一定要立即处理它,而是在合适的时候处理。所以就需要将已经到来的信号进行暂时保存。
在Linux操作系统中,信号(Signal)是一种向目标进程发送通知消息的机制,用于通知接收进程某个事件已经发生。信号通常是异步发生的,也就是说,它的发送和接收可能在不同的时刻发生。
例如用户输入命令,在Shell下启动一个前台进程。
用户按下Ctrl-C ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程
前台进程因为收到信号,进而引起进程退出。
注意:
-
Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
-
Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。(jobs查看后台进程,fg num 把编号为num的后台进程放到前台)
-
前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步
(Asynchronous)的。
- 前台进程不能被暂停(ctr1+z)如果被暂停,该前台进程,必须立即被放到后台。OS会自动的把shell提到前台或者后台。
1.2 查看信号列表
Linux系统中定义了许多信号,每个信号都有其特定的用途和含义。用kill -l命令可以察看系统定义的信号列表。
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义 #define SIGINT 2
编号34以上的是实时信号,本文只讨论编号34以下的信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal
1.3 信号处理常见方式
当内核或者一个进程A想要通知另一个进程B某个事件发生时,它会发送一个信号给进程B。进程B接收到信号后,根据信号的类型和当前状态,可能会采取以下几种行动之一:
-
忽略信号 :进程可以选择忽略某些信号。
-
执行该信号的默认处理动作 :每个信号都有一个默认的动作,例如,终止进程、忽略信号、停止进程等。
-
捕捉信号(自定义):进程可以设置一个信号处理函数来处理信号,这样当信号发生时,要求内核在处理该信号时切换到用户态执行这个处理函数。
信号机制在进程控制、错误处理和特殊情况处理等方面都有广泛的应用。例如,如果需要强制终止一个进程,可以使用SIGKILL信号。(kill -9 进程号)
以下是一些常见的Linux信号:
SIGINT:当用户按下中断键(通常是Ctrl+C)时,发送给前台进程组的信号。
SIGKILL:用来立即结束一个进程,不能被捕获或忽略。
SIGTERM:请求终止进程,可以被捕获或忽略,以便进程可以做一些清理工作。
SIGSTOP:停止一个进程的执行,不能被捕获或忽略。
SIGCONT:继续执行一个之前被停止的进程。
二、信号的产生
2.1 通过终端按键产生信号
当用户在终端中按下特定的键组合时,终端驱动程序会将这些按键解释为信号并发送给前台进程组。例如,当用户按下Ctrl+C时,终端驱动程序会生成一个2号信号SIGINT发送给前台进程。
对于普通信号来讲,进程通过位图来表示自己是否收到和收到哪种信号。
位图:0000 00010
比特位的位置,表示信号编号。
比特位的内容,表示是否收到信号。
注意:
-
OS向目标进程发信号其实就是写信号,更新进程task_struct信号位图字段。
-
每一个进程都有一张自己的函数指针数组,数组的下标就和信号编号强相关。
-
无论信号有多少种产生方式,永远只能是OS向目标进程发送。
-
每个进程对于信号要有函数指针数组和信号位图。
CPU与外设在数据层面不打交道,但是在信息控制方面,CPU要与外设打交道。
可以简单理解为:CPU有部分针脚用来和硬件对应,一个硬件一个针脚,每个针脚对应一个中断号,每一个硬件对应针脚的中断号基本是唯一的针脚。在内核中有一个中断描述符表(IDT)(函数指针数组,函数是特定中断处理方法),中断号就是所使用函数对应的下标,硬件启动或触发时会向针脚发送信号,通过中断向量表和中断号就可以使用对应的函数进行操作。
键盘组合键被按下,完整过程:按下组合键后,先向CPU特定的针脚发送硬件中断,OS使用终端编号执行中断处理方法,把数据从外设读到内存里,OS对数据识别,根据组合键和函数的映射关系,把组合键解释成特定的信号,向目标进程/前台进程的PCB写入指定的信号,即完成OS的工作,后续进程在合适时机根据位图处理对应信号。
Core Dump:
Core Dump(核心转储)是操作系统在进程异常终止时,将进程的用户空间内存内容(包括栈、堆、数据段等)转储到磁盘上的一个文件中,通常这个文件的名称为core。这个过程对于调试非常有用,因为它允许开发人员在事后分析进程的内存状态,以确定导致进程异常终止的原因。
当进程由于某种错误(如非法内存访问导致段错误、非法指令、浮点异常等)而异常终止时,操作系统会检查该进程的资源配置限制(Resource Limit),以决定是否应该生成核心转储文件。这些资源配置限制通常存储在进程控制块(PCB)中,其中包括了核心转储文件的最大大小。事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。
在Linux系统中,默认情况下,核心转储文件的大小限制可能是0,这意味着默认不允许生成核心转储文件。这是出于对安全的考虑,因为核心转储文件可能包含敏感信息。然而,在软件开发和调试阶段,允许生成核心转储文件是非常有帮助的。
ulimit命令是用于设置或显示用户可以使用的资源限制的。要允许生成最大为1024K的核心转储文件,可以使用命令:ulimit -c 1024
这个命令会改变当前Shell进程及其派生的子进程的资源配置限制,使得它们在异常终止时可以生成最大为1024K的核心转储文件。需要注意的是,这个改变只对当前Shell会话有效,并且在某些系统中,可能需要重新登录或者重启应用程序才能生效。
一旦设置了核心转储文件的大小限制,当进程异常终止时,核心转储文件就会被生成,并且可以使用调试器(如gdb)进行分析,以帮助开发者定位和修复问题。
2.2 通过系统调用
Linux提供了一系列的系统调用来允许进程发送信号给其他进程。
kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。raise函数可以给当前进程发送指定的信号(自己给自己发信号)。(这两个函数都是成功返回0,错误返回-1。)
abort函数使当前进程接收到信号而异常终止。就像exit函数一样,abort函数总是会成功的,所以没有返回值。
当进程调用这些函数时,内核会根据函数参数指定的目标进程和信号类型,将信号加入到目标进程的信号队列中。例如,kill()系统调用会检查发送者和接收者之间的权限,然后内核会通过查找进程表找到目标进程,并将信号传递给它。
2.3 由软件条件产生信号
闹钟就是一种软件条件。
alarm函数 和SIGALRM信号。SIGPIPE是一种由软件条件产生的信号。
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
操作系统中的时间
- 所有用户的行为,都是以进程的形式在OS中表现的。操作系统通过管理这些进程来执行用户任务。
- 操作系统只要把进程调度好,就能完成所有的用户任务。操作系统负责决定哪个进程将获得CPU时间,以及它们将获得多长时间。这个过程称为CPU调度。调度算法的目标是高效、公平地分配CPU资源,确保所有进程都能得到适当的执行时间,从而完成用户任务。调度算法有很多种,比如FIFO(先进先出)、Round Robin(轮转)、优先级调度等。
- CMOS时钟是计算机主板上的一个实时时钟,它用于保持时间和日期,即使计算机关闭也不例外。CMOS时钟通过周期性的,高频率的,向CPU发送时钟中断与操作系统交互。
朴素的对OS进行理解:操作系统本质,就是一个死循环,先进行设置中断向量、初始化硬件、加载驱动程序、创建初始进程等初始工作,然后就进入循环,等待并处理事件。通过CMOS不断地发送时钟中断和其他类型的中断和信号,来让操作系统不断运行。操作系统的执行,是基于硬件中断的。
软件条件产生的信号通常是由于某些特定的事件或条件发生,例如,子进程终止时,父进程会收到SIGCHLD信号。此外,当进程执行某些系统调用(如pause()或wait())时,它们可能会因为信号而提前返回。这种机制是基于内核的同步原语和进程的状态管理。
2.4 异常
当进程执行时发生硬件错误或异常,如除以零、访问非法内存地址等,CPU的异常处理机制会触发,内核会为当前进程生成相应的信号。例如,除以零错误会产生SIGFPE信号,非法内存访问会产生SIGSEGV信号。内核的异常处理程序会将这些硬件异常转换为信号,并将其发送给出错的进程。把进程杀掉,默认就是处理问题的方式之一。
除以零错误会产生SIGFPE信号的过程:
在x86架构中,除以零的错误通常是由浮点单元(FPU)检测到的。FPU有自己的状态寄存器,如状态寄存器(FPSR)和控制寄存器(FPCR),用于记录浮点运算的状态和配置。当发生除以零的操作时,FPU会设置FPSR中的一个特定位,表示发生了浮点异常。CPU在执行后续指令时会检查FPSR,如果发现有异常,CPU就会触发一个异常处理流程。
内核的异常处理程序会将这个硬件异常转换为相应的信号。在Linux系统中,除以零的异常通常会转换为SIGFPE信号。内核然后会将这个信号发送给导致异常的进程。进程接收到信号后,可以根据信号的默认行为来处理,比如终止执行(OS将其解释为kill(targetprocess, signo);),或者进程可以注册自己的信号处理函数来处理这个信号。
进程异常终止时报core错误,一方面表示比较严重,另一方面表示需要用户关心,还需要有下一步,因为报错原因可能尚不清楚,需要用户排查。
三、信号的保存
3.1 信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
注:
- 信号的忽略、信号的默认处理、信号的自定义捕捉都是信号递达的方式。
- 在信号位图中,就叫做信号未决。在未决状态下,信号被保持在位图中,等待被递达给目标进程。
3.2 信号在内核中的表示
三张表:block表、pending表、handler表
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。
- 信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
- 在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不
- 能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
- 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
3.3 sigset_t
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储。sigset_t是一个数据类型,称为信号集,用于表示一组信号 **。**在sigset_t中,每一位对应一个信号,用于表示该信号是否被阻塞或在未决状态。
- 未决信号集:这个集合包含了所有当前未决的信号,即那些已经被产生但尚未被处理或递送给进程的信号。在未决信号集中,如果一个信号的位被设置了,那么这个信号处于未决状态。
- 阻塞信号集(信号屏蔽字):这个集合包含了所有当前被阻塞的信号,即那些即使被产生也不会被递送给进程的信号。在阻塞信号集中,如果一个信号的位被设置了,那么这个信号就是被阻塞的。
- 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的"屏蔽"应该理解为阻塞而不是忽略。
3.4 信号集操作函数
sigset_t类型对于每种信号用一个bit表示"有效"或"无效"状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
- sigemptyset:初始化set所指向的信号集,把其中所有信号的对应bit清零,使其不包含任何有效信号。
- sigfillset:初始化set所指向的信号集,使其中所有信号的对应bit置位,使其包含所有支持的信号。
- sigaddset:用于向一个已经初始化的sigset_t类型的信号集中添加一个特定的信号。即将指定信号的对应位设置为1,表示该信号在信号集中是有效的。
- sigdelset:用于从一个已经初始化的sigset_t类型的信号集中删除一个特定的信号。即将指定信号的对应位清零,表示该信号在信号集中是无效的。
- sigismember:用于检查一个特定的信号是否在sigset_t类型的信号集中。如果信号在信号集中,返回值为1;如果信号不在信号集中,返回值为0。
注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
3.5 sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
how:这个参数指示如何更改信号屏蔽字。它可以是以下三个值之一:
- SIG_BLOCK:将set中的信号添加到当前信号屏蔽字中,即阻塞set中的信号。
- SIG_UNBLOCK:从当前信号屏蔽字中移除set中的信号,即解除对set中信号的阻塞。
- SIG_SETMASK:将当前信号屏蔽字设置为set中的信号,即替换当前信号屏蔽字为set。
**set:**如果这个参数是非空指针,它指向一个sigset_t类型的信号集,该信号集包含了要添加到信号屏蔽字或要从信号屏蔽字中移除的信号。
oset:如果这个参数是非空指针,它指向一个sigset_t类型的信号集,用于保存调用sigprocmask之前的信号屏蔽字。
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号 屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
3.6 sigpending
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
下面用刚学的几个函数做个实验。
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
void PrintPending(const sigset_t &pending)
{
for(int signo = 31; signo > 0; signo-- )
{
if(sigismember(&pending, signo)) //判断指定信号是否在目标集合中
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
int main()
{
std::cout << "pid : " << getpid() << std::endl;
//定义信号集对象,并清空初始化
sigset_t mask, omask;
sigemptyset(&mask);
sigemptyset(&omask);
//屏蔽2号信号
sigaddset(&mask,2);
sigprocmask(SIG_BLOCK,&mask, &omask);
//让进程不断获取当前进程的pending
int cnt = 0;
sigset_t pending;
while(true)
{
sigpending(&pending);
PrintPending(pending);
sleep(1);
cnt++;
if(cnt == 5)
{
std::cout << "解除对2号信号的屏蔽, 2号信号准备递达" << std::endl;
sigprocmask(SIG_SETMASK, &omask, nullptr);
}
}
return 0;
}
键入ctrl+c(SIGINT)该信号被test阻塞,所以一直处于未决状态,不被处理,直至5秒后解除对2号信号的屏蔽,才对信号递达。
四、信号的捕捉处理
4.1 信号的捕捉
信号在合适的时候被处理,什么时候?进程从内核态返回到用户态的时候,进行信号的检测和信号的处理。
OS代码(系统调用代码)、数据、数据结构就如同曾经的库函数调用一样,调用系统调用接口,也是在进程的地址空间中进行的。我们的进程的所有代码的执行,都可以在自己的地址空间内通过跳转的方式,进行调用和返回!!
用户态是一种受控的状态,能够访问的资源是有限的。地址空间只能访问[0,3]GB。
内核态是一种操作系统的工作状态,能够访问大部分系统资源。可以让用户以OS的身份访问地址空间的[3,4]GB。
系统调用背后,就包含了身份的变化。
该图片描述了操作系统中用户模式和内核模式之间的交互过程,特别是在处理信号方式为自定义(捕捉信号)时。
- 用户模式到内核模式的转换:当在用户控制流程中执行某些指令时,因为中断、异常或系统调用,进程会进入内核。
- 内核中的信号处理:内核在处理完异常准备返回用户模式之前,会先处理当前进程中可以递送的信号。
- 信号的处理动作:内核会执行特定的信号处理函数(如sighandler),并根据信号的类型进行相应的操作。如果是自定义的信号处理函数,它会直接被执行;如果不是,则会返回到主控制流中上次被中断的地方继续执行。
- 信号处理函数的返回:信号处理函数执行完毕后,会通过sys_sigreturn函数返回用户模式。
- 用户模式的恢复:从信号处理函数返回后,进程会返回到用户模式,并在上次被中断的地方继续执行。
信号捕捉中,一共会涉及到4次状态切换。不使用系统调用也会涉及内核态转用户态。进程切换时是由内核态转换为用户态,需要进行信号检测。进程执行有时间片,时间片完就要切换进程,切换进程发生身份变化,就要进行信号检测。
在Linux中,内核如何实现信号的捕捉?
- 信号生成:当一个信号被产生时,通常是由于硬件中断、软件异常、定时器到期或其他系统事件。
- 信号排队:内核将信号添加到接收信号的进程的信号队列中。如果信号被阻塞,它将保持在队列中,直到进程解除对该信号的阻塞。
- 信号递达:当一个信号队列中有信号时,内核会检查是否有信号可以递达给进程。如果有,内核会根据信号处理动作的类型来决定如何处理。
- 用户自定义信号处理函数:如果信号的处理动作是用户自定义的函数,内核会在递达信号时调用这个函数。这个过程称为信号捕捉。
- 信号捕捉流程:
• 内核检查是否有信号可以递达给进程。
• 如果有,内核决定递达信号,而不是恢复进程的上下文继续执行。
• 内核切换到用户态,并调用用户自定义的信号处理函数。
• 信号处理函数使用自己的堆栈空间,与调用它的函数(在这种情况下是内核)没有直接的调用关系。
• 信号处理函数执行完毕后,内核执行特殊的系统调用sys_sigreturn。
• sys_sigreturn系统调用用于返回内核态,内核将信号处理函数的返回值和上下文传递给进程,以便进程可以继续执行。
• 如果没有新的信号要递达,内核再次返回用户态,恢复进程的上下文继续执行。- 信号递达后的执行:信号递达后,进程可以继续执行,或者如果信号处理函数设置了SA_RESTART标志,进程可能会从信号递达的位置重新开始执行。
4.2 sigaction
sigaction函数在Linux系统中用于处理信号,它允许用户进程读取、修改或设置与指定信号相关的处理动作。调用成功则返回0,出错则返回-1。
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
- signo:这是要操作的信号的编号。
- act:如果这个参数非空,它指向一个struct sigaction类型的结构体,该结构体包含了新的信号处理动作。如果将其设置为SIG_IGN,则忽略该信号;如果设置为SIG_DFL,则使用信号的默认处理动作。如果设置为一个函数指针,则根据act修改该信号的处理动作。
- oact:如果这个参数非空,则通过oact传出该信号原来的处理动作。
struct sigaction的结构体定义如下:
struct sigaction {
void (*sa_handler)(int); // 信号处理函数
void (*sa_sigaction)(int, siginfo_t *, void *); // 实时信号处理函数
sigset_t sa_mask; // 额外需要屏蔽的信号集合
int sa_flags; // 标志位,如SA_RESTART, SA_NODEFER等
};
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用
**当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。**如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,sa_sigaction是实时信号的处理函数,在此不详细解释。
五、其它概念的补充
5.1 可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。node2节点可能会造成内存泄漏。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
我们平时C++写的大部分函数都是不可重入的,因为有可能会用到STL容器,容器的操作就会涉及到资源的申请。
5.2 volatile
在Linux中,volatile关键字用于声明变量要保持内存可见性,即告诉编译器不要对这个变量的访问进行优化,每次访问变量时都要直接从内存中读取它的值,而不是使用缓存中的值。
原因:在使用VS编写代码时,有一个Debug版和一个Release版,Debug版可以用来调试,Release版生成的可执行程序是优化后的。比如一个没有修改过的变量被频繁访问,编译器可能会将该变量直接保存到寄存器里,这样效率就会提升,但是如果我们想向进程发信号来修改这个变量也就做不到了,因为修改的变量是在内存中,CPU访问这个变量不再访问内存,而是直接使用寄存器的值。所以我们要是想要通过信号来修改值,就要在变量前加上volatile关键字,告诉编译器不要对这个变量的访问进行优化,来保证每次访问该变量都是从内存中读取。
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
int flag = 0;
//volatile int flag = 0;
void handler(int signo)
{
std::cout << "signo: " << signo << std::endl;
flag = 1;
std::cout << "change flag to: " << flag << std::endl;
}
int main()
{
signal(2, handler);
std::cout << "getpid: " << getpid() << std::endl;
while(!flag);
std::cout << "quit normal!" << std::endl;
return 0;
}
注意:在Linux中,GCC编译器默认情况下不会进行积极的优化,但是可以通过指定不同的优化选项来让编译器对代码进行优化。
GCC提供了多个优化级别,从 O0(无优化)到 O3(最大优化),以及一些特殊的优化选项。下面是几个常用的优化级别:
- O0:无优化(默认值)。编译器会尽量保证编译速度,而不对生成的代码进行优化。
- O1:一级优化。编译器会尝试优化代码,同时保持合理的编译时间和生成代码的大小。
- O2:二级优化。编译器会进行更多的优化,包括一些可能会增加代码大小的优化。这个选项在编译大型程序时比较常用,因为它可以显著提高程序的运行速度,同时编译时间也不会过长。
- O3:三级优化。这是最高级别的优化,编译器会尝试各种可能的优化。这可能会增加编译时间,并且生成的代码可能更难调试。
此时再按ctrl+c就不会退出了。
推荐所有使用flag的操作,都要从内存中拿数据。
5.2 SIGCHLD信号
子进程在退出的时候,是要给父进程发送信号的(SIGCHLD)。
Linux中用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。
采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
cpp
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
void handler(int signo)
{
std::cout << "get a signal: " << signo << std::endl;
pid_t id;
while((id = waitpid(-1,NULL, WNOHANG)) > 0)
{
std::cout << "wait child success : " << id << std::endl;
}
}
int main()
{
signal(SIGCHLD,handler);
for(int i = 0; i < 10; i++)
{
pid_t id = fork();
if(id == 0)
{
std::cout << "child is running" << std::endl;
sleep(5);
exit(EXIT_SUCCESS);
}
}
int cnt = 10;
while(cnt--)
{
std::cout << cnt << std::endl;
sleep(1);
}
return 0;
}
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
signal(SIGCHLD, SIG_IGN);
// Linux支持手动忽略SIGCHLD,忽略之后,所有的子进程都不要父进程进行等待了,退出自动回收资源。
等待子进程不仅仅是为了解决僵尸,还有一个功能是获取子进程的退出信息,如果是为了解决僵尸问题,那就必须要等待子进程。