信号:是进程间事件异步通知的一种方式,属于软中断。
#include
#include
using namespace std;
int main()
{
while (true)
{
cout << "我是一个进程,我的pid : " << getpid() << endl;
sleep(1);
}
return 0;
}
这段代码运行起来就会每隔一秒打印一句,用户可以通过Ctrl + C 来终止这个进程。Ctrl + C 的原理:首先用户在键盘上按下Ctrl + C按键,这个键盘会产生硬件中断,会被OS获取到,然后将输入的内容转化成信号,然后OS会把信号发送给目标进程。
前台进程收到此信号之后就会终止进程。
注意:
1.Ctrl + C产生的信号只能发送给前台进程。一个命令后加 &(./mytest &)就会放到后台运行,这样Shell就不必等待进程结束就可以接受新的命令,启动新的进程。
2.Shell可以同时运行一个前台进程和多个后台进程,只有前台进程才能接收到像Ctrl + C这种控制键产生的信号。
3.前台程序运行过程中,用户可能随时按下Ctrl + C而产生一个信号,也就是说进程的用户空间代码运行到任何地方都有可能接收到SIGINT信号而终止,所以信号相对于进程的控制流程来说是异步的。
1-31号信号是普通信号,其余的信号是实时信号,我们讨论的是普通信号。
这些信号本质就是用#define 定义的宏。例如SIGINT 就是 #define SIGINT 2。
可以通过man 7 signal 查看信号详细信息
验证Ctrl + C控制键产生的是SIGINT信号,下面介绍以下signal这个接口。
#include
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signal函数的作用就是可以用户自定义信号的处理方式,它的第一个参数是信号的编号(你可以使用2,当然也可以使用SIGINT),第二个参数是一个函数指针就是自定义处理该信号的方法,采用回调的方式完成的。
void handler(int signo)
{
cout << "收到了 " << signo << " 号信号" << endl;
}
int main()
{
signal(SIGINT, handler);
while (true)
{
cout << "我是一个进程,我的pid : " << getpid() << endl;
sleep(1);
}
return 0;
}
事实证明了Ctrl + C控制键产生的信号就是2号SIGINT信号,默认执行动作是终止进程。
处理信号的方式一般有三种情况:
执行该信号的默认执行动作。
忽略此信号。
自定义信号处理函数,当进程处理该信号时,由内核态切换到用户态时,就会执行该信号处理函数,这种方式叫做捕捉一个信号。
上面实验的Ctrl+C控制键产生的是SIGINT信号,除此之外,Ctrl + \产生3号SIGQUIT信号。同样也可以试着对3号信号做捕捉。
void handler(int signo)
{
cout << "收到了 " << signo << " 号信号" << endl;
}
int main()
{
signal(SIGQUIT, handler);
while (true)
{
cout << "我是一个进程,我的pid : " << getpid() << endl;
sleep(1);
}
return 0;
}
SIGINT信号默认动作是终止进程,SIGQUIT信号默认动作是终止进程 + Core Dump(核心转储)。
当一个进程要异常终止时,可以选择把进程的用户空间内存数据转储到磁盘上,形成的文件名通常是core.pid,这就叫做Core Dump。异常终止通常是因为存在BUG,例如野指针的访问引起的段错误,或者除0引起的浮点数溢出等等。事后,可以通过调试器gdb查清错误的原因,这叫做事后调试。
对于云服务器来说是关闭核心转储功能的
云服务器是一个生产环境,默认是关闭核心转储的,因为在生产环境中会上线某种服务,通常来说一个服务挂掉的时候,系统中有检测的机制,当监测到服务挂掉的时候,会重启服务,那么可想而知如果某个服务出现故障挂掉了而系统将其自动重启,刚重启又会挂掉,循环的挂掉重启挂掉…,那么就会有大量的core文件写入磁盘,并且这种循环的速度是惊人的,短时间内就可能写入大量的core文件,当磁盘被写满时就连操作系统都不能启动起来了,整个主机就会挂掉,所以生产环境通常是不开启核心转储功能的。
ulimit -a #查看系统特定资源上限
ulimit -c #设置core file文件的大小
实验:
将核心转储功能打开后,让进程发生除0错误,那么此时OS就会给该进程发送SIGFPE信号(浮点数异常)让进程终止,由于SIGFPE信号的行为是终止进程并且发生核心转储,所以实验的现象应为,发生除0错误的进程被终止并且形成Core.pid文件。
#include
#include
#include
using namespace std;
int main()
{
int a = 10 / 0;
return 0;
}
core-file core.pid #打开core文件定位错误位置
子进程终止后会变成僵尸进程,父进程可以通过进程等待的方式来释放子进程的资源避免内存泄漏,同时还可以获取子进程的退出信息。获取子进程的退出信息是通过一个输出型参数status来完成的,status是int型变量,status的低7为表示进程收到的终止信号,第8位是core dump表示位,表示是否发生了核心转储,次低8位表示进程的退出码,所以通过父进程waitpid等待子进程,获取子进程的退出信息,可以知道子进程是否发生了核心转储。
int main()
{
pid_t id = fork();
if (id == 0)
{
// child
int a = 10 / 0;
exit(1);
}
// parent
int status = 0;
waitpid(id, &status, 0);
cout << "exit signal : " << (status & 0x7f) << " exit code : " << ((status >> 8) & 0xff)
<< " core dump : " << ((status >> 7) & 0x1) << endl;
return 0;
}
注意:
core dump标志位表示的是是否发生了核心转储,意味着如果进程收到core类型的信号,但是核心转储功能是关闭的,core dump标志位也不会被置1。
int kill(pid_t pid, int sig);
该系统调用的功能就是给指定进程发送指定信号,第一个参数为进程的pid,第二个参数为向进程发送的信号,成功返回0,失败返回-1。
kill指令就是通过调用kill函数完成的。
kill pid 默认向进程发送15号信号。
模拟实现一个mykill指令:
void Usage(char *str)
{
cout << "Usage : " << str << " -pid -signo" << endl;
}
int main(int argc, char **argv)
{
if (argc != 3)
{
Usage(argv[0]);
return 1;
}
string str1 = argv[1] + 1; //+1 是将-过滤掉
string str2 = argv[2] + 1;
kill(stoi(str1), stoi(str2));
return 0;
}
通过mykill指令给一个死循环的进程发送SIGINT信号。
int raise(int sig);
只有一个参数为发送的信号,成功时返回0,失败是返回非0。
与kill函数不同的是raise只能给当前进程发送信号,kill是可以给指定的进程发送信号。
void abort(void);
abort函数是让当前进程收到SIGABRT信号而异常终止。与exit函数一样,abort函数总是会成功的,所以没有返回值。
在之前介绍使用管道来实现进程间通信时提到,如果管道的读端被关闭那么写端OS会杀死写端的进程,因为OS不会维护没有意义,低效率或者浪费资源的事情。OS就是向写端进程发送SIGPIPE信号,来杀死写端进程的。下面来验证以下:
void handler(int signo)
{
cout << "我是父进程,我收到了" << signo << "号信号" << endl;
exit(1);
}
int main()
{
int pipe_fd[2];
int n = pipe(pipe_fd);
if (n == -1)
{
perror("pipe");
exit(1);
}
pid_t id = fork();
if (id == 0)
{
// child
close(pipe_fd[1]);
char buffer[1024];
int cnt = 5;
while (cnt--)
{
int n = read(pipe_fd[0], buffer, sizeof(buffer) - 1);
buffer[n] = '\0';
cout << "我是子进程,从管道中读取到的数据:" << buffer;
}
close(pipe_fd[0]);
exit(2);
}
// parent
close(pipe_fd[0]);
signal(SIGPIPE, handler);
while (true)
{
const char *str = "hello Linux\n";
write(pipe_fd[1], str, strlen(str));
sleep(1);
}
close(pipe_fd[1]);
return 0;
}
#include
unsigned int alarm(unsigned int seconds);
这个函数的返回值为0,或者是上次闹钟的剩余时间。就好比你中午准备睡一个小时的午觉,害怕睡过头所以你定了一个1小时的闹钟,但是刚过了30分钟就被人吵醒了,接着你又重新定了一个40分钟的闹钟,那么此时的返回值就是上次闹钟剩余的那三十分钟。如果给alarm的参数设置为0的话,表示取消掉之前的闹钟,返回值仍然是之前闹钟所剩余的时间。
可以写段代码来验证一下alarm的返回值。
因为OS系统中可能存在大量被设置的时钟,所以OS必须对这些时钟做管理,先描述在组织,为每个时钟创建一个结构体将其描述起来,并且把所有的时钟结构体用一种数据结构组织起来,这样OS就管理起来了所有的时钟,OS会过一段时间就会检查时钟结构体是否已经到了设定的时间,如果到了那么就给相应的进程发送SIGALRM信号,如果用户不对SIGALRM信号做捕捉那么就执行默认动作终止进程。
顺便通过alarm函数来验证一下与外设的IO是效率低下的。
int count = 0;
int main()
{
alarm(1);
while (true)
{
count++;
cout << count << endl;
}
return 0;
}
int count = 0;
void handler(int signo)
{
cout << "count : " << count << endl;
exit(1);
}
int main()
{
signal(SIGALRM, handler);
alarm(1);
while (true)
{
count++;
}
return 0;
}
可以看到边计算边打印最终count被加到了三万多次,而一秒钟一直计算count被加到了五亿多次,可见与外设的IO是效率低下的。
硬件异常发生后被某种硬件检测到后,通知给操作系统,然后操作系统会向产生异常的进程发送信号。常见的硬件异常产生的信号有野指针的访问产生的SIGSEGV信号,除0错误产生的SIGFPE信号。
模拟野指针引起的异常
void handler(signo)
{
cout << "收到了" << signo << "号信号" << endl;
}
int main()
{
signal(SIGSEGV, handler);
int *p = nullptr;
*p = 100;
return 0;
}
为了解释为什么会一直死循环打印,先要了解野指针访问的是如何引起硬件异常的。
我们知道我们进程中用到的地址都是虚拟地址,但是CPU需要的是物理地址,所以虚拟地址会通过页表的映射转换为相应的物理地址,这种转换操作是由CPU中的MMU结构来完成的,页表会有一定的检查功能,例如某个地址的内容是只读的但是用户对其进行了写操作,就会引发MMU异常,OS得知硬件异常后就会给相应的进程发送信号,终止进程。
之所以死循环打印是因为,我们对信号进行了自定义捕捉并没有使进程终止,所以执行完信号的处理操作后会继续执行,但是我们的自定义捕捉操作中并没有对硬件异常做了修复,所以继续运行时MMU仍然是异常的,仍然会通知OS,OS还会向该进程发送信号。所以就会一直死循环。为了这种现象我们可以在自定义处理信号的函数最后加上exit()函数。
除0操作产生的异常
我们写的代码最终都是在CPU运行的,CPU中存在了大量的寄存器,例如代码中的算数运算和逻辑运算的相关数据都是放在寄存器中的,CPU中还有一些状态寄存器,当代码中发生除0错误的时候,状态寄存器的溢出标志位就会被置1,此时就会发生硬件异常,OS得知硬件异常后会给相应进程发送信号,终止进程。
在内核中存在三张与信号相关的表结构,分别是block表,pending表,和信号处理函数的函数指针数组。
每个信号都有两个状态标志位,分别表示阻塞和未决,还有一个函数指针表示处理信号的动作。信号产生时,OS会在进程pcb中设置该信号的未决标志,直到信号递达后才删除该标志。
对于上图中的1号信号,没有产生过,也没有被阻塞,当器被递达时会执行默认的处理动作。
对于图中的2号信号,已经产生过其pending表中的位置被置1,并且也被阻塞block表中的位置被置1,其递达后的处理动作为忽略。但是没有接触阻塞之前并不能忽略此信号,因为进程有机会改变对这个信号的处理动作。
对于图中的三号信号,没有产生过但是已经被阻塞,对信号的处理动作是用户自定义的。
如果在对一个信号解除阻塞之前,收到了多次该信号,OS是这样处理的:首先系统允许给一个进程发送同一信号一次或多次。在Linux中,常规信号在递达之前产生多次那么系统只记录一次,而实时信号在递达之前产生多次,系统会都记录下来放在一个队列中。
操作系统内定义了一种新的数据类型,这种数据类型就是一种位图结构用于我们获取或修改block表或者是获取pending表使用的。
以long int类型为4个字节的系统来说,这种数据类型定义了 32 * 1024 / (8 * 4) = 1024 个比特位,那么知道信号编号是如何与比特位对应的呢?起始也很简单以65为例,首先用65除以32得到属于第几个long int,再用65 % 32得到具体属于这个long int的第几个比特位。
为了更方便的操作sigset_t类型,系统提供了一批函数。
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);
对于前四个函数成功返回0失败返回-1,最后一个函数如果信号在信号集合中返回1不在返回0,失败返回-1。
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
使用sigprocmask系统接口来屏蔽2号信号。
int main()
{
// 在sigset_t类型中添加2号信号
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
sigaddset(&set, SIGINT);
// 屏蔽2号信号
sigprocmask(SIG_BLOCK, &set, &oldset);
while (true)
{
cout << "getpid : " << getpid() << endl;
sleep(1);
}
return 0;
}
阻塞2号信号,然后在十秒后解除对2号信号的阻塞。(当信号之前被阻塞,当他解除阻塞的时候会被立即递达
)
int main()
{
// 在sigset_t类型中添加2号信号
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
sigaddset(&set, SIGINT);
// 屏蔽2号信号
sigprocmask(SIG_BLOCK, &set, &oldset);
int cnt = 0;
while (true)
{
cout << "getpid : " << getpid() << endl;
sleep(1);
if (cnt++ == 10)
{
sigprocmask(SIG_SETMASK, &oldset, nullptr);
// 因为oldset中没有阻塞任何信号,所以使用oldset设置阻塞信号集等于将阻塞信号集清空
}
}
return 0;
}
int sigpending(sigset_t *set);
获取pending信号集,获取成功返回0,失败返回-1,set是一个输出型参数。
将2号信号阻塞,不断的获取pending位图并打印,然后用户给该进程发送2号信号,可以观察到的现象就是pending位图2号位置由0变位1。
void ShowPending(const sigset_t &pending)
{
for (int signo = 1; signo <= 31; signo++)
{
if (sigismember(&pending, signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
int main()
{
// 在sigset_t类型中添加2号信号
sigset_t set, oldset;
sigemptyset(&set);
sigemptyset(&oldset);
sigaddset(&set, SIGINT);
// 屏蔽2号信号
sigprocmask(SIG_BLOCK, &set, &oldset);
// int cnt = 0;
while (true)
{
sigset_t pending;
sigemptyset(&pending);
sigpending(&pending);
ShowPending(pending);
//cout << "getpid : " << getpid() << endl;
sleep(1);
// if (cnt++ == 10)
// {
// sigprocmask(SIG_SETMASK, &oldset, nullptr);
// // 因为oldset中没有阻塞任何信号,所以使用oldset设置阻塞信号集等于将阻塞信号集清空
// }
}
return 0;
}
重新认识一下进程地址空间:
我们直到进程地址空间的0-3GB是用户代码可以访问的区域而3-4GB的空间是内核空间,我们的进程是无法访问的。用户空间映射到物理内存通过的是用户级页表,同样内核空间的代码和数据映射到物理内存也有一个内核级页表,不过由于不同的进程0-3GB的用户空间是各不相同的,所以不同进程对应的不同的用户级页表,但是3-4GB的内核空间都是相同的,所以我们进程如何调度看到的内核空间都是相同的所以只需要一张内核级页表即可。操作系统的本质就是在进程的地址空间运行的。
用户态与内核态:
所以进程在调用系统调用的时候实际上就是在进程的地址空间内做跳转,但是为例防止进程随意访问操作系统的代码和数据,就引出了内核态和用户态的概念。进程处于用户态的时候是不能访问操作系统的代码和数据的。但是用户态和内核态是如何识别的呢?实际上在CPU中存在一个CR3寄存器,它就记录了当前进程处于用户态还是内核态,当CR3内部的标志位设置为3的时候表示为用户态,标志位设置为0的时候表示为内核态。那么CR3中的状态是如何修改的呢?实际上我们的进程在调用系统调用的时候,在执行调用逻辑的时候,会去修改执行级别。在返回时会将执行级别修改回来。
操作系统的本质:
操作系统的本质就是一个死循环,它自己也有属于自己的进程systemd通过自己调度自己,还是在别的进程的3-4GB的内核空间运行,它在不停的运行着,做着管理软件硬件的工作。
进程是如何被调度的呢?
在计算机中存在一个时钟硬件,它每隔很短的时间就会向操作系统发送中断,操作系统处理中断的方法就是检测进程的时间片,当一个进程的时间片到了的时候就会保存进程的上下文,调用shedule()系统调用选择合适的进程去调度。
前面一直说信号会在合适的时候被处理,这个合适的时候是指进程从内核态切换到用户态的过程,进程从内核态切换到用户态之前会去检查block位图pending位图,如果某个信号的block位图为0pending位图为1,那么在此时就会处理该信号执行信号的处理动作,如果在handler表中对应的位置为SIG_DFL
表示执行默认方法一般就是终止或暂停进程,执行完信号处理函数后恢复进程的上下文切换到用户态。如果handler表中对应的处理方法是SIG_IGN
表示忽略该信号,那么进程直接切换回用户态。如果handler表中对应位置为用户自定义方法
,那么由于用户自定义的处理方法是在用户态的,所以进程先从内核态切换到用户态执行用户自定义的处理方法,(执行完后不能直接返回用户态,因为此时进程的上下文保存在内核中),执行完后调用sigreturn系统接口返回内核态,最终再从内核态切换回用户态。
如果信号的处理动作是用户自定义时,那么信号捕捉的过程中会有两次检查处理信号的时间结点,第一次是由内核态切换回用户态前会检测信号,第二次是执行完用户自定义方法后经过sigreturn返回用户态,再经过内核态返回用户态的时候会第二次进行信号的检测。
之前提到可以通过使用signal函数来自定义信号处理方法,但是signal函数不如sigaction接口的功能丰富。
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
sigaction结构体:
struct sigaction {
void (*sa_handler)(int); //自定义捕捉放法
void (*sa_sigaction)(int, siginfo_t *, void *); //与实时信号有关不讨论
sigset_t sa_mask; //正在执行信号处理方法时,需要阻塞的信号集合
int sa_flags; //通常置0
void (*sa_restorer)(void);//与实时信号有关不讨论
};
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
#include
using namespace std;
#include
#include
#include
void ShowPending(sigset_t &set)
{
int signo = 1;
for (; signo <= 31; signo++)
{
if (sigismember(&set, signo))
{
cout << "1";
}
else
{
cout << "0";
}
}
cout << endl;
}
void handler(int signo)
{
cout << "正在处理 " << signo << " 号信号" << endl;
int cnt = 10;
while (cnt--)
{
sigset_t pending;
int n = sigpending(&pending);
assert(n == 0);
(void)n;
ShowPending(pending);
sleep(1);
}
cout << "处理完毕" << endl;
}
int main()
{
sigset_t set, oset;
// 屏蔽2号信号--本质是将PCB中维护的三个表中的block位图结构中2号信号的位置置1。
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2);
sigprocmask(SIG_BLOCK, &set, &oset);
// 捕捉2号信号
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, 3);
struct sigaction sig;
sig.sa_flags = 0;
sig.sa_mask = mask;
sig.sa_handler = handler;
sigaction(2, &sig, nullptr);
int cnt = 0;
while (true)
{
sigset_t pending;
sigemptyset(&pending);
int n = sigpending(&pending);
assert(n == 0);
(void)n;
ShowPending(pending);
if (cnt++ == 15)
{
sigprocmask(SIG_SETMASK, &oset, nullptr);
}
sleep(1);
}
return 0;
}
volatile的作用是:保持内存的可见性,告知编译器,不要做被该关键字修饰变量的优化,对变量的任何操作都必须在真实的内存中进行操作。
案例:
int flag = 0;
void handler(int signo)
{
cout << "flag from 0 to 1 " << endl;
flag = 1;
}
int main()
{
signal(SIGINT, handler);
while (!flag)
;
return 0;
}
对于这段代码,main执行流中首先对2号信号做了自定义捕捉,然后就是一个死循环。当进程收到2号信号的时候,信号处理函数会将flag从0修改为1,此时main执行流中的死循环终止,进程退出。
但是当编译器对代码做优化的时候,结果可能就不想我们想的那样了。gcc/g++ -O0 -O1 -O2等指定编译器的优化等级。
g++ -o mytest mytest.cc -std=c++11 -O2
可以看到虽然在信号处理函数中将flag修改为1,但是while循环却丝毫不受影响,编译器究竟做了什么手脚呢?通过汇编代码来一探究竟。
优化后的汇编代码
使用volatile关键字修饰flag后的汇编代码
可以看到,编译器优化后只将flag的数据从内存中取到eax中一次并且值判断了一次flag的值是否等于0,然后就一直执行死循环,可见我们在信号的自定义捕捉函数中对flag做修改,对于main执行流来说是没有影响的,因为编译器优化后对于flag而言对于内存是不可见的。使用volatile关键字修饰flag后,可以看到每次循环都是先从内存中将flag的值取到eax中,这样在信号的自定义函数中修改flag的值,对main执行流来说也是有影响的,volatile关键字确保了内存的可见性。
我们之前处理僵尸进程的方法就是父进程使用waitpid的方法等待子进程退出然后处理子进程的资源,获取子进程的退出信息,然而,父进程是如何得知子进程退出的呢?
事实上,子进程退出后,会给父进程发送SIGCHLD信号,但是父进程对于SIGCHLD的默认处理动作是忽略(父进程的handler表中对于SIGCHLD的位置填写的是SID_DFL-其默认动作是忽略
),由此我们就可以得到一种新的处理僵尸进程的方法,就是自定义信号的捕捉方法,父进程收到子进程退出的信号再去waitpid清理资源,获取退出信息等。
pid_t id;
void handler(int signo)
{
cout << "我是 pid :" << getpid() << " 收到来自 pid : " << id << "的" << signo << "号信号" << endl;
while (1)
{
pid_t res = waitpid(-1, nullptr, WNOHANG);
if (res > 0)
{
cout << "处理了" << res << "进程" << endl;
}
else
{
break;
}
}
}
int main()
{
signal(SIGCHLD, handler);
for (int i = 0; i < 8; i++)
{
id = fork();
if (id == 0)
{
// child
int cnt = 5;
while (cnt--)
{
cout << "i am child , pid : " << getpid() << endl;
sleep(1);
}
exit(1);
}
}
while (true)
{
sleep(1);
}
return 0;
}
相比于上面的写法还有一种更简洁的写法但是只是用与Linux操作系统。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。