Linux之信号

信号

一、信号的概念

生活中的信号

比如你在网上买了很多件商品, 在等待不同商品快递的到来. 但即便快递没有到来, 你也知道快递来临时, 我就要去拿快递了. 也就是说当"快递到了"的信号传达给我时, 我知道如何去处理.

1. 信号没有产生的时候, 我们已经知道怎么处理这个信号了.

但是我们并不知道具体什么时候快递会来

2. 信号的到来, 我们并不清楚是什么时候, 信号到来相对于我正在做的工作, 是异步产生的. (异步是常态)

当快递员到了你楼下, 你也收到快递到来的通知, 但是你正在打游戏, 需5min之后才能去取快递. 也就是取快递的行为并不是一定要立即执行, 可以理解成“在合适的时候去取”。

3. 信号产生了, 我们不一定要立即处理它, 而是在合适的时候去处理

当你时间合适, 顺利拿到快递之后, 就要开始处理快递了.而处理快递一般方式有三种:

1. 执行默认动作(打开快递, 使用商品)

2. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)

3. 执行自定义动作(快递是礼物,你要送给你的朋友)

快递到来的整个过程, 对你来讲是异步的, 你不能准确断定快递员什么时候给你打电话, 所以在 收到通知拿快递 期间, 是有一个时间窗口的, 在这段时间, 你并没有拿到快递, 但是你知道有一个快递已经来了. 本质上是你“记住了有一个快递要去取”, 但是如果过了5min之后我忘了有取快递这码事, 我也就不会去取快递了.

4. 我要有一种能力, 将已经到来的信号进行暂时保存.

所以什么叫作信号: 信号是一种向目标进程发送通知消息的机制


二、信号的前置知识

前台进程与后台进程 

写了一段简单的死循环打印代码: 

#include <iostream>
#include <unistd.h>
                                                                                                                                               
int main()
{
  while(true)
  {
    std::cout << "Running..." << std::endl; 
    sleep(1);
  }
  return 0;
}
前台进程 

./process 运行后则不停地输出Runing..., 如果按下Crrl+C, 则程序终止:

结论1: Ctrl+C 可以终止前台进程

 当前进程运行时, 在命令行输入一些指令shell并不会做出任何反应:

结论2: shell也是前台进程, 且前台进程只能有一个. 

问题: shell 也是前台进程, 为什么ctrl+c  不会终止shell?

结论3: 这说明ctrl+c终止前台进程属于一般情况, 并不是所有的ctrl+c都会终止前台进程

后台进程 

刚才运行的进程属于前台进程, 如果在./process后加一个'&', 则程序会变成后台进程:

可以看到此时输入指令shell会做出反应, 因为此时的前台进程还是shell, 所以我们的前台进程后台进程可以并发的去运行, 但是按下ctrl+c无法终止后台进程 

注意: 进程在运行的时候, 前台进程(命令行操作)只能有一个, 后台进程可以有多个

区分一个进程是前台还是后台主要是看[有没有能力接受用户的输入], 前台进程只能有一个, 因为键盘只有一个.

如何终止后台进程呢? 

 1. 用kill -9 杀死后台进程:

2. 指令: jobs 可以查看后台进程, 此处运行了两个后台进程:

 指令: fg(front ground) + 任务编号 可以把指定的后台进程变成前台进程

 当后台进程转到前台时, 我们输入指令又没什么反应, 因为shell也是一个前台进程, 当有一个任务放在了前台, shell进程并没有退出, 但由于前台进程只能有一个, 所以os自动把shell放到后台.

此时可以用 Ctrl+C 终止进程: 

结论4: OS可以自动把shell放在前台或后台, 当有其它前台进程运行时, shell被放到后台, 前台进程终止时, shell又被放回前台. 

Ctrl + Z 可以暂停一个前台进程

但是前台进程不能被暂停, 如果被暂停, 该前台进程必须被放到后台

指令: bg + 任务编号 把一个任务切换为后台进程:

中断技术和信号的联系

OS是硬件的管理者, 所以硬件的一些变化OS都要知道, 比如鼠标点击, 键盘输入等.

而linux下一切皆文件, 键盘文件也有自己的缓冲区, OS知道键盘有数据, 通过键盘驱动, 将键盘的数据读到文件的缓冲区内, 上层就通过fd把文件读上来, scanf也就能接受到数据了.

但是根本问题是OS怎么知道键盘有数据呢?

对于OS来言, 不可能花费大量时间消耗在轮询每种硬件设备上, 这就涉及到中断技术.

中断: CPU 在执行程序的过程中,由于某种外部或内部事件的作用,使 CPU 停止当前正在执行的程序而转去为该事件服务,待事件服务结束后,又能自动返回到被中断程序进行执行的过程。

外部设备通过8259芯片间接地向CPU发送相关的中断信息, 中断号被存储到CPU内部寄存器中, 此时中断号就可以被程序读取了, 而OS内部会提供一张中断向量表(函数指针数组), 中断号和数组下标对应, 内部存储着特定硬件的读取方法. 所以当外设就绪时, CPU会把当前工作停下来, 由操作系统去读取中断号, 去执行相应的读取方法, 所以数据就被拷贝到OS内特定的内存区域. 整个过程都是由外部主动驱动的.

上面的过程像什么?

  • 按键盘的行为对于操作系统而言是异步的, 
  • 每一种信号都有对应的编号, 而中断号也有对应的编号.
  • 每一种信号在没发生时就已经知道要做什么了, 中断号通过中断向量表也可以知道.

所以信号本质就是用软件去模拟中断的行为, 只不过在设计上与中断是两套机制.

信号纯软件的, 专门用于进程之间信息的通知.

中断号软硬件结合的, 是外设与OS之间进行信息的通知.


操作系统中的信号

 我们可以通过键盘输入组合键产生信号, 比如 Ctrl+C 向前台进程发送 2 号信号(SIGINT) 终止进程:

1. 在Linux中输入 kill -l 可以查看可以被进程识别的信号.

2. 其中编号1-31为普通信号, 编号34-64为实时信号. 因为32和33号信号不存在, 所以共有62个信号. 我们只讨论普通信号, 对实时信号暂不做研究.

3. 为什么没有 0 号信号? 之前在介绍waitpid的参数status时说过, status低八位表示信号, 次低八位表示退出码, 当进程正常终止时, 低八位设置为0,  次第八位设置为退出码; 进程异常终止时, 第八位是core dump, 低七位是终止的信号, 次低八位无意义. 所以信号为 0 其实是表示进程正常终止的标志, 不能占用它.

