你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”,当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取。在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。
我们之前提到过,可以通过kill -l
命令来查看信号:
[grm@VM-8-12-centos lesson16]$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
其中我们将标号34以上的叫做实时信号
,本文章不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在signal(7)中都有详细说明 man 7 signal
每个信号都有一个编号和一个宏定义名称,这些宏定义可以在signal.h中找到。
我们之前讲过的,当显示屏中不断有数据在刷屏时我们可以用ctrl+c
来终止进程,其本质用户按下ctrl+c ,这个键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程,前台进程因为收到信号,进而引起进程退出。但是我们也提过前台进程可以用ctrl+c终止前台进程,但是却不能终止后台进程,我们将可执行程序运行时加上&
就能够变成后台进程。此时我们只有通过kill -9
命令来杀死进程。
注意:
signal
其实signal本质上来说并不是向进程发送信号,而是在收到了信号后我们用户自定义处理信号的方式,我们上面提到过,信号的处理方式有三种:
首先我们来看看signal的介绍:
我们观察参数,发现其中有一个函数指针,这个函数指针是由我们自己实现的,也就是当我们收到特定的信号时我们自定义的采取怎样的方式去处理。
我们可以写代码来验证一下:
signal.cc:
#include
#include
#include
#include
using namespace std;
void hander(int signo)
{
cout<<"get a signal:"<<signo<<endl;
exit(2);
}
int main()
{
signal(2,hander);
while(1)
{
cout<<"my pid is:"<<getpid()<<endl;
sleep(1);
}
return 0;
}
当我们在键盘上敲下kill -9 进程pid
时我们可以观察到下面现象:
这是由于我们自定义了信号的处理方式,当进程收到了2号信号时就不会执行默认2号信号的处理方式,而是执行了我们自己定义的处理方式(用函数指针实现)
注意:signal(2, handler)调用完这个函数的时候,hander方法被调用了吗?
答案是没有的,是接受到2号信号后才回调这个hander方法。
kill
int kill(pid_t pid, int signo);
这个函数的使用很简单,通过参数的命名我们就能够知道如何给指定进程发送信号。
那我们可以自己实现一个mykill:
#include
#include
#include
#include
#include
#include
#include
using namespace std;
void Usage(string proc)
{
std::cout << "\tUsage: \n\t";
std::cout << proc << " 信号编号 目标进程\n"
<< std::endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
return 1;
}
int sig = stoi(argv[1]);
int pid = stoi(argv[2]);
int n = kill(pid, sig);
return 0;
}
其实很好理解,杀死目标进程我们是创建了新进程来处理。
raise
int raise(int signo);
kill命令是调用kill函数实现的。kill函数可以给一个指定的进程发送指定的信号。raise函数可以给当前进程发送指定的信号(自己给自己发信号)。
除此之外还有一个C语言的abort
函数(使当前进程接收到信号而异常终止)
#include
void abort(void);
就像exit函数一样,abort函数总是会成功的,所以没有返回值。
SIGPIPE是一种由软件条件产生的信号,在“管道”中已经介绍过了。本节主要介绍alarm函数 和SIGALRM信号
#include
unsigned int alarm(unsigned int seconds);
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数.
我们来看看下面这个程序:
int cnt=1;
void hander(int sigo )
{
cout<<cnt<<endl;
exit(1);
}
int main()
{
signal(14,hander);
alarm(1);
while(true)
{
cout<<cnt++<<endl;
}
return 0;
}
这个程序的作用是统计出1秒中cnt累加的次数,我们运行起来看看:
当我们修改代码,不要加上输出语句再试试:
我们发现第二次的cnt累加次数明显远大于第一次的值,这其实也是很好理解的因为IO很慢
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号(8号)发送给进程。再比如当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为SIGSEGV(11号)信号发送给进程。
我们可以写程序来验证一下:
#include
#include
using namespace std;
void hander(int sign)
{
cout<<"初零错误"<<endl;
}
int main()
{
signal(SIGFPE,hander);
int a=10;
cout<<a/0<<endl;
return 0;
}
当我们运行时:
为什么会一直在重复打印呢?原因是在于我们自定义8号信号时执行但是没有退出程序,而除零错误·一直存在,所以会一直在显示屏上刷屏。野指针问题也类似。
SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且Core Dump,现在我们来验证一下。
什么是Core Dump?
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。默认是不允许产生core文件的,因为core文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: ulimit -c 1024.
那我们究竟如何查看当前进程的资源上限呢?
我们可以使用ulimit -a
:
我们发现在云服务器上默认是关闭了核心转储文件的,如果我们想设置可以使用命令:
ulimit -c XXX
来自定义的设置好核心转储文件大小。
我们之前通过man 7 signal
命令查看时:
不难发现有些信号是带有Core的,Term的默认执行动作是终止进程,没有其他动作;而Core是先会进行核心转储,再终止进程的。
我们可以来试试:
当我们写了一个除零错误的代码时让其自定义执行默认动作时:
就会生成一个core.xxx的文件,这个文件就是核心转储文件,那么这个文件有啥用呢?
我们在用gdb调试时可以使用core-file core.xxx
来帮助我们快速定位到错误所在,但是当我们没重复运行一次时就会重新生成一个core文件,所以云服务器上是默认关闭掉CoreDump文件。不知道大家忘记了没有,我们在讲解进程控制时就已经提过了一个Core Dump标志:
是否具有core dump是由这个标志位所决定的,这个标志位的获取方法有很多种,我这里给出一种:
(status<<7)&1
- 上面所说的所有信号产生,最终都要有OS来进行执行,为什么?OS是进程的管理者.
- 信号的处理是否是立即处理的?在合适的时候.
- 信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?
- 一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
- 如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
- 实际执行信号的处理动作称为信号递达(Delivery)
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞 (Block )某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号
从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号
的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有
效”和“无效”的含义是该信号是否处于未决状态。下面将详细介绍信号集的各种操作。 阻塞信号集也叫做当
前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。
#include
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置满,表示该信号集的有效信号包括系统支持的所有信号。注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。
#include
sigpending
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
现在我们可以用刚才介绍的函数做实验:实验内容为屏蔽2号信号,对2号信号进行自定义捕捉,获取pending信号集并打印,过一段时间后解除对2号信号的屏蔽,再次打印pending信号集。
代码实现:
#include
#include
#include
using namespace std;
void printSigpending(sigset_t& pending)
{
for(int i=1;i<=31;++i)
{
if(sigismember(&pending,i)) cout<<"1";
else cout<<"0";
}
cout<<endl;
}
void hander(int signo)
{
cout<<"执行对"<<signo<<"号信号的自定义捕捉动作"<<endl;
}
int main()
{
signal(2,hander);
sigset_t set,oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set,2);
sigprocmask(SIG_SETMASK,&set,&oset);
int cnt=0;
while(true)
{
sigset_t pending;
sigemptyset(&pending);
int n=sigpending(&pending);
printSigpending(pending);
sleep(1);
if(cnt++==10)
{
cout<<"解除对2号信号的屏蔽"<<endl;
sigprocmask(SIG_SETMASK,&oset,nullptr);
}
}
return 0;
}
我们不难从上面发现当我们发送了多次2号信号并且2号信号处于屏蔽状态时只会保留一次。
此时我们想终止该进程时可以用ctrl+\
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
我们之前讲解过,信号可以不是被立即处理的,而是在一个合适的时候,这个合适的时候是什么时候呢?
当进程从内核态
转换到用户态
的时候会在OS的指导下进行信号的检测。
那什么是用户态,什么是内核态呢?
先用通俗易懂语言来描述下:
用户态:当执行用户自己的代码时,进程所处于的状态;
内核态:当执行OS的代码时,进程所处于的状态。
那么在哪些情况下进程会从用户态转换到内核态呢?
进程的时间片到了,需要进行进程的切换时;
进行系统调用等。
我们还可以从地址空间上来理解,不知道大家忘记了下面这张图片了没有?
在32位的地址下,内存有4GB大小,其中【0,3】是用户空间,【3,4】是内核空间。执行用户空间的代码的进程就处于用户态,执行内核空间的代码的进程就处于内核态。
在用户空间中每一个进程都有一张用户级别的页表,用户级别的页表在不同的进程下有可能是不相同的;除此之外每一个进程还会为内核空间分配一个内核级别的页表,而不同的进程的内核级页表是相同的,所以不同进程就看到了同一份页表。所以OS运行的本质是在进程的地址空间上运行的,当我们调用OS的代码是直接在进程地址空间进行跳转即可。
那么问题来了,OS是如何判别用户态和内核态呢?
在CPU中有一种叫做CR3
的寄存器,寄存器中3表示进程处于用户态,0表示进程处于内核态。
所以我们现在可以解释一下进程调度的过程:OS时钟硬件每隔一段时间都会给OS发送时钟中断,OS会执行对应中断的处理方法(检测时间片),然后将进程的上下文进程保存和切换,选择合适的进程,OS处理时采用的是schedule
函数。
当我们自定义了信号的捕捉方式时,整个过程中进程从用户态到内核态的转换:
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态而不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用
sigreturn
再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
这时我们可能就会有一些小问题:当执行了某种信号时,pending位图是在hander方法处理完之前还是处理完之后被置为0的呢?
这个问题很好验证,我们只需要在自定义捕捉时打印一下即可,我们放在sigaction一起验证。
#include
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数。这里就不在过于多说,感兴趣的同学可以自己去查询。
我们可以写代码来验证下:
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);
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);
}
}
效果展示:
通过上面的演示我们可以验证pending位图是在hander方法处理完之前就已经清0了。
首先我们先来看看这样一种场景:
当我们执行其中一个进程代码的insert方法时,执行了第一行代码后该进程的时间片到了,调用了另外进程同样也执行了insert方法的第一行代码,然后切回到最开始的进程执行第二行代码,此时就造成了node2的内存泄漏,而这种函数就叫做不可重入函数
反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。但是使用局部变量就不会造成上面的混乱问题。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
这个关键字我们在C语言时就已经涉猎过,现在我们在从系统的角度再来理解一下。
我们先来看这样一段代码:
#include
#include
#include
using namespace std;
int g_val=1;
void hander(int signo)
{
cout<<"g_val form 1 to 0"<<endl;
g_val=0;
cout<<g_val<<endl;
}
int main()
{
signal(2,hander);
while(g_val);
cout<<" success quit"<<endl;
return 0;
}
当我们运行时:
我们发现当我们在终端下一直敲Ctrl+C时程序并不会运行,最后敲了Ctrl+\才退出,为什么呢?
这是由于编译器做了优化,它是怎么优化的呢?
我们知道编译器取数据都是到内存上面去取到的,当编译器识别到变量g_val时,由于在主函数里面没有直接修改该变量的值,所以编译器认为你没有修改这个变量,那我就将变量放到寄存器
中,我们使用该变量时就不用在到内存上面去取了,直接在寄存器中取出数据即可,而寄存器中的数据一直保存的是1,所以该程序就死循环出不来了。
有什么办法去掉编译器的优化吗?我们可以加volatile
关键字在变量前面,这就告诉编译器不要做优化了,也就是不要将该变量放在寄存器中,每次你取数据时还是老老实实到内存上面来取,这样做可不可行呢?我们接下来试试:
运行结果:
我们可以观察到这种方式是可行的。
其实不仅仅在Linux上,在VS中也会出现这样的优化,来看看下面的代码:
大家可以猜猜结果:
我们来调试一下:
在内存窗口中发现他们都是20,但是当我们看打印结果时就傻眼了:
打印结果居然是10和20,这里其实也是编译器做了优化,编译器认为既然你a加了const属性,那么我就认为你不可以直接修改,就直接把变量a放在了寄存器中,所以在打印结果中我们看到的是10而内存窗口看到的是20,我们加了volatile
关键字后看到的结果都为20了: