什么是信号
信号是一种用于进程之间通信或操作的一种手段,通常一个进程会在任意时刻接收到系统发送的信号,因此信号也可以看做是一种进程事件异步通知的手段。
一个进程可以对信号有三种操作:
- 默认操作:默认进行信号对应的操作——通常都是终止进程
- 自定义操作:可以通过函数来指定某一信号对应的操作
- 忽略操作:可以通过函数来忽略某一信号
进程能够识别信号,但是进程收到信号后并不一定立马执行信号所对应的操作,也有可能是被临时记住,在后续的操作中执行信号的操作。
当进程收到一个信号后,若是在执行更重要的代码时,就会将收到的信号保存在pcb中的位图结构中,用来标记是否收到该信号。
注意:发送信号实际上就是修改进程pcb中的位图结构,修改对应的位置就是对应的信号。
结论
- 进程认识所有信号,并且保存对信号的默认动作
- 当进程收到信号时,进程可能在执行更重要的代码,因此信号不一定被立即处理
- 进程本身具有保存信号的功能——保存在pcb中,利用比特位标记来判断是否收到信号
- 进程对于信号有三种处理方式——默认、自定义、忽略
查看系统的信号表
- kill -l ——查看信号表
- man 7 signal ——查看信号对应的操作
这些信号都有对应的宏定义,能够在 <signal.h> 文件中找到。
其中,1---31号都是普通信号,34-64号都是实时信号。
- 普通信号的处理方式都是异步的
- 实时信号的处理方式可以是同步的也可以是异步的
信号的产生
信号的产生一共有四种:
- 终端按键产生信号
- 调用系统函数产生信号
- 软件条件产生信号
- 硬件异常产生信号
我们先用这样一段代码作为测试代码:
#include<iostream>
#include<unistd.h>
using namespace std;int main()
{while(1){cout<<"I am a process, pid : "<<getpid()<<endl;sleep(1);}return 0;
}
编译后便开始进行测试。
终端按键产生信号
一般通过按键产生信号用过最多的就是 "ctrl + c",向进程发送SIGINT信号,来终止进程。
我们可以看到通过ctrl+c能够成功的将进程关闭掉。
类似于ctrl + c,我们还可以通过ctrl + \ 向进程发送SIGQUIT信号来关闭进程,不过这种方式还会产生core文件,一般用于检查进程异常终止的原因。
Core Dump:当一个进程产生异常终止的时候,可以选择把用户空间的内存数据全部保存下来,放入一个名为core的文件中,这就是Core Dump(核心转储)。我们可以用检查文件来检查core文件以发现错误原因,而一个进程运行产生core文件的大小取决于shell进程的pcb中的Resource Limit大小,一般不能够产生core文件,不过可以用 ulimit -c 1024 命令来运行shell产生core文件,最大1024k。
当我们修改了Resource Limit后再运行文件
能够看到确实产生了core文件。
而这个core文件我们可以通过gdb来查看问题所在。
使用gdb需要在编译的时候加上-g选项。
不过需要注意的是,像ctrl + c 和 ctrl + \ 只对前台进程产生作用。
我们可以在一个命令后加一个 "&" ,可以将命令放在后台运行,而不必等待进程结束后再执行命令。
我们发现这样进程就在后台运行了,ctrl + c 和 ctrl + \ 无法关闭,我们就可以采用 kill 命令来关闭。
kill命令格式: kill (信号) (对应进程的pid)
这样我们就能发现该后台进程被关闭了。
调用系统函数产生信号
除了通过键盘之类的终端产生信号之外,我们还能通过系统调用来产生信号。
kill函数
比如 kill 函数,我们可以在代码中调用该函数。
//mykill代码
#include<iostream>
#include<unistd.h>
#include<signal.h>
#include<sys/types.h>
#include<string.h>using namespace std;static void Usage(const std::string argv)
{cout<<"\nUsage : ./mykill signal pid "<<endl;
}//mykill signal pid
int main(int argc,char* argv[])
{if(argc != 3){Usage(argv[0]);exit(1);}pid_t id = atoi(argv[2]);int sig = atoi(argv[1]);int n = kill(id,sig);if(n != 0){cout<<"Kill Failed"<<endl;return -1;}return 0;
}
我们发现,我们可以直接调用 mykill 来直接关闭对应进程。
raise函数
raise函数也是一种系统调用,能够向进程发送信号,不过它是对调用该函数的进程发送信号。
我们编写一段函数:
//myraise代码
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<signal.h>using namespace std;int main()
{int cnt = 0;while(true){cnt++;cout<<"cnt : "<<cnt<<endl;if(cnt == 10){raise(2);}sleep(1);}return 0;}
我们发现,该进程10秒后就自动退出了。
abort函数
abort函数也是对调用该函数的进程发送信号。
#include<iostream>
#include<stdlib.h>
#include<unistd.h>
using namespace std;int main()
{int cnt = 0;while(true){cnt++;cout<<"cnt : "<<cnt<<endl;if(cnt == 10){abort();}sleep(1);}return 0;
}
该函数是对调用它的进程发送SIGABRT信号然后终止该进程,并且若是允许产生 Core 文件的时候,该函数还会产生一个Core文件。
软件条件产生信号
管道产生信号
当匿名管道中,读端关闭,写端一直写,OS就会给进程发送13信号(SIGPIPE)来终止写段进程。
我们来试一试。
#include<iostream>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
#include<signal.h>
#include<stdio.h>
#include<cassert>
using namespace std;int main()
{int pipefd[2] = {0};int n = pipe(pipefd);int id = fork();if(id == 0){//关闭读close(pipefd[0]);char buffer[1024];int cnt = 0;int pid = getpid();while(true){snprintf(buffer,sizeof(buffer),"我是子进程,pid : %d,cnt : %d",pid,cnt++);write(pipefd[1],buffer,sizeof(buffer));sleep(1);}}else {close(pipefd[1]);char buffer[1024] = {0};int cnt = 0;while(true){ssize_t s = read(pipefd[0],buffer,sizeof(buffer));if(s > 0){cout<<"我是父进程,收到消息 : "<<buffer<<endl;cnt++;}else {cout<<"写端关闭"<<endl;break;}if(cnt == 5){close(pipefd[0]);cout<<"读端关闭"<<endl;break;}}}int statu = 0;n = waitpid(id,&statu,0);assert(n != -1);cout<<"收到信号 : "<<(statu&0x7f)<<endl;
}
我们发现确实是收到了13号信号。
alarm函数产生信号
alarm函数能够设置一个时间,当时间到达后OS就向进程发送SIGALRM信号,用以终止进程。
一般来说,这个函数的返回值是0,不过它的返回值也可以是之前设定的闹钟时间所余下的时间。
我们写下这样一串代码来测试一下。
//myalarm.cc
#include<iostream>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>using namespace std;int main()
{int ret = alarm(20);int id = fork();if(id == 0){int second = 20;int n = 0;while(second != n){cout<<"已经过去"<<++n<<"秒"<<endl;sleep(1);}}cout<<"ret = "<<ret<<endl;cout<<"请输入你想修改的时间: "<<endl;int n;cin>>n;ret = alarm(n);cout<<"ret : "<<ret<<endl;int statu = 0;int wait = waitpid(id,&statu,0);return 0;
}
我们发现,第一次返回值是0,但是第二次返回值是17。
硬件异常产生信号
硬件异常就是硬件通过某种方式被监测到异常并通知给内存,然后向内核发送信号。
列如除0异常,或者野指针访问。
除0异常
#include<iostream>
#include<stdlib.h>
#include<unistd.h>
#include<signal.h>
using namespace std;
void handler(int sig)
{cout<<"signal : "<<sig<<endl;
}
int main()
{signal(8,handler);int cnt = 0; cnt/=0;while(true){}return 0;
}
我们写下一段除0异常并捕捉该异常。
我们发现即便只是出现一次除零异常,进程也会一直捕捉该异常。
这是因为当CPU进行计算除0的时候,由于0是无限小,因此所计算的结果也就是无限大,因而进程的pcb中的状态寄存器中的溢出标记位会置1,表示此次计算出现错误,OS就会向进程发送SIGFPE信号。
但是由于此进程一直未退出,导致pcb中的状态寄存器的溢出标记位一直为1,因此OS会一直向进程发送SIGFPE信号,进程收到信号后,在合适的时候就会执行该信号对应的动作,从而导致死循环打印。
野指针异常
野指针异常也是类似的,由于从虚拟内存访问到物理内存需要通过页表和CPU中的MMU(内存管理单元)来找到物理地址,当访问野指针时,MMU就会出错,OS就检查到硬件异常,从而发送SIGSEGV信号给进程。
总结
- 信号的产生是由OS管理的
- 信号并不一定是即时处理
- 没有被即时处理的信号一般保存在进程的PCB中
- 进程即使没有收到信号也知道这个信号的对应处理方式
- OS发送信号给进程的过程就是通过修改进程PCB中的位图结构,表示是否收到了信号
阻塞信号
信号和其他相关常见概念
- 实际执行信号的动作称为递达(Delivery)
- 信号从产生到递达之间的状态称为未决(Pending)
- 进程可以选择阻塞某个信号
- 被阻塞的信号将一直处于未决状态,知道进程取消阻塞信号
- 阻塞和忽略不一样,阻塞是未递达,而忽略是递达后的一种执行动作
信号在内核中的表示
前面多次提过,OS向操作系统发送信号是修改PCB中的位图结构,而这个位图其实是三张表。
这三张表分别是block,pending,以及handler,表的下标对应信号。
其中block表示该信号是否被阻塞,1表示被阻塞,0表示未被阻塞。
当OS向进程发送信号时,就会将发送的信号的pending表改成1,当进程能够处理信号时,就会检查pending表,若是1,就会再去查看block表,若是block为1,则表示这个信号被阻塞了,pending表不做修改;若是block为0,就表示这个信号未被阻塞,进程开始处理该信号,处理完后将pending表对应位置修改为0。
而某一个信号在阻塞时被发送了多次,Linux系统根据信号类别有两种处理方式:
1:普通信号只计算一次
2:实时信号就依次放入一个队列中来处理
sigset_t
为了方便用户对信号进行操作,OS给用户提供了一个类型——sigset_t。
这是一个位图结构,用来对进程的block表和pending表进行修改,但是我们无法直接通过位操作来对这个类型进行修改,而是必须通过操作系统提供的结构来进行修改。
这个类型的也被称为信号集,对应的位置在block表中被置1表示该信号被阻塞了,在pending表中置1则表示该信号处于未决状态,置0则表示该信号未被阻塞或者未处于未决状态。
操作系统也为我们提供了一系列接口用以对sigset_t进行操作
- sigemptyset :将set中的所有bit位置0,表示该信号集没有有效信号
- sigfillset:将set中所有bit位置1,表示该信号集包括系统所有支持的信号
- sigaddset:将set第signo位bit位置1,表示该信号集支持signo信号
- sigdelset:将set第signo位bit位置0,表示该信号集不支持signo信号
- sigismember:检查signo信号是否在set信号集中,在返回1,不在返回0,出错返回-1
- 需要注意的是,当我们需要使用sigset_t时,我们必须先调用sigemptyset或者sigfillset来初始化sigset_t,然后才能使用sigaddset和sigdelset来在信号集中添加或者删除某个信号
sigprocmask
该函数能够读取或者更改进程的信号屏蔽字。
这个函数一共有三个参数,我们一个一个了解。
how参数:一共有三种
SIG_BLOCK:将set信号集添加到block表中,相当于 mask |= set
SIG_UNBLOCK:将set信号集中的信号从block表中删除,相当于 mask = mask & ~set
SIG_SETMASK: 将block表中的信号设置为set,相当于 mask = set
其中,set若是非空指针,调用该函数则会按照 how 参数来对当前进程的block表进行修改。
oldset若是非空指针,调用该函数则会将当前进程的block表全部保存在 oldset 中。
若set 和 oldset 都是非空指针,则会先把block表中的信号保存到oldset中,然后根据 how 参数和 set参数对block 表进行修改。
sigpending
该函数能够查看进程的pending表。
该函数会查看进程的pending表并且通过set输出。
我们写下一段代码用来测试一下:
#include<iostream>
#include<signal.h>
#include<unistd.h>using namespace std;
void printpending(sigset_t p)
{for(int i = 1; i < 32; i++){if(sigismember(&p,i)){cout<<"1";}else{cout<<"0";}}cout<<endl;
}int main()
{sigset_t s,p;sigemptyset(&s);sigemptyset(&p);//将2号,3号信号放到s信号集中sigaddset(&s,2);sigaddset(&s,3);//将s信号集放入block表中sigprocmask(SIG_BLOCK,&s,NULL);int n = 0;while(1){sigpending(&p);printpending(p);n++;if(n == 5){//5秒后将2号,3号信号设置为非阻塞sigprocmask(SIG_UNBLOCK,&s,NULL);}sleep(1);}return 0;
}
我们发现,在5秒钟前,我们输入的ctrl +c 和 ctrl + \ 都处于pending状态,而进程无法响应,这说明这两个信号被阻塞了,5秒后,进程将这两个信号设置为非阻塞后,进程就立马关闭了。
不过实际上,有两个信号是无法被block的,那就是9号和19号信号,当这两个信号发送给进程的时候,就会立即终止进程。
捕捉信号
在讲述信号是什么的时候,我们就曾说过,进程会在合适的时候处理信号,而这个合适的时候就是当进程从内核态转到用户态的时候。
当一个进程需要进行系统调用,或者被系统调度的时候,进程就会需要访问一些系统资源,这个时候,OS就会将进程从用户态转为内核态,这样进程就能够访问OS的内核或者硬件资源,并使用OS的代码。
我们都知道,当进程到CPU中进行调度的时候,CPU会有一整套寄存器给进程使用,比如rex,rax之类的寄存器,这些我们统称为可见寄存器,同时CPU还有一些寄存器,称为不可见寄存器,在这些寄存器之中,有一个寄存器称为CR3,用来表征进程当时是处于内核态还是用户态,若其中CR3=0表示内核填,CR3=3表示用户态。
现在我们知道当进程被系统调度或者调用系统调用的时候,就会从用户态转化为内核态,然后就能够使用OS的代码,那么一个进程是如何访问OS的代码的呢?
首先我们都知道,一个进程的地址空间中,有一个1G大小的空间是内核空间,这个内核空间能够通过内核级页表来访问加载在物理内存中的操作系统,进而使用OS的代码。
也就是说当进程进行系统调用转为内核态后,就会通过内核级页表来访问OS的代码。
并且由于操作系统在内存中只有一个,因此内核级页表也只有一个,能够被所有进程看到。
进程从用户态转化为内核态总流程:
- 进程进行系统调用,被系统调度,或者处理异常时,就需要访问OS的代码或者访问硬件资源
- 而系统调用接口起始位置,有一个汇编命令 Int 80,它会让进程陷入内核,从而将 CR3的值变为0,从而转为内核态
- 然后再从mm_struct中,通过内核级页表,访问OS,从而成功访问OS代码
铺垫了这么多就是为了讲明白当进程收到信号时会执行什么操作,我们先了解一下捕捉信号是什么?
捕捉信号:当一个信号的处理动作是用户自定义函数时,在信号递达的时候就会调用这个函数,这就是捕捉信号。
而捕捉信号的总流程实际上也已经呼之欲出了:
其中有一处需要点出的是,由于操作系统不信任所有人,因此在处理信号的自定义动作的时候,进程又会转回用户态,然后执行处理函数,处理完后再通过特殊的系统调用转回内核态,如果没有新的信号递达就再从内核态转回到主流程上次被中断的地方,继续向下执行代码。
sigaction
为了方便用户操作,OS提供了专门用于捕捉信号的接口——sigaction,用来读取和修改指定信号的处理动作。
signum:需要修改执行动作的信号
act:若act不为空则根据act修改信号的处理动作
oldact:若oldact不为空,则通过oldact输出信号原来的处理动作
其中我们发现,sigaction的参数和函数名是一样的,我们再来看看struct sigaction。
其中第一个参数表示给普通信号设置的执行函数,第二个参数表示给实时信号设置的函数,第三个参数表示信号集。
接下来我们实际使用一下这个函数。
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;void sighandler(int sig)
{cout<<"catch one signal : "<<sig<<endl;
}
int main()
{struct sigaction act,oact;act.sa_handler = sighandler;act.sa_flags = 0;sigemptyset(&act.sa_mask);sigaction(2,&act,&oact);while(1) sleep(1);return 0;
}
我们发现确实是能够捕捉到信号的。
此外还有一个问题:当某一个信号的处理函数比较费时时,又来了一个信号时OS会怎么做呢?
当某个信号正在执行它的处理函数时,它自己会被OS放入block表中,表示被阻塞中, 直到处理函数处理结束后才会被解除阻塞。
此外,我们发现sigaction还有一个参数 —— sa_mask,这个参数是表示当执行信号处理函数时,希望其他信号一起被阻塞时,可以添加到这个字段中,在完成信号处理函数后会被自动解除。
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;void sighandler(int sig)
{cout<<"catch one signal : "<<sig<<endl;sleep(5);
}
int main()
{struct sigaction act,oact;act.sa_handler = sighandler;act.sa_flags = 0;sigemptyset(&act.sa_mask);sigaddset(&act.sa_mask,3);sigaction(2,&act,&oact);while(1) {sleep(1);}return 0;
}
比如这里,我们将3号信号添加到 sa_mask 中,再运行函数就会发现需要等5秒后才能接收到3号信号。
signal
signal函数是另一个简化版的sigaction函数。
只需要给它输入信号和对应的处理函数,即可直接捕捉信号。
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;void sighandler(int sig)
{cout<<"catch one signal : "<<sig<<endl;
}
int main()
{signal(2,sighandler);while(1) sleep(1);return 0;
}
可重入函数
我们设置一个情景,我们有一个全局链表,然后我们设置某一个信号的处理函数是为这个链表进行头插,而主函数也是在给这个链表进行头插。
就像图中,main函数中执行insert函数,把node1插入到链表中,当执行到 p->next = head的时候,突然来了一个信号,或者因为其他的什么原因,导致进程进入了内核态,然后进程发现有一个信号待处理,而这个信号的处理函数中要将node2插入到链表中,并且执行完成了,完成后,进程回到之前运行的位置,将head = p 执行了,然后我们就发现,这个链表只有node1是被真正插入了,node2不见了。
像上面inser函数这种,可能在第一次调用函数未完成时就再次进入该函数,这种行为被称为重入,而像insert这种,访问了一个全局变量,后因重入导致出错的函数则是不可重入函数,而一个函数只访问自己的局部变量或参数就是可重入函数。
不可重入函数的条件:
- 调用了malloc/free函数,因为malloc函数也是用全局链表管理堆的
- 调用了标准库的IO库函数,标准IO库的很多实现都以不可重入的方式使用全局数据结构
volatile
目前的编译器一般会给我们优化代码,用来提高效率,但是这也会导致一些问题,我们先来看一串代码。
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;int quit = 0;
void sighandler(int sig)
{cout<<"quit = "<<quit<<endl;quit = 1;cout<<"quit = " <<quit<<endl;
}
int main()
{signal(2,sighandler);while(!quit);cout<<"我是正常退出!"<<endl;return 0;
}
我们发现代码正常退出,但是如果我们提高编译器的优化级别后就会发现这样的问题。
注意:编译命令的 -O3 表示优化级别最高。
此时我们发现,明明quit已经等于1了,但是依旧没有退出,这就是因为编译器的优化问题了。
因为优化级别最高了,而信号的执行流和main的执行流是不一样的,因此CPU在运行代码时,它认为 quit 只是做了值检测,但是没有修改,因此CPU就不会从内存中获取数据,而是从它内部的寄存器获取数据,但是它寄存器的值是没有改变的,一直是0,所以即便展现出来这个quit值为1,但是CPU中的寄存器的值却是0,就导致这个问题出现。
而针对编译器优化问题,就需要volatile关键字——保持内存可见性,即时时刻刻通过内存来读取数据。
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;volatile int quit = 0;
void sighandler(int sig)
{cout<<"quit = "<<quit<<endl;quit = 1;cout<<"quit = " <<quit<<endl;
}
int main()
{signal(2,sighandler);while(!quit);cout<<"我是正常退出!"<<endl;return 0;
}
我们添加上volatile关键字后再编译,发现确实退出了。
SIGCHLD
在之前,父进程需要调用wait和waitpid函数清理僵尸进程,父进程可以阻塞或非阻塞式等待子进程结束,但是这两种方式各有缺点,阻塞式等待浪费时间,非阻塞式等待又代码复杂。
实际上在子进程终止时,会给父进程发送SIGCHLD信号,该信号的默认动作是忽略,父进程可以自定义该信号的处理函数,这样父进程就可以自己执行自己的任务了,只需要在处理函数中调用wait即可。
或者我们可以用signal或者sigaction函数,将SIGCHLD信号处理动作设置为SIG_IGN,这样子进程会自动清理掉,不会产生僵尸进程,也不会通知父进程。
volatile int quit = 0;
void sighandler(int sig)
{ pid_t id;id = waitpid(-1,NULL,WNOHANG);cout<<"wait success! id = "<<id<<" sig = "<<sig<<endl;
}
int main()
{signal(SIGCHLD,sighandler);pid_t id = fork();if(id == 0){cout<<"我是子进程 ,pid = "<<getpid()<<endl;sleep(3);exit(-1);} while(1){cout<<"我是父进程,pid = "<<getpid()<<endl;sleep(1);}return 0;
}
当然我们也可以直接忽略,也没有问题。
总结
信号是进程通信或者系统调度的一种重要手段, 它设计了很多内核的操作,许多题目中也会问到类似的问题,希望这篇博客能够对各位起到一点帮助,谢谢各位。