注意: 由于信号是由定义的, 所以在使用信号时既可以用信号名, 又可以用信号编号.

既然信号是发送给进程的, 而进程是通过其PCB管理的, 所以被接收的信号就存放在进程的task_struct中.

如何存储呢?

进程要知道31个信号收到与否, 利用二进制的思想, 用1和0表示与否, 所以信号被存放在位图里. 31个信号正好可以放在一个32位的整形变量中, 每个比特位的偏移量表示信号编号, 0或1代表一个信号是否收到.

struct task_struct
{
    //信号位图
    unit32_t sigmap;
}

所以进程对于普通信号:

1. 每一个进程都有一张自己的函数指针数组, 数组的下标信号编号强相关.

2. 信号被存放在进程的位图中.

由于OS进程的管理者, 所以无论信号有多少种产生方式, 永远都是OS向进程发送信号.

如何理解发送信号呢?

"发送"有一些抽象, 发送信号本质信号, 是修改task_struct中保存信号变量的对应比特位, 也就是修改PCB中的信号位图.

因为信号必须由操作系统发送的特质, 所以我们之前才能使用过的这些信号:

  • kill -9+pid——终止某个进程
  • kill -19+pid——暂停某个进程
  • kill -18+pid——继续运行某个进程

信号处理方式的注册

所谓注册就是告诉操作系统某个进程接收到某个信号后的处理方式

关于信号要了解的第一个就是signal系统调用:

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

头文件:signal.h

功能:自定义信号的处理方式, 实质修改那张函数指针表, 将信号默认的函数替换为自定义的handler.

参数:int signal是要注册的信号编号, sighandler_t handler是自定义处理方式的函数指针

我们可以将信号的处理方式写成一个返回类型为void, 参数为int的函数. 然后该函数的指针传递给signal, 此时当进程接收到指定的信号编号时, 就会执行我们定义的函数.

下面是一个测试代码:

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
    std::cout << "获得一个"<< signo <<"号信号"<<std::endl;
    exit(1);
}

int main()
{
    signal(2, handler);
    while(true)
    {
      std::cout << "Running..." << ", pid: "<<getpid() <<std::endl; 
      sleep(1);
    }
    return 0;
}

大部分信号的默认处理方式都是终止进程,只是细节有一定差距, 所以我们此时可以自定义2信号的处理代码.

直接 Ctrl + C: 

kill -2: 

 2号信号SIGINT的默认处理方式就是结束进程, 而我们使用了自定义的处理方式, 所以进程在收到2号进程后额外打印一条输出语句. 也就证明了 Ctrl+C 确实是发送2号信号. 


三、信号的产生 

1.终端热键

像Xshell这样的终端常设置一些热键用于给进程发送相应信号.

比如刚才使用的 Ctrl+C 可以发送2号信号SIGINT(interrupt), Ctrl + Z 发送 20号信号SIGTSTP

还有一个常用热键 Ctrl+\, 用于发送3号信号SIGQUIT

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
    std::cout << "获得一个"<< signo <<"号信号"<<std::endl;
    //exit(1);                                                                                                                                                          
}

int main()
{
    signal(2, handler);
    signal(3, handler);
    signal(20, handler);
    while(true)
    {   
      std::cout << "Running..." << ", pid: "<<getpid() <<std::endl; 
      sleep(1);
    }   
    return 0;
}

 可以看到按下Ctrl+C/\/Z后, 进程针对信号都执行了自定义的行为, 分别打印出2/3/20号信号:

既然我们可以对信号进行自定义, 如果我们对31个信号都自定义处理, 是否就可以创造一个关不掉的进程了?

 给9号信号也自定义处理:

当我们发送9号信号时进程还是会退出, 为了避免关不掉的进程产生, 操作系统设置部分信号不能被注册自定义处理, 比如9号信号, 它依旧可以直接关闭进程. 但是大多数信号是可以自定义处理的.

梳理一下组合键被按下后信号发送的过程:

所以当键盘上的组合键被按下, 先向CPU特定针脚发送中断信号, 根据中断号执行对应的读取方法, 将数据从外设读到内存, OS对读到的数据进行识别, 发现是Ctrl+C/Z等组合键, OS将其解释为特定的信号, 向目标进程(前台进程)的PCB里的位图写入特定的信号, 完成了信号的发送. 进程在正常执行的过程中, 会在合适的时候去处理对应的信号.

2. 系统调用

(1)kill函数

int kill(pid_t pid, int sig);

头文件:sys/types.h、signal.h

功能:给一个指定的进程发送一个信号

参数:pid是信号发送的目标进程, sig是发送的信号编号

返回值:成功发送返回0, 失败返回-1

所以我们可以用 kill 这个系统调用实现一个我们自己的 kill指令:

#include <sys/types.h>
#include <signal.h>
#include <iostream>
#include <string>
#include <string.h>                                                               
#include <cstdlib>
#include <errno.h>

void Usage()
{
    std::cout << "kill -signalNumber pid" << std::endl;
}

int main(int argc, char* argv[])
{
    if(argc != 3)
    {   
        Usage();
        exit(1);
    }   
    
    int sigNum = std::stoi(argv[1]+1);
    pid_t  pid = std::stoi(argv[2]);

    int ret = kill(pid, sigNum);
    if(ret < 0)
    {   
        std::cerr << "error code:" << errno << strerror(errno) <<std::endl;
    }   
    return 0;
}

(2) raise

int raise(int sig);

头文件:signal.h

功能:给当前进程发送一个信号

参数:sig是发送的信号编号

返回值:成功发送返回0,失败返回-1

每隔一秒发送一次2号信号: 

#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
    std::cout << "获得一个"<< signo <<"号信号"<<std::endl;
}

int main()
{
    signal(2, handler);

    while(true)
    {   
        raise(2);                                                                                                                                                       
        sleep(1);
    }   

    return 0;
}

(3)abort函数

void abort(void);

头文件: stdlib.h

功能: 给自己发送6号信号SIGABRT

既然abort是向进程发送6号信号, 那我对6号信号进行自定义处理, 进程还会被终止吗? 

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

void handler(int signo)
{
    std::cout << "获得一个"<< signo <<"号信号"<<std::endl;
    //exit(1);
}

int main()
{
    signal(6,handler);
    abort();
    while(true)
    {                                                                                                                                                                   
        std::cout << "Running..." << ", pid: "<<getpid() <<std::endl; 
        sleep(1);
    }   
    return 0;
}

即使对6号信号进行自定义处理, abort函数最终依然会将当前进程终止. 


3.硬件异常

(1) 除零错误

