文章目录
- 一、信号入门
- 1. 生活角度的信号
- 2. 技术应用角度的信号
- 3. Linux下常见的信号
- 二、信号产生
- 1. 终端按键产生信号
- 2. 核心转储
- 3. 通过系统调用向进程发信号
- 4. 软件条件产生信号
- 5. 硬件异常产生信号
- 三、信号保存
- 1. 信号相关概念及内核中的信号表示
- 2. 信号集操作函数
- 3. sigpending 和 sigprocmask
- 四、信号捕捉
- 1. 内核地址空间的引入
- 2. 信号的捕捉
- 五、其它
- 1. 可重入函数
- 2. volatile
- 3. SIGCHLD信号
一、信号入门
1. 生活角度的信号
生活中,我们会受到很多信号
。比如说:红绿灯、闹钟、下课铃、倒计时、鸡叫、狼烟、冲锋号、肚子叫、你妈妈/女朋友/男朋友的脸色…
当我们收到这些信号时,我们会根据这些信号做出该做的行为和动作。我们能够认识并处理一个信号,是因为曾今有人或者事情 “培养” 过我们,并记住了对应场景下的信号。就算这些信号没有产生,我们也知道该怎么处理它。
2. 技术应用角度的信号
信号
是进程间通信机制中唯一的异步通信机制,属于软中断
,用户或者操作系统通过发送一定的信号,通知进程发生了某些事件,进程可以在后续合适的时间进行 信号处理
。
-
因为信号可能随时产生,所以在产生信号前,我们可能正在做优先级更高的事情而不能立马处理这个信号,所以我们需要在后续合适的时候对它进行处理。同理,在Linux下,当信号产生时,进程可能正在处理某些任务。所以,信号可能不能立即被进程处理。
-
为什么进程能够识别信号呢?这时因为设计操作系统的程序员将常见的信号及信号处理动作内置到了进程的代码和属性中。让进程对信号具有了识别能力。
-
信号的产生相对于进程来说是异步的。
异步
是指两个或者两个以上的对象或事件不同时存在或发生(或多个相关事务的发生无需等待其前一事物的完成)。同步
是指两个或者两个以上随时间变化的量在变化过程中保持一定的相对关系。 -
进程应该如何记录对应产生的信号呢?先描述、再组织。用
0/1
来描述一个信号产生与否,使用一种数据结构——位图
来管理这个信号。
-
所谓的发送信号,本质其实是写入信号,直接修改特定进程的信号位图中的特定比特位。
0->1
-
task_struct 内核数据结构,只能由OS进行修改——无论后面有多少种信号产生的方式,最终都必须让OS来完成最后的发送过程!信号产生之后,不是立即处理的,是在合适的时候。
-
处理信号的方式有三种:
默认动作、忽略信号、用户自定义动作
。
3. Linux下常见的信号
kill - l
——查看常见的信号
在Linux内核下有62种不同的信号,编号1~31
的信号称为 普通信号
,编号34~64
的信号称为 实时信号
。
普通信号和实时信号的关系就像分时操作系统和实时操作系统的关系类似,分时操作系统是基于时间片轮转调度的,而实时操作系统要求要有严格的时序,可以认为是一个队列。将一个任务放入该队列中,那么操作系统就尽量快地将该任务处理完。日常生活中使用最多的就是分时操作系统,而实时操作系统常见于特殊的行业,如军工领域和自动驾驶领域等等。
man 7 signal
——查看信号的相关描述
二、信号产生
1. 终端按键产生信号
我们之前已经知道,当我们想结束一个程序时,可以通过键盘按下组合键 Ctrl + C
来结束一个进程,
#include <iostream>
#include <unistd.h>using namespace std;
int main()
{while(true){cout << "我是一个进程,我正在运行...,pid值:" << getpid() << endl;sleep(1);}return 0;
}
Ctrl + C
的本质是我们通过键盘按键向进程发送了2
号信号,进程接收到2号信号后的默认处理动作是终止进程。
其实键盘的工作方式是通过中断方式进行的。键盘是槽位的,每个槽位都会对应一个编号。因为有键盘驱动,操作系统是能够识别这些编号的。只要按下了一些键,操作系统立马就能够识别到。当按下组合键时,操作系统也是可以识别到的。操作系统既然都识别到了你按下了组合键,那么操作系统给特定的进程发送信号,也就是轻而易举的事情了。
当我们按下组合键Ctrl + C
时,操作系统便能够识别并解释该组合键,查找到正在运行的前台进程,然后将Ctrl + C对应的信号写入到进程内部的位图结构中,这样操作系统就完成了信号的发送。现在就等进程在合适的时候去处理该信号了。
注意:
- Ctrl + C 产生的信号只能发给前台进程。一个命令后面加个
&
可以放到后台运行,这样 shell 不必等待进程结束就可以接受新的命令,启动新的进程。- shell 可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能收到像 Ctrl + C 这种组合键产生的信号。
- 前台进程在运行过程中用户随时可能按下 Ctrl + C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous) 的。
signal函数
——自定义捕捉信号
signum
指的是进程号,handler
指的是函数指针类型(该函数的返回值是void,参数是int)。我们调用signal函数后,当进程接收到signum信号时,进程就会调用handler函数并将signum传给handler函数,signal函数的返回值是对于signum信号的旧的处理方法。
void handler(int signo)
{cout << "get a signal:" << signo << endl;
}int main()
{signal(2, handler);while(true){cout << "我是一个进程,我正在运行...,pid值:" << getpid() << endl;sleep(1);}return 0;
}
这里我们需要注意的是:signal函数仅仅是修改了进程对于特定信号的后续处理动作,而不是直接调用对应的处理动作。当进程收到对应信号时,才会去调用对应的处理动作。
SIGINT(2号信号):
程序终止(或中断,interrupt)信号,通常是Ctrl+c或Delete键(INTR字符)时发出SIGQUIT(3号信号):
与SIGINT类似,但由Ctrl+(QUIT字符)控制,进程收到该信号时会产生core文件,类似于一个程序错误信号。SIGFPE(8号信号):
除0异常信号。
2. 核心转储
当进程发生错误或收到 “信号”(signal)
而终止执行时,系统会将核心映像写入一个文件,以作为调试之用,这就是所谓的 核心转储(core dump)
。当在一个程序崩溃时,系统会在指定目录下生成一个core文件,我们就可以通过 core文件来对造成程序崩贵的原因进行调试定位。
进程异常终止通常是因为有 Bug,比如非法内存访问导致段错误,事后可以用调试器检查 core 文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)。一个进程允许产生多大的 core 文件取决于进程的 Resource Limit(这个信息保存在 PCB 中)。默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。 首先用 ulimit 命令改变 shell 进程的 Resource Limit,允许 core 文件最大为1024K:$ ulimit -c 1024。
一般来说,云服务器(生产环境)的核心转储功能是关闭的。程序员写代码的环境称为开发环境,测试人员的环境是测试环境(测试Realease版本),产品上线后用户可以使用的环境称为生产环境(有对应的服务器)。我们所购买的云服务器是集成开发、测试、发布、部署于一体的机器。
验证产生core
的功能:
开启核心转储功能——ulimit -c 10240
- 编译环境设置为Debug模式
- 修改代码
#include <iostream>
#include <cassert>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
int main()
{// signal(SIGFPE, handler); // 8号信号int id = fork();if(id == 0){sleep(2);int a = 10;a /= 0;}int status = 0;int ret = waitpid(id, &status, 0);assert(ret != -1);(void)ret;cout << "父进程: " << getpid() << " 子进程: " << id << " exit signal: "\<< (status & 0x7F) << " is core: " << ((status >> 7) & 1) << endl;return 0;
}
输入 gdb
可执行程序 进入gdb调试器再次输入 core-file +core
文件
gdb直接定位到当前进程终止是因为8号信号,信号的更详细描述为 Arithmetic exception
注意:当进程不是通过收到核心转储信号终止进程的,也不会产生core文件。
3. 通过系统调用向进程发信号
kill
系统调用kill函数可以向指定进程发送指定信号
- 第一个参数为目标进程
- 第二个参数为信号
- 向目标进程(pid)发送对应的信号(sig)
- 成功返回0,失败返回-1
// mykill.cc
#include <iostream>
#include <string>
#include <cstdio>
#include <cstring>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
using namespace std;void Usage(string proc)
{cout << "Usage: \n\t";cout << proc << " 信号编号 目标进程\n" << endl;
}int main(int argc, char* argv[])
{//main函数的两个参数,char* argv[] 为指针数组 ,argv为指针数组,// 指向字符串,int argc,argc为数组的元素个数if(argc != 3){Usage(argv[0]);exit(1);}// 进程编号int signo = atoi(argv[1]);// 目标进程int target_id = atoi(argv[2]);int n = kill(target_id, signo);if(n != 0){cout << errno << ":" << strerror(errno) << endl;exit(2);}return 0;
}// loop.cc
#include <iostream>
#include <cstdlib>
#include <unistd.h>using namespace std;int main()
{while(true){cout << "我是一个进程, 我正在运行..., pid值:" << getpid() << endl;sleep(1);}return 0;
}
raise
raise
函数可以给调用该函数的进程发送信号,raise(sig) 相当于kill(getpid(), sig)。
int main()
{while(true){cout << "我正在运行中..." << endl;sleep(1);raise(2);}return 0;
}
abort
abort
函数会给调用该函数的进程发送6号(SIGABRT)
信号,通常用来终止进程。因为abort函数总是会调用成功的,所以没有返回值。
void handler(int signo)
{cout << "get a signal:" << signo << endl;
}int main()
{signal(1, handler);cout << "begin..." << endl;sleep(1);abort();cout << "end..." << endl;return 0;
}
系统调用向进程发信号的本质就是:用户调用系统接口,执行操作系统对应的系统调用代码, 操作系统向目标进程写信号,进程后续处理信号执行相应的处理动作。
4. 软件条件产生信号
alarm
函数
函数可以设定一个未来时间,如:alarm(5) alarm函数调用完了,5秒后给当前进程发送SIGALRM(14)信号,该信号的默认动作是终止当前进程
int main()
{alarm(1);int count = 0;while(true) count++;{cout << count++ << endl;} return 0;
}
这个程序中,我们计算1秒钟计算机能将一个整数累加到多少?
这次计算只计算到了6万多,很明显,因为打印的原因,需要经过多次的对IO设备的访问,严重拖慢了计算速度。下面我们改一下代码:
int count = 0;
void handler(int signo)
{cout << "get a signal:" << signo << "\tcount:" << count <<endl;
}int main()
{signal(SIGALRM, handler);alarm(1);while(true) count++;return 0;
}
这里我们明显看到了速度的提升,这也间接说明了IO效率是非常低下的。
那么我们如何理解软件条件给进程发送信号呢?OS先识别到某种软件条件触发或者不满足,然后给OS构建信号发送给指定的进程。在本次例子中,由于闹钟也是结构体,当闹钟超时了,OS会给闹钟结构体中存储的进程ID发送SIGALRM
信号。
5. 硬件异常产生信号
除0错误
int main()
{int a = 10;a /= 0;cout << "div zero" << endl;return 0;
}
当我们运行该程序时,程序会报错。那出席这种报错的原因是什么呢?
CPU内部有一个状态寄存器,该寄存器用来保存CPU本次计算的状态,其结构也是位图,有着对应的状态标记位(溢出标记位)。假设本来有32/64位,除0时,导致有更高的进位,计算机识别有溢出了,若溢出,状态寄存器的溢出标记位就会置1。操作系统就会意识到有除0错误,触发硬件异常
,操作系统就会找到出错的进程,向该进程发送SIGFPE信号
。
修改一下程序:
void handler(int signo)
{cout << "进程确实收到了: " << signo << " 导致奔溃" << endl;
}
int main()
{signal(SIGFPE, handler);int a = 10;a /= 0;cout << "div zero" << endl;return 0;
}
这时我们发现程序陷入了死循环,这是因为出现硬件异常时,进程不一定会退出。但是由于我们捕捉到了SIGFPE
信号并处理了该信号,但是寄存器中的异常一直未被解决!寄存器中的数据也是进程的上下文,当进程进行进程切换时,寄存器中的数据也被保存下来了。当该进程再次被调度时,操作系统又立马意识到该进程出现了异常。所以一直给进程发送SIGFPE信号,就出现了死循环打印的现象。
三、信号保存
1. 信号相关概念及内核中的信号表示
信号递达:
实际执行信号的处理动作。信号处理动作有默认、忽略、自定义捕捉。信号未决:
信号从产生到递达之间的状态。也就是进程收到一个信号,该信号还未被处理,信号被保存在Pending位图中。阻塞:
进程可以选择阻塞(Block)某个信号,被阻塞的信号保持在未决状态,直到进程解除对此信号的阻塞,才能执行递达动作。
内核中的信号表示
pending:
pending表保存信号的位图结构,1 表示收到了信号,0 表示没有收到信号。handler:
handler表是一个函数指针数组,数组的下标是信号编号,数组中存的是信号的处理动作。block:
1 表示信号被阻塞,0 表示该信号未被阻塞。
信号的处理过程:
OS给目标进程发信号就是修改pending位图,修改位图后表示OS完成了对进程发送信号。然后遍历pending位图看哪些比特位为1,如果比特位为1,再去看block位图上是否为1,如果是1说明该信号被阻塞了,进程不会去处理这个信号。如果未被阻塞,进程可以处理该信号,处理完成后将pending位图上的比特位右1改为0,表示该信号已接处理完成。
如果在进程解除对某信号的阻塞之前这种信号产生多次,那么Linux处理过程如下:
- 普通信号在递达之前产生多次只计一次。
- 实时信号在递达之前产生多次可以依次放在一个队列里。
sigset_t:
sigset_t是操作系统自定义的类型,该类型是位图结构,用于表示上图中的pending表和block表,用户不能直接通过位操作来修改位图,需要使用操作系统提供的方法来修改位图。
2. 信号集操作函数
int sigemptyset(sigset_t *set);
函数 sigemptyset 初始化 set 所指向的信号集,使其中所有信号的对应比特位清零,表示该信号集不包含任何有效信号。
int sigfillset(sigset_t *set);
函数 sigfillset 初始化 set 所指向的信号集,使其中所有信号的对应比特位置 1,表示该信号集的有效信号包括系统支持的所有信号。
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
sigaddset 函数将 signo 信号对应的比特位置为 1,sigdelset 函数将 signo 信号对应的比特位置为 0。
int sigismember(const sigset_t *set, int signo);
sigismember 函数可以判断 signo 信号是否在信号集中,如果 signo 信号在信号集中,返回 1;如果不在,返回 0;出错则返回 -1。
注意:在使用 sigset_ t 类型的变量之前,一定要调用sigemptyset 或 sigfillset 函数做初始化,使信号集处于确定的状态。初始化 sigset_t 变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号。
3. sigpending 和 sigprocmask
sigpending
sigpending函数通过输出型参数set获取当前进程的未决信号集,调用成功放回0,调用失败返回-1。
sigprocmask
sigprocmask函数可以帮助我们读取或者更改进程中的信号屏蔽字(阻塞信号集),调用成功返回0,调用失败返回-1。
如果 oldset 是非空指针,则读取进程的当前信号屏蔽字通过 oldset 参数传出。如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何更改。如果 oldset 和 set 都是非空指针,则先将原来的信号屏蔽字备份到 oldset 里,然后根据 set 和 how 参数更改信号屏蔽字。假设当前的信号屏蔽字为 mask,下表说明了 how 参数的可选值。
#include <iostream>
#include <cassert>
#include <signal.h>
#include <unistd.h>
#include <sys/wait.h>
using namespace std;
void showBlock(sigset_t* oset)
{int signo = 1;for(; signo <=31; signo++){if(sigismember(oset, signo)) cout << "1";else cout << "0";}cout << endl;
}int main()
{// 定义并初始化信号集sigset_t set, oset;sigemptyset(&set);sigemptyset(&oset);sigaddset(&set, 2); // 向集合中添加2号信号// 将2号信号屏蔽sigprocmask(SIG_SETMASK, &set, &oset);int cnt = 0;while(true){// 打印老的信号集showBlock(&oset);sleep(1);cnt++;if(cnt == 10){cout << "recover block" << endl;// 打印新的信号集——发现二号信号的比特位是1showBlock(&set);// 解除对2号信号的block, 2号信号会递达, 所以2号信号会处理它的默认动作——终止进程。sigprocmask(SIG_SETMASK, &oset, &set);}}return 0;
}
使用2号信号想要干掉进程时,由于2号信号被阻塞, 无法终止进程 ,时间结束后,取消对2号进程的屏蔽,2号信号就会立即被递达而终止掉当前进程。
下面来一个更直观的例子来演示一下信号的捕捉
#include <iostream>
#include <cassert>
#include <unistd.h>
#include <signal.h>
using namespace std;static void PrintPending(const sigset_t &pending)
{cout << "当前进程的pending位图: ";for(int signo = 1; signo <= 31; signo++){if(sigismember(&pending, signo)) cout << "1";else cout << "0";}cout << "\n";
}static void handler(int signo)
{cout << "对特定信号:"<< signo << "执行捕捉动作" << endl;
}int main()
{// 设置对2号信号的的自定义捕捉signal(2, handler);int cnt = 0;//1. 屏蔽2号信号sigset_t set, oset;// 1.1 初始化sigemptyset(&set);sigemptyset(&oset);// 1.2 将2号信号添加到set中sigaddset(&set, SIGINT/*2*/);// 1.3 将新的信号屏蔽字设置进程sigprocmask(SIG_BLOCK, &set, &oset);//2. while获取进程的pending信号集合,并01打印while(true){// 2.1 先获取pending信号集sigset_t pending;sigemptyset(&pending); // 不是必须的int n = sigpending(&pending);assert(n == 0);(void)n; //保证不会出现编译是的warning// 2.2 打印,方便我们查看PrintPending(pending);// 2.3 休眠一下sleep(1);// 2.4 10s之后,恢复对所有信号的block动作if(cnt++ == 10){cout << "解除对2号信号的屏蔽" << endl; //先打印sigprocmask(SIG_SETMASK, &oset, nullptr); //?}}return 0;
}
将2号信号block掉,并且不断的获取并打印当前进程的pending信号集,如果此时我们发送一个2号信号,会看到pending信号集中的2号信号比特位由0变成1,若干秒后解除对2号信号的屏蔽,2号信号会被递达而执行我们提前设置好的自定义捕捉动作,执行完成之后。pending信号集中的2号信号比特位再由1变为0。
四、信号捕捉
1. 内核地址空间的引入
信号相关的数据字段是在进程的 PCB 内部,PCB 内部属于内核范畴,普通用户无法对信号进行检测和处理。那么要对信号进行处理,就需要在内核状态。当执行系统调用或被系统调度时,进程所处的状态就是内核态;不执行操作系统的代码时,进程所处的状态就是用户态。现在我们已经知道需要在内核态下进行信号处理,那究竟具体是什么时候呢?结论:在内核态中,从内核态返回用户态的时候,进行信号的检测和处理! 如何进入内核态呢?进行系统调用或产生异常等。汇编指令int 80
(80 是中断编号)可以进程进入内核态,也就是将代码的执行权限从普通用户转交给操作系统,让操作系统去执行!注:汇编指令int 80
内置在系统调用函数中。
用户态和内核态由谁来更改?
操作系统提供的所有系统调用,内部在正式执行正式调用逻辑的时候,会去修改执行级别。
- 所有的进程
[0,3]GB
是不同的,每一个进程都要有自己的用户级页表- 所有的进程
[3,4]GB
是一样的,每一个进程都可以看到同一张内核级页表,所有进程都可以通过统一的窗口,看到同一个操作系统!- OS运行的本质: 其实都是在进程的地址空间内运行的!
- 所谓的系统调用的本质:其实就如同调用
.so
中的方法,在自己的地址空间中进行函数跳转并返回即可!
进程是如何被调度的?
- OS是一款软件,本质上是一个死循环。
OS的时钟硬件
在每隔很短的时间内会向OS发送时钟中断。 - OS要执行对应的对应的中断处理方法,需要检测当前进程的时间片(通过
系统函数schedule
),进程被调度,就是时间片到了,然后将进程对应的上下文等进行保存并切换。再选择合适的进程。
什么行为会引起从用户态到内核态的转变?
执行系统调用、进程调度或者处理异常等等!
2. 信号的捕捉
sigaction函数
sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回 0,出错则返回 -1。signum 是指定的信号编号。若 act 不为空,则根据 act 修改该信号的处理动作。若 oldact 不为空,则通过 oldact 传出该信号原来的处理动作。act 和 oldact 指向 sigaction 结构体。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
#include <iostream>
#include <cstring>
#include <cassert>
#include <unistd.h>
#include <signal.h>
using namespace std;
static void handler(int signo)
{cout << "对特定信号:"<< signo << " 执行捕捉动作" << endl;
}
int main()
{struct sigaction act, oldact;memset(&act, 0, sizeof(act));memset(&oldact, 0, sizeof(oldact));act.sa_handler = handler;act.sa_flags = 0;sigemptyset(&act.sa_mask);sigaction(2, &act, &oldact);while(true) sleep(1);return 0;
}
Linux下不允许出现信号正在处理又来信号再被处理的情况,所以操作系统无法决定信号什么时候来,但是可以决定什么时候去处理信号。
#include <iostream>
#include <cstring>
#include <cassert>
#include <unistd.h>
#include <signal.h>
using namespace std;static void PrintPending(const sigset_t &pending)
{cout << "当前进程的pending位图: ";for(int signo = 1; signo <= 31; signo++){if(sigismember(&pending, signo)) cout << "1";else cout << "0";}cout << "\n";
}static void handler(int signo)
{cout << "对特定信号:"<< signo << " 执行捕捉动作" << endl;int cnt = 30;while(cnt){cnt--;sigset_t pending;sigemptyset(&pending); // 不是必须的sigpending(&pending);PrintPending(pending);sleep(1);}
}
int main()
{struct sigaction act, oldact;memset(&act, 0, sizeof(act));memset(&oldact, 0, sizeof(oldact));act.sa_handler = handler;act.sa_flags = 0;sigemptyset(&act.sa_mask);// 额外屏蔽3、4、5号信号sigaddset(&act.sa_mask,3);sigaddset(&act.sa_mask,4);sigaddset(&act.sa_mask,5);sigaction(2, &act, &oldact);while(true) {cout << getpid() << endl;sleep(1);}return 0;
}
五、其它
1. 可重入函数
- main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
- 像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数。反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
2. volatile
该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>int quit = 0;
void handler(int signo)
{printf("change quit from 0 to 1\n");quit = 1;
}int main()
{signal(2, handler);while(!quit);printf("quit 正常\n");return 0;
}
我们做一下调整:
这里我们发现无法结束进程,虽然已经对flag进行了修改。正常情况下,每次循环通过flag进行检测时,都需要到内存中取数据,但是编译器优化后。编译器认为main函数里面的代码没有对flag进行修改,所以为了提高效率,第一次过后就不去内存中取数据了,而是直接通过读取寄存器里的值来进行循环检测。而实际情况是内存中flag的值早就被改成了1。
关键字 volatile
就是为了保持内存的可见性,保证每次检测,都要从内存中进行数据读取,不要用寄存器中的数据。
当我们加入volatile关键字后,运行结果如下:
3. SIGCHLD信号
前面我们谈过,使用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞的查询是否有子进程结束等待清理(轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了。采用第二种方式,父进程在处理自己的工作的时候还要记得时不时的轮询一下。
子进程在退出的时候会给父进程发送SIGCHLD
信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程就可以专心处理自己的工作了。不必关心子进程,子进程在终止时会通知父进程,父进程在信号处理函数中调用wait函数清理子进程即可。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。 系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。请编写程序验证这样做不会产生僵尸进程。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
pid_t id;
void handler(int signo)
{sleep(5);printf("捕捉到一个信号:%d,who:%d\n", signo, getpid());//-1代表等待任意一个子进程pid_t ret = waitpid(-1, NULL, 0);if (ret > 0){printf("wait success,ret:%d,id:%d\n", ret, id);}
}
int main()
{signal(SIGCHLD, handler); // 自定义捕捉int i = 0;for(i = 0; i < 10; i++){id = fork();if (id == 0){// 子进程int cnt = 5;while (cnt){printf("我是子进程,我的pid是:%d,ppid:%d\n", getpid(), getppid());sleep(1);cnt--;}exit(1);}}// 父进程while (1){sleep(1);}return 0;
}
监测脚本:
while :; do ps axj | head -1 && ps axj | grep myprocess;echo "----------------------------"; sleep 1; done
通过for循环创建出10个子进程,若10个子进程发送信号,处理信号需要一个一个处理,所以当发送一个信号时,信号暂时被保留下来,但是父进程只有一个pending位图保留信号,当再次保留信号时,pending位图再次被置为1,把上次信号覆盖掉,造成信号丢失,最后处理信号时可能比发送信号的数量少。
修改方法:
void handler(int signo)
{sleep(5);printf("捕捉到一个信号:%d,who:%d\n", signo, getpid());while(1){pid_t ret = waitpid(-1, NULL, 0);if (ret > 0){printf("wait success,ret:%d,id:%d\n", ret, id);}else break;}//-1代表等待任意一个子进程printf("handler done...\n");
}
这里我们将SIGCHLD
的处理动作置为SIG_IGN
,这样fork出来的子进程在终止时会自动清理
signal(SIGCHLD, SIG_IGN);