我们都知道在除法运算中, 除数不能为0. 在计算机运算中除零会导致硬件抛异常.

我们编写一个程序看看 (该程序可编译通过, 但编译器会报警告).

#include<iostream>
#include<unistd.h>
#include <signal.h>
using namespace std;

void handler(int signo)
{
    cout << "获得一个" << signo << "信号"<< endl;
    sleep(1);
}

int main(int argc, char* argv[])
{
    signal(8,handler);

    cout << "进程运行中..." << endl;
    int resualt = 10; 
    resualt /= 0;
                                                                                                                                                                        
    return 0;
}

在运行到除零语句的时候进程接收到了8号SIGFPE信号(Floating point exception由8号信号产生), 但是进程却没有终止运行, 而是一直循环地处理8号信号, 为什么呢? 这个过程在底层时如何实现的呢?

1. 首先, 程序运行时, 内存中的数据会被拷贝到寄存器中通过CPU运算,如果有必要运算的结果还会被覆盖到内存中。

2. CPU中有一个状态寄存器, 一旦CPU在运算时发现了除0操作, 就将状态寄存器的溢出标志位OF置由0变为1, 此时硬件产生了异常, OS通过读取中断码发现了CPU的异常, 并将其解释给对应进程抛8号异常, 进程信号位图的8号位置由0置1, 然后处理八号信号.

3. 由于我们是用signal自定义8号信号的处理, 所以进程并没有被终止, 而是只输出了一条语句, 所以下一次进程在被执行的时候, 硬件上下文又会重新被CPU读取, 溢出异常依然存在, 就会产生循环.

4. 这也就说明了把进程杀掉, 默认就是处理异常的方式之一! 因为寄存器的内容属于进程的上下文, 所以进程被杀掉之后, 之后此进程就不会再被运行, 即使当前CPU寄存器的内容依然是异常的状态, 但是在其它进程运行时, 这些内容就会被当前进程的硬件上下文覆盖掉, 所以进程被杀掉是恢复CPU的信息健康状态的处理方式之一!

5. 所以一直会陷入"进程出异常->进程不退出->进程被调度"的循环

(2) 解引用空指针

空指针的解引用操作本质获取地址为 0x00000000 的内存块的内容, 而0号地址的内容属于内核空间, 是不允许用户访问的. 也就是说页表里没有0号虚拟地址到物理地址的映射, CPU中的MMU 通过查找页表来实现地址转换,将虚拟地址映射到物理地址. 而MMU发现该映射关系不存在, MMU 将引发页面错误异常, 像之前一样, OS就会捕获这个异常, 并发送信号给进程.

而这次接收到的是11号信号SIGSEGV(Segmentation Violation段错误)

#include<iostream>
#include<unistd.h>
#include <signal.h>
using namespace std;

void handler(int signo)
{
    cout << "获得一个" << signo << "信号"<< endl;
    sleep(1);
}

int main(int argc, char* argv[])
{
    signal(11,handler);

    cout << "进程运行中..." << endl;
    int* p = NULL;
    *p = 1;
                                                                                                                                                                        
    return 0;
}

总结:

结论1: 进程出异常时, 进程不一定会退出, 退出与否取决于用户想让它怎么做, 但一般都会让它退出.

结论2: 在VS等编译器时程序运行时发生的崩溃, 与VS本身没有直接关系, 而是因为程序在调度运行发生的除零/野指针等异常被Windows操作系统识别到了, windows杀掉了进程.

结论3: C++/Java等语言中都有异常捕获功能, 但抛出异常的根本目的不是为了修复这个异常, 而是为了在异常的位置打印语句提示用户某些操作失败了, 是为了让程序执行流正常地结束.

这也就是父进程waitpid需要获得子进程结束的信号的原因.

注意: 前三点始终贯彻一个观点--产生信号的方式有很多, 但是发送信号只能有OS发送! 


 4. 软件条件

  (1) 匿名管道

在匿名管道里说过, 如果管道的读端关闭, OS会给写端进程发送13号信号终止进程.

此处子进程作为读端读取三次父进程发送的消息后就退出:

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <string.h>
using namespace std;

void handler(int signo)
{
    cout << "接收到" << signo << "号信号" << endl;
    exit(1);                                                                      
}

int main()
{
    int pipefd[2];
    pipe(pipefd);

    pid_t n = fork();
    if(n == 0)
    {   
        //child, read端
        close(pipefd[1]);
        int cnt = 3;
        char message[1024];
        while(cnt--)
        {   
            ssize_t ret = read(pipefd[0], message, sizeof(message)-1);
            message[ret] = '\0';
            cout << "child process read: " << message << endl;
        }   
        exit(0);
    }   
    
    //father
    close(pipefd[0]);
    signal(13, handler);
    const char* buf = "Hello, I am father process";
    while(true)
    {   
        ssize_t ret = write(pipefd[1], buf, strlen(buf));
        sleep(1);
    }   
    
    return 0;
}

运行结果可以很明显看到父进程作为写端的确接收到了13号信号. 在这里读端是否关闭可看作软件中的条件, 条件达成即发送信号

(2) 闹钟

闹钟就是计时器, 而在系统调用确实有一个这样的函数:

unsigned int alarm(unsigned int seconds);

头文件:unistd.h

功能:从执行至该函数开始计时seconds秒, 时间到后给本进程发送14号信号SIGALRM

参数:seconds

  • 如果seconds不为0, 则函数会设置一个新的闹钟时间, 并返回之前闹钟剩余的秒数(如果有的话)如果之前没有设置闹钟, 则返回0
  • 如果seconds为0, 则函数会取消之前设置的闹钟, 并返回之前闹钟剩余的秒数(如果有的话)

返回值:在调用alarm函数之前, 之前设置闹钟剩余的秒数. 如果之前没有设置闹钟(即第一次调用alarm函数), 则返回0

一个进程只能有一个闹钟时间, 如果在调用alarm之前已设置过闹钟时间, 则任何以前的闹钟时间都会被新设置的闹钟时间所代替

设置一个30秒的闹钟, 程序死循环每隔一秒++计数器cnt, 然后对12号新号自定义处理, 用n接收alarm(0)的返回值, 也就是刚才30秒时钟的剩余时间, 然后打印出来, cnt + n 应该等于30: 

#include <iostream>
#include <unistd.h>
#include <signal.h>

using namespace std;

int cnt = 0;
int n = 0;

void handler(int signo)
{
    n = alarm(0);
    cout << "cnt: " << cnt << ", n: " << n <<endl;                                                    
    exit(0);
}

int main()
{
    signal(14, handler);
    cout << "pid: " << getpid() << endl;
    alarm(30);

    while(true)
    {   
        cnt++;
        sleep(1);
    }   

    return 0;
}

 闹钟的管理使用优先级队列(小根堆), 系统将计时时间最短的闹钟放在头部, 时间长的放在尾部. 所以, 操作系统只需要检测队首 (根节点) 的时间是否到就可以控制所有的闹钟. 时间达到就向队首进程发送14号SIGALRM信号并且将闹钟移出队列, 并继续检测下一个成为队首的闹钟。


核心转储(core dump)

man 7 signal 我们可以查看信号对应的名称, 编号, 默认处理方式信号产生原因等信息.

既然大部分信号对进程的处理都是终止进程, 那么直接把这么多合并不就好了.

如果只看结果的话确实没问题, 但进程一旦发生错误就必定会收到信号, 进程错误的原因包含在发送的信号内, 所以产生信号的原因更加重要, 不同的信号可能处理相同但错因完全不同.

以2号和3号信号举例:

2号信号叫做SIGINT, 默认处理方式是Term. 3号信号叫做SIGQUIT, 默认处理方式Core.

Core 和 Term 都可以终止进程, 但 Term 是直接终止进程, 而Core是会保存一些信息后再终止进程.

#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;

void handler(int signo)
{
    cout << "获得一个" << signo << "信号"<< endl;
    sleep(1);
}

int main(int argc, char* argv[])
{
    int* p = NULL;
    *p = 1;
    return 0;
}

解引用空指针会发送段错误, OS会发送11号信号终止进程, 但这里没有显示出Core dump的相关信息:

 因为云服务器默认关闭了file core选项, 直接运行是看不到现象的, 但是虚拟机默认会打开.

指令: ulimit -a 可以查看云服务器选项

其中第一行core file size为0就表示不生成core信息的文件.

除此之外, 还有一些选项.比如, 能够打开的最多文件个数open files是65535个, 管道文件可写入的最大值pipe size为8×512 = 4096字节,以及栈的大小stack size为8192×1024字节等等信息。 

输入ulimit -c 1024 将core文件的大小改为1024个数据块:

 此时我们再次运行程序就能发现当前目录下多出一个core文件 (当前linux系统为ubuntu, 其它系统此文件可能叫作core.进程pid). 在进程出现错误时, 内存中的有效数据会储存到文件内, 这个过程就叫做核心转储(core dump), 这个文件叫核心转储文件. 

但是如果我们再打开一个shell, ulimit -a 会发现core file size依然是 0, 这说明ulimit -c 1024设置只对当前的shell生效:

那么core文件有什么用呢? 在gcc编译时加上选项-g, 可以包含调试信息:

用gdb打开生成的可执行程序exception, 并输入 core-file 核心转储文件,此时就能定位到出错的地方:

核心转储其实就是能在进程出错的时候把退出的原因报告给用户, 可以定位到哪一行出了异常, 核心转储 Core 相比 Term 方式能够让我们快速定位出现异常的位置.


四、信号的保存

1. 相关概念 

信号递达 (Delivery): 实际执行信号的处理动作, 信号递达时可以选择三种处理动作: 分别是默认处理, 忽略处理, 和自定义处理.
信号未决 (Pending): 信号从产生到递达之间的状态, 信号在pending位图里是, 就是信号未决状态.(下面会说)

信号阻塞 (Block ): 被阻塞的信号产生时将保持在未决状态, 直到进程解除对此信号的阻塞, 才执行递达的动作.

注意:

1. 阻塞和忽略是不同的, 信号的忽略是信号递达时可以选择的一种处理动作, 信号未决是信号产生但还没被处理的一种状态

2. 在收到信号后, 信号未决不一定是信号被阻塞, 也可能是未来得及处理; 信号被阻塞则信号一定处于未决状态.


2. 在内核中的表示

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

首先, 下图应该横着看, 从左到右分别是两个位图: 阻塞(block)位图未决(pending)位图, 还有一个函数指针(handler)数组存储每个信号的处理动作. 

pending和block是两个一模一样的位图, 偏移量表示信号编号, 而pending中比特位的内容表示该信号是否处于未决状态, block中比特位的内容表示是否对该信号进行阻塞(屏蔽).

对于 handler, 联系之前signal函数使用我们就能知道: 当我们使用 signal 注册自定义处理方式时, 操作系统会将我们定义的函数的指针放在 handler 表的对应下标的位置上, 在信号递达后就会调用该函数.

关于signal补充两个宏定义, SIG_DEL(默认处理)SIG_IGN(忽略处理), 它们分别是void(*)(int) 类型的0和1.

如果是默认处理方式(0), 就直接调用handler默认的初始函数指针所对应的函数. 在信号被处理时内部如果判断处理方式不是0或1, 那就会当成自定义函数去执行:

信号产生后, 操作系统就会修改pending位图对应比特位置为1(此时信号处于未决状态), 然后在合适的时候信号应该被处理时, 操作系统检测block位图:

  • 如果该信号对应信号的比特位为1, 则说明该信号被阻塞, 就不会去递达
  • 比特位为0, 信号没有被阻塞, 就会调用handler表中的处理函数, 并在调用处理函数前把pending表中该信号对应的比特位置为0.

根据上面的过程我们得到的结论如下:

  • 一个信号有没有产生与它是否被阻塞没有关系
  • 被阻塞的信号在产生之后就会一直处于未决状态, 不会被递达, 只有当阻塞被解除后才会被递达
  • 默认情况下,所有信号都是不被阻塞的, 所有信号都没有产生, 也就是block位图和pending位图默认所有比特位都是0

此外, 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?

允许系统递送该信号一次或多次, Linux是这样实现的:

  • 常规信号在递达之前产生多次, 只计为一次,
  • 实时信号在递达之前产生多次可以依次放在一个队列里, 我们不讨论实时信号.

3. sigset_t信号集与信号集操作

什么是信号集? 

未决阻塞 标志可以用相同的数据类型 sigset_t 来存储, sigset_t称为信号集(实际上是一个位图). 在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有
效”和“无效”的含义是该信号是否处于未决状态

signal.h中对sigset_t的定义: 

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

#endif

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

信号集操作 

操作系统提供了一些相关的系统调用, 这些接口的集合称为信号集操作, 使用它们需要引用signal.h头文件

我们需要使用的是以下5+1+1个:

5个单纯对 sigset_t 类型的变量进行操作的函数: sigemptyset、sigfillset、sigaddset、sigdelset、sigismember:

 int sigemptyset(sigset_t *set);

头文件:signal.h

功能:使所有信号对应的比特位清零, 表示该信号集不包含任何有效信号.

参数:sigset_t *set是某个信号集变量的指针.

返回值:成功返回0, 失败返回-1

int sigfillset(sigset_t *set);

头文件:signal.h

功能:使所有信号对应的比特位变为1,表示该信号集的有效信号包括系统支持的所有信号。

参数:sigset_t *set是某个信号集变量的指针。

返回值:成功返回0,失败返回-1

int sigaddset(sigset_t *set, int signo);

头文件:signal.h

功能:使指定signo信号对应的比特位变为1.

参数:sigset_t *set是某个信号集变量的指针,int signo是需要被信号编号.

返回值:成功返回0,失败返回-1

int sigdelset(sigset_t *set, int signo);

头文件:signal.h

功能:使指定信号signo所对应的比特位变为0,表示该信号集中对应信号无效。

参数:sigset_t *set是某个信号集变量的指针,int signo是需要被信号编号

返回值:成功返回0,失败返回-1

int sigismember(const sigset_t *set, int signo);

头文件:signal.h

功能:判断指定信号所对应的比特位是否为1,返回类型是bool类型。

参数:sigset_t *set是某个信号集变量的指针,int signo是需要被信号编号。

返回值:成功返回0,失败返回-1

注意: 在使用sigset_ t类型的变量之前, 一定要调用sigemptysetsigfillset初始化, 使信号集处于确定的状态. 初始化sigset_t变量之后就可以在调用 sigaddset 和 sigdelset 在该信号集中添加或删除某种有效信号.

sigprocmask

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)

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

头文件:signal.h

功能:修改内核数据结构中的block位图, 同时也可以获取原来被修改的block位图.

参数:

1. int how指修改方式,共有三个选项:

  • SIG_BLOCK, 在block原有位图基础上添加sigset_t变量中设置的比特位;
  • SIG_UNBLICK, 在bolck原有位图基础上删除sigset_t变量中设置的比特位;
  • SIG_SETMASK, 用sigset_t变量覆盖原有的block位图

2. set是我们设置好的sigset_t变量的指针, 如果set是非空指针,则 更改进程的信号屏蔽字, 用于替换系统内的block.

3. oset是一个输出型参数, 如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出.

返回值:设置成功返回0,失败返回-1。

将2号信号屏蔽:

#include <iostream>
#include <signal.h>
#include <unistd.h>

void handler(int signo)
{
    std::cout << "handler: " << signo << std::endl;
}
int main()
{
    std::cout << "pid: " << getpid() << std::endl;
    
    signal(2, handler);

    sigset_t set, oset;
    sigemptyset(&set);
    sigemptyset(&oset);

    sigaddset(&set, 2);
    sigprocmask(SIG_BLOCK, &set, &oset);

    while(true)
    {
        sleep(1);
    }
    return 0;
}

不管是kill -2 发送2号信号, 还是Ctrl+C, 都没有任何反应, 因为2号信号被屏蔽了: 

 如果把31个信号全部屏蔽呢? 进程就无法被终止了吗? 

9号信号SIGKILL不允许被屏蔽, 所以9号信号被成为管理员信号, 任何进程都可以被9号信号终止. 

sigpending

int sigpending(sigset_t *set);

头文件:signal.h

功能:获读取当前进程的未决信号集, 通过set参数传出

参数:set是一个输出型参数, 返回从内核中获取的pending位图情况保存到该变量中.

返回值:成功返回0,失败返回-1.

 不断地打印pending位图, 便于查看信号的阻塞情况, 因为信号产生但被阻塞了, 那pending位图对应的比特位一定为1:

#include <iostream>
#include <signal.h>
#include <unistd.h>

void PrintPending(const sigset_t& pending)
{
    for(int i = 31; i > 0; i--)
    {
        if(sigismember(&pending, i))
            std::cout << "1";
        else
            std::cout << "0";
    }
    std::cout << std::endl;
}

int main()
{
    std::cout << "pid: " << getpid() << std::endl;

    //屏蔽2号信号
    sigset_t set, oset;
    sigemptyset(&set);
    sigemptyset(&oset);
    sigaddset(&set, 2);
    sigprocmask(SIG_BLOCK, &set, &oset);

    //进程不断获取当前进程的pending
    sigset_t pending;
    while(true)
    {
        sigpending(&pending);
        PrintPending(pending);
        
        sleep(1);
    }

    return 0;
}

由于我们已经把2号信号屏蔽了, 我们发送2号信号后, 该信号不会被递达, 所以它只能是未决的, 所以可以通过打印pending位图看到第二个比特位为1:

 修改一下while循环, 3秒之后解除对2号信号的屏蔽:

sigset_t pending;
    int cnt = 0;
    while(true)
    {
        sigpending(&pending);
        PrintPending(pending);
        if(cnt++ == 3)
        {
            std::cout << "解除对2号信号的屏蔽, 2号信号即将被递达" << std::endl;
            sigprocmask(SIG_UNBLOCK, &set, &oset);
        }
        sleep(1);
    }

解除对2号信号的屏蔽后, 信号被2号信号终止了.

思考: 在信号从未决状态到信号递达时, 是先把信号在位图中的比特位置为0, 还是先执行处理方法?

在handler函数中打印一下pending, 就能确定先后顺序了: 

#include <iostream>
#include <signal.h>
#include <unistd.h>

void PrintPending(const sigset_t& pending)
{
    for(int i = 31; i > 0; i--)
    {
        if(sigismember(&pending, i))
            std::cout << "1";
        else
            std::cout << "0";
    }
    std::cout << std::endl;
}

void handler(int signo)
{
    sigset_t pending;
    sigpending(&pending);
    std::cout <<"######################"<<std::endl;
    PrintPending(pending);
    std::cout <<"######################"<<std::endl;
    exit(1);
}

int main()
{
    std::cout << "pid: " << getpid() << std::endl;
    signal(2,handler);

    //屏蔽2号信号
    sigset_t set, oset;
    sigemptyset(&set);
    sigemptyset(&oset);
    sigaddset(&set, 2);
    sigprocmask(SIG_BLOCK, &set, &oset);

    //进程不断获取当前进程的pending
    sigset_t pending;
    int cnt = 0;
    while(true)
    {
        sigpending(&pending);
        PrintPending(pending);
        if(cnt++ == 3)
        {
            std::cout << "解除对2号信号的屏蔽, 2号信号即将被递达" << std::endl;
            sigprocmask(SIG_UNBLOCK, &set, &oset);
        }
        sleep(1);
    }

    return 0;
}

 所以在信号递达后执行处理方法前, 信号在pending位图里对应比特位就被置为0:


五、信号的处理

1.内核态和用户态的概念 

我们之前一直在说 进程在接收到信号后并不是立刻处理的, 而是会选择合适的时候处理, 这里合适的时候是什么时候?

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

想要理解这句话就需要知晓两个概念:进程的内核态和用户态。

用户态是一种受控的状态, 能够访问的资源是有限的.(比如访问野指针)

内核态是一种操作系统的工作状态, 能够访问大部分的系统资源.

默认情况下, 用户态只能访问[0,3GB]的空间, 而内核态可以让用户以OS的身份访问[3,4GB]空间. 

系统调用的背后, 就包含了身份的变化, 调用时用户态->内核态, 返回时内核态->用户态:

系统调用是操作系统内的代码, 如果用户要访问系统级资源(比如文件), 不能让用户直接访问, 而是要通过操作系统, 这也是为什么之前的IO级别的操作都需要封装系统调用.

既然要调用系统调用, 进程如何看到操作系统? 这就要重谈一下进程地址空间:

进程地址空间我们都很熟悉, 地址空间经过页表映射到物理内存. 以4GB地址空间为例, 0~3GB是用户空间, 这里的数据我们随时都能访问, (比如变量名, 下标等), 其对应的页表叫作用户级页表. 为了保证进程独立性,每个进程都会有一个进程地址空间,也都各自有一个用户级页表

3~4GB是内核空间, 不管内存中有多少个进程, 最先加载到物理内存的肯定是操作系统, 操作系统在哪里, 也肯定以高优先级加载进了物理内存中, 其对应的页表称作内核级页表.

结论: 操作系统中有多个进程, 内核页表也只有一张:

如果有多个进程, 为了保证进程独立性, 每个进程都会有一个进程地址空间, 也都各自有一个用户级页表. 但是操作系统在内存中只有一份, 内核页表在操作系统中只有一张就够了.正因为每个进程都通过同一个内核级页表和内存中的内核相映射,所以每一个进程地址空间中3~4GB这部分的内容都是一样的。 所以无论进程如何调度, CPU总能直接找到OS.


补充: 进程运行时操作系统如何知道当前进程是用户态还是内核态?

在进程运行时,操作系统就需要知道当前进程的身份状态。CPU中的CS寄存器低2位比特位用来表示内核态还是用户态, 用1表示内核态, 3表示用户态. 随着身份的变化, 页表也会有对应的变化.

所以操作系统中还有一套CR寄存器:

CR3用来保存当前进程对应的页表信息, (其中保存的是页表的物理地址)

CR1用来引发缺页中断的虚拟地址, 当软件试图访问已映射在虚拟地址空间中,但是并未被加载在物理内存中的一个分页时, 会发生缺页中断, 就要重新加载物理内存然后构建映射, 但是引发缺页中断的虚拟地址需要被保存, 以便操作系统处理完相关工作后, 能够继续执行代码.

 那这种由用户态切换为内核态的方式具体是什么?

这涉及CPU的内部中断(缺陷和陷阱), 指令int 80, 就是把身份由3改为1, 完成用户态到内核态的转化. int 80指令不是谁都能用的, 需要进行身份审核, 但是作为一个正常的Linux的合法用户, 登录上了自己的Linux机器, 就可以使用相应的系统调用了.


回到之前的思路, 既然要调用系统调用, 进程如何看到操作系统? 

代码执行至系统调用时, CPU中的CS寄存器对应比特位从3变为1, 进程的运行级别从用户态变成了内核态, 相当于程序的执行者从用户变成了操作系统, 此时就可以对这1GB的内核空间进行访问.

而此时进程需要执行操作系统的代码和数据, 只需要在自己的进程地址空间内部跳转到内核空间内对应的虚拟地址, 然后通过内核页表映射即可,  访问完之后跳转回用户空间即可, 而不是直接去访问物理地址.

结论: 就如同曾经的库函数调用, 调用系统调用接口, 也是在进程的地址空间中! 进一步说, 我们进程所有代码的执行, 都可以在自己的进程地址空间通过跳转的方式, 进行调用和返回!


2. 重新理解信号处理的时机

有了上面的铺垫, 现在再来理解 "从内核态返回到用户态的时候, 信号会被递达."这句话.

先看黑色的箭头, 表示信号的默认处理方式:

1. 在执行主控流程的某条指令由于中断, 异常或系统调用时, 要进行用户态到内核态的转化.

2. 内核工作处理完之后, 要进行内核态到用户态的转化.

3.  所以当进程从内核态返回至用户态时, 顺手就可以完成信号的检测处理, 因为此时仍处于内核态.

4. 检查到信号pending为1, block为0时, 就会对信号进行处理, 默认处理和忽略处理都在内核态就可以完成, 因为内核的数据结构在内核态可以直接修改.

红色的箭头表示信号的自定义处理, 自定义处理是最麻烦的:

当处理一个信号的自定义捕捉, 首先将pending位图里对应bit位由1置0(因为此时处于内核态, 也就解释了信号的保存里最后的"思考"), 然后由内核态切换为用户态, 执行用户级别的函数的自定义处理函数. 

思考, 这里可以在内核态去执行自定义处理函数吗?

不可以, 即使技术角度可以从内核态访问到用户的数据, 但是自定义处理函数是用户的代码, 一旦在其中写了越权的代码, 用户就可以绕开权限的认证, 访问内核的数据, 所以OS不相信用户表现在方方面面.

问题: 自定义处理函数处理完成之后, 该返回到哪里去? 直接从用户态跳转回用户态?

sighandler方法只是在一次用户态到内核态的转化中顺便执行的, 它不知道往哪里跳转, 不知道上一次为什么由用户态跳转到了内核态, 不知道应该向上次进入内核态的地方返回什么. 具体说, sighandler 和 main 函数使用不同的堆栈空间, 它们之间不存在调用和被调用的关系, 是 两个独立的控制流程.

所以sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态.

我们自定义的sighandler也没有显式写sigreturn函数啊, 怎么调用?

其实是内部它修改了sighandler函数栈帧的返回地址, 指向sigreturn即可.

 既然信号的处理是在内核态返回用户态进行的, 那如果我的代码没有使用过系统调用, 或者陷入死循环, 是不是就不会进入内核态然后捕捉信号呢?

不会, 用户态进入内核态不只是通过系统调用, 我们的进程都有时间片, 时间片到了就需要进程切换, 当操作系统再次调度此进程时, 操作系统需要把进程的PCB放入运行队列, 各种寄存器的上下文恢复..., 这些在内核态的工作做完之后, 操作系统就要返回到用户态执行代码.

结论: 无论是否通过系统调用, 进程的生命周期里一定会有非常多的进程切换, 一旦进行进程切换就要涉及内核态与用户态的切换, 所以当前进程依然有无数次机会进行信号的处理!

总结:

信号的自定义处理过程可以看成一个无穷大符号加一条线, 线的上边是用户态, 下边是内核态. 线的交点代表信号检测. 每经过一次中间的线就会发生一次状态改变,一共改变四次。 

首先操作系统由于某种原因(中断, 系统调用等)进入内核态, 内核态返回用户态时进行信号检测, 如果是自定义处理则跳转进用户态执行处理方法, 然后调用sigreturn返回内核态, 最后由内核态返回用户态.

疑问: 中间信号检测的交点再最后一次内核态返回用户态的过程中, 好像还检测一次, 但是我们之前并没有提到过它, 那是否在这里也会进行信号检测呢? 下面介绍sigaction最后会说明


3. 自定义信号的捕捉

sigaction

头文件: signal.h

功能: 注册更改信号自定义处理方式。

参数:

signum 表示信号的编号,

act 是输入型参数, 用来保存属性的结构体,

  • 其中sa_handler是保存自定义处理函数的函数指针(和signal函数的handler一样),
  • sa_mask 是需要额外屏蔽的信号, 下面主要讨论这个参数
  • sa_sigaction是处理实时信号的, 不考虑
  • sa_flags字段包含一些选项,本章的代码都把sa_flags设为0
  • sa_restorer也不考虑

oldact是输出型参数, 会将原本的处理方式的属性放入这个结构体变量中.

返回值: 成功返回0, 失败返回-1

该函数如果只使用sa_handler参数, 效果和signal都是一样的, 改变信号的处理方式.

我们主要来看sa_mask参数, 在此之前先说明一个结论:

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

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

 下面这段代码2号信号处理方法是死循环;

#include <signal.h>
#include <iostream>
#include <unistd.h>
using namespace std;

void Print(const sigset_t* set)
{
    for(int i = 31; i > 0; i--)
    {
        if(sigismember(set, i))
            cout << "1";
        else 
            cout <<"0";
    }
    cout << endl;
}

void handler(int signo)
{
    std::cout << "get a signal: " << signo << std::endl;
    while(1)
    {
        sigset_t pending;
        sigpending(&pending);
        Print(&pending);
        sleep(1);
    }
}

int main()
{
    cout << "pid: " << getpid() << endl;

    struct sigaction act, oact;
    act.sa_handler = handler;

    sigaction(2, &act, &oact);

    while(1)
    {
        sleep(1);
    }
    return 0;
}

 在处理2号信号时, OS自动屏蔽2号信号, 所以再次发送2号信号时, 2号信号应该被阻塞且处于未决状态, 但是其它信号没有被屏蔽, 所以我发送3号信号进程依然会被终止:

 现在把3号信号添加进sa_mask里面, 让2号信号捕捉期间, 3号信号也被屏蔽:

 解答上面留下的疑问:

这里还是会进行信号的检测, 首先说明信号检测其实就是数pending位图里比特位为1的个数, pending位图不为0说明还有信号需要被处理, 下面测试当进程同时有多个信号需要被处理, OS会怎么做:

#include <signal.h>
#include <iostream>
#include <unistd.h>
using namespace std;

void Print(const sigset_t* set)
{
    for(int i = 31; i > 0; i--)
    {
        if(sigismember(set, i))
            cout << "1";
        else 
            cout <<"0";
    }
    cout << endl;
}

void handler(int signo)
{
    std::cout << "get a signal: " << signo << std::endl;
    sleep(1);
}

int main()
{
    cout << "pid: " << getpid() << endl;
    //自定义处理2,3,4,5信号
    signal(2, handler);
    signal(3, handler);
    signal(4, handler);
    signal(5, handler);

    sigset_t mask, omask;
    sigemptyset(&mask);
    sigemptyset(&omask);

    sigaddset(&mask, 2);
    sigaddset(&mask, 3);
    sigaddset(&mask, 4);
    sigaddset(&mask, 5);

    sigprocmask(SIG_SETMASK, &mask, &omask);

    int cnt = 15;
    while(true)
    {
        cnt--;
        cout << "cnt: "<< cnt << endl;
        sleep(1);

        if(cnt == 0)
        {
            sigprocmask(SIG_SETMASK, &omask, nullptr);
            cout << "cancel 2,3,4,5 signal block" << endl;
        }
    }

    return 0;
}

向进程发送2,3,4,5信号:

对信号解除屏蔽之后, 对这四个信号统一进行了处理, 这就说明在最后一次返回时, 会对信号进行检测, 直到信号都被处理完再统一返回: 


六、其它补充问题

不/可重入函数

以链表的插入为例, 如果有许多个进程同时在一个链表中插入节点, 那会不会出现冲突呢? 

main函数调用 insert 函数向链表head中插入节点node1, 插入操作分为两步, 刚做完第一步p->next = head 的时候, 因为硬件中断使进程切换到内核, 再次回用户态之前检查到有信号待处理, 于是切换到sighandler函数, sighandler也调用insert函数向同一个链表head中插入节点node2, 插入操作的两步都做完之后从sighandler返回内核态, 再次回到用户态就从main函数调用的insert函数中继续往下执行, 先前做第一步之后被打断, 现在要继续做完第二步head=p.

结果是, main函数和sighandler先后向链表中插入两个节点node1和node2, 而最后只有node1真正插入链表中了, 在sighandler函数中插入的node2丢失了.

什么是重入?

像上例这样, insert函数被不同的控制流程调用, 有可能在第一次调用还没返回时就再次进入该函数,这称为重入.

此例中, insert函数访问一个全局链表, 会因为重入而造成错乱, 像这样的函数称为 不可重入函数.

反之, 如果一个函数只访问自己的局部变量或参数, 则称为 可重入(Reentrant) 函数. 

注意: 

  • 可重入和不可重入是函数的特性, 是中性的, 没有绝对的优劣.
  • 我们目前使用的大部分结构都是不可以重入函数。

符合以下条件之一的就是不可重入函数:

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

 更多内容在多线程部分说明


 volatile关键字

定义一个全局变量flag, 当flag为0的时, while进行死循环. 注册2号信号的处理方式自定义为将flag改为1.

#include <iostream>
#include <signal.h>
using namespace std;

int flag = 0;

void handler(int signo)
{
    cout << "chage flag 0 to 1" << endl;
    flag = 1;
}

int main()
{
    signal(2, handler);
    cout << "process start" << endl;
    while(!flag);
    cout << "process quit" << endl;
    return 0;
}

毫无疑问, flag变成1的时候, 主函数跳出死循环, 进程正常退出并打印"process quit":

gcc优化级别 

g++编译器是对gcc的封装, gcc编译器支持不同的优化级别, 不同的级别对优化的激进程度不同, 可以通过命令行选项 -O 来指定优化级别, 这些优化级别控制编译器如何优化代码,影响编译时间、生成的代码大小以及运行时性能。

g++ -O0 -o program_O0 main.cpp  # 无优化
g++ -O1 -o program_O1 main.cpp  # 基本优化
g++ -O2 -o program_O2 main.cpp  # 高级优化
g++ -O3 -o program_O3 main.cpp  # 最大优化
g++ -Os -o program_Os main.cpp  # 优化代码大小
g++ -Ofast -o program_Ofast main.cpp  # 最高性能优化

现在我们使用-O1选项进行优化, 发现发送2号信号后, flag确实从0变成了1, 但是进程还在运行. 再次发送多次2号信号, 进程依旧不终止:

 虽然flag确实被改成了1, 但是while(!flag); 却还是死循环, 那优化后的程序是如何运行的呢?

1. 最开始flag在物理内存中一定有一块空间, 内容是0

2. 当CPU处理到while(!flag); 指令时, 它会从内存读取flag的值放到寄存器中. 当我们发送信号后, flag被修改后, 内存中的flag确实从0变成1.

3. 正常没有优化时, CPU每完成一次while循环时, 都要从内存中读取flag的数据并更新到寄存器内, 再去判断while循环. 所以当flag从0变成1后, CPU会读取内存, 把寄存器的数据更新为1, 此时while循环也就会停下来.

4. 但是优化编译后, 由于CPU从内存读取信息相比直接从寄存器读取数据来说是很慢的, 而且在main函数代码中, flag只被读取不被修改, 所以编译器认为这个数据在main执行流内不会变化. 编译器在第一次将数据从内存读取到寄存器中便会不再从内存读取了, 即使你改变了内存中的flag, 寄存器中的数据也不会更新. 所以每次执行while时候都是使用的第一次从内存读取到寄存器中的值, 一直都是0, 所以循环始终不退出.

总之, 出现上述现象的原因就是CPU判断循环时使用的flag值和内存中的flag不一样. 所以, 为了让CPU每次在使用该变量时都从物理内存中取数据更新至寄存器, 可以使用volatile关键字来修饰这个变量

volatile int flag = 0;


SIGCHLD信号 

我们知道父进程使用wait和waitpid可以回收僵尸子进程, 父进程既可以阻塞等待, 也可以非阻塞轮询等待, 不断查询子进程是否退出. 我们之前用到waitpid回收子进程时, 都是在父进程显式地调用waitpid, 采用阻塞等待, 父进程阻塞了就不能处理自己的工作了; 采用轮询等待, 父进程在处理自己的工作的同时还要记得时不时地轮询一 下, 程序实现复杂.

如果现在父进程是一个死循环, 那父进程想在子进程退出的时候就回收它, 怎么办呢? 

实际上, 在子进程退出时, 会给父进程发送SIGCHLD信号, 我们可以自定义SIGCHILD的处理方式, 完成回收.

#include <iostream>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
using namespace std;

void handler(int signo)
{
    cout << "get a signo: " << signo << endl;
    waitpid(-1, nullptr, 0);//阻塞等待
}

int main()
{
    signal(SIGCHLD, handler);

    pid_t id = fork();
    if(id == 0)
    {
        cout << "child process is running.." << endl;
        sleep(5);
        exit(10);
    }
    
    while(1)
    {
        sleep(1);
    }
    return 0;
}

可以看到如预期一样, 5秒后子进程退出发送信号被父进程回收:

 如果父进程有10个子进程呢, 这些子进程都同时退出, 发送了10次信号, 但是父进程pending位图最多只能保存该信号2次, 一次是正在被处理, 第二次是信号阻塞时被保存起来的, 其它的信号全被"丢弃"了.

for(int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if(id == 0)
        {
            cout << "child process is running.." << endl;
            sleep(5);
            exit(10);
        }
    }

 可以看到这里进程没有被全部回收, 有三个进程还处于僵尸状态, 可能是因为回收的速度太快了,  还能有7个进程被回收了, 但是依然没有全部回收:

所以在handler里添加一句循环回收的代码:

void handler(int signo)
{
    cout << "get a signo: " << signo << endl;
    pid_t pid = 0;
    while(pid = waitpid(-1,nullptr,0) > 0)
    {
        cout << "回收进程:" << pid << endl;
    }
}

可以看到接收一次SIGCHLD信号后, 所有进程都被回收了: 

但是如果10个进程里只有6个进程退出, 还有4个没退出, 那在回收时父进程就要一直阻塞等待, 所以我们要把等待方式改成非阻塞等待: 

void handler(int signo)
{
    cout << "get a signo: " << signo << endl;
    pid_t pid = 0;
    while(pid = waitpid(-1,nullptr,WNOHANG) > 0)
    {
        cout << "回收进程:" << pid << endl;
    }
}

其实, SIGCHLD信号的默认处理动作是忽略:

但是此信号的忽略比较特殊, 该信号默认处理的忽略动作和我们手动把信号设置为SIG_IGN是有区别的:

1. 如果是默认处理的忽略, 那就是真的忽略了, 也就是说父进程不回收的话, 子进程退出后全是僵尸状态.

2. 如果在Linux下我们手动将SIGCHLD的处理动作改为SIG_IGN, 这样fork出来的子进程在终止时会自动被清理掉, 不会产生僵尸进程, 也不会通知父进程.

#include <iostream>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
using namespace std;

int main()
{
    signal(SIGCHLD, SIG_IGN);//手动设置SIG_IGN

    for(int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if(id == 0)
        {
            cout << "child process is running.." << endl;
            sleep(5);
            exit(10);
        }
    }
    
    while(1)
    {
        sleep(1);
    }
    return 0;
}

 所以如果父进程想要获得子进程的退出信息, 必须要wait回收子进程, 如果只是想避免僵尸进程出现, 那么wait回收或者手动设置SIGCHILD为SIG_IGN两种方法都可以!

注意: 系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的, 但SIGCHLD是一个特例. 此方法对于Linux可用, 但不保证在其它UNIX系统上都可用.


 

  • 26
    点赞
  • 31
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值