我们平时在命令行中使用 ctrl + c 等组合键,为什么就能中断一个前台进程呢❓❓❓
这其实是当我们输入组合键的时候,键盘输入产生一个硬件中断,被 OS 获取,解释为信号,并且发送该信号到目标前台进程。当前台进程收到了信号之后,进而导致该进程退出!
如下图所示,我们输入 ctrl + c 组合键之后,当前这个前台进程会收到 SIGINT 信号,进而进行 Term 行为,也就是终止该进程!
除此之外不同的组合键对应着不同的信号,这个由对应的操作系统支持!
⚜️注意:Shell 可以同时运行一个前台进程和任意多个后台进程,但是只有前台进程才能接到像 ctrl + c 这种控制键产生的信号。
除此之外,前台进程在运行过程中用户随时可能按下 ctrl + c 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
那么可能就会有人问了,我们怎么知道该进程接收到的就是 SIGINT 信号呢❓❓❓
其实我们是可以使用我们以前学过的进程退出码来识别的,但是现在我们可以使用另一个系统调用函数来识别!这个函数就是 signal() 函数!
💥signal函数 -- 设置处理信号的功能
#include <signal.h>
typedef void (*sighandler_t)(int); // 函数指针,因为signal会采用回调函数的技巧
sighandler_t signal(int signum, sighandler_t handler);
// 作用:为当前进程注册一个以signum为信号的,handler为执行函数的接口
// 返回值:返回一个函数指针,目的就是为了可以调用!
// 参数:
// signum:我们要进行处理的信号(系统的信号我们可以再终端键入kill -l查看(共64个)。其实这些信号时系统定义的宏)
// handler:我们处理的方式(是系统默认还是忽略还是捕获) 其中第一个参数就是系统中的信号,常见的有 SIGABRT、SIGFPE、SIGILL、SIGINT、SIGSEGV、SIGTERM 等等,可以通过 kill -l 查看!其实本质都是宏定义,所以可以直接使用对应的整型大小代替!
下面我们来回忆一下上面讲过的一个程序处理信号的三种方式:
- 按默认处理(
SIG_DFL):信号由该特定信号的默认动作处理 - 忽略处理(
SIG_IGN):忽略信号,即使没有意义,代码执行仍将继续 - 自定义函数处理:定义一个特定的函数来处理信号。也称为捕捉信号
这也是上面 signal 函数的第二个参数,我们可以使用 SIG_DFL 或者 SIG_IGN 或者自定义函数来处理信号!
⚜️要注意的是,这个函数仅仅只是设置(注册)这个处理信号的功能,并不代表这个方法被调用了!如果进程没有收到信号的话,那么是不会执行设置的这个处理信号的功能的!
除此之外,如果是一个自定义处理信号函数,它应该遵循以下原型:
void handler_function (int parameter); 下面我们分别从默认处理、忽略处理、自定义函数处理来使用一下 signal()!
① 按默认处理
当执行程序时是死循环,此时按下 ctrl+c 进程停止,因为我们对信号 2 采取默认动作处理,系统默认信号 2 终止进程。
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
int main()
{
signal(2, SIG_DFL); // 注册处理函数,告诉2号信号默认处理
while(true)
{
sleep(1);
cout << "i am a process, i am running, pid: " << getpid() << endl;
}
return 0;
}
② 忽略处理
当执行程序时,陷入死循环,此时按下 ctrl+c 进程并不会停止,因为我们对 ctrl+c 产生的 2 号 SIGINT 信号采取了忽略处理,若要停止进程可用 ctrl+z 也就是 SIGQUIT 信号!
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
int main()
{
signal(2, SIG_IGN); // 注册处理函数,告诉2号信号也就是ctrl+c设为忽略动作
while(true)
{
sleep(1);
cout << "i am a process, i am running, pid: " << getpid() << endl;
}
return 0;
}
③ 自定义函数处理信号
提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉信号!并且值得注意的是,既然采用了自定义的函数来处理信号,那么原来的那个默认处理,就不存在了!
💥除此之外,系统对于 9号信号、19号信号 等信号,是一定会将该进程终止掉的,因为如果我们在自定义 9号信号 处理的时候如果不退出,那么就会导致进程无法杀掉,最后系统会崩掉,为了防止发生这种情况,OS 调用 9号信号 的时候肯定是会将对应的进程杀掉的,不存在说不杀掉的情况!
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
// 自定义处理信号的函数
void signal_handler(int signo)
{
cout << "进程捕捉到一个信号,信号编码为:" << signo << endl;
}
int main()
{
signal(2, signal_handler); // 注册处理函数,告诉2号信号通过自定义函数处理
while(true)
{
sleep(1);
cout << "i am a process, i am running, pid: " << getpid() << endl;
}
return 0;
}
⚜️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(这个下面会讲)!
所以 core dump 出现的主要原因就是为了支持错误之后的调试!
我们来看一下信号的一些行为:
可以看到这些不同的信号之间其实存在着一些相同的行为,比如说 Term、Core 等等,那么既然有这些行为了,为什么我们还需要这么多种不同的信号呢,直接让它们采用 Term 等行为终止不就好了吗❓❓❓
这个时候我们就要知道信号的意义:信号的不同,代表着不同的事情发生,但是对事件发生之后所采取的动作可以是一样的!
那么我们这里的重点来了,Term 和 Core 好像都表示终止进程,它们有什么不同呀❓❓❓
下面我们以其中的 段错误信号 SIGSEGV 来演示一下:
int main()
{
signal(SIGSEGV, SIG_DFL);
// 核心转储
int arr[10];
arr[100000] = 10; // 会发生段错误
return 0;
}
// 运行结果
[liren@VM-8-2-centos signal]$ ./mysignal
Segmentation fault 这不也是终止进程吗,那这和 Term 有什么区别❓❓❓
别急,其实这是因为我们使用的是云服务器,默认如果进程是 Core 退出的话,我们暂时是看不到明显的现象的!
我们可以使用 ulimit -a 指令来查看一下系统目前资源限制的设定(ulimit 命令用于控制 shell 程序的资源,为 shell 的内建指令,可用来控制 shell 执行程序的资源!)
可以看到第一项就是我们想找的,core file 的大小为 0,也就是说云服务器中默认不会给我们开辟这段核心转储的大小,需要我们去手动调整一下。
我们可以通过上图中括号的选项,对对应的资源进行修改,而 core file size 就是通过 -c 选项修改,所以指令为:ulimit -c 大小
接下来我们再来使用上面我们写的代码,这一次看看有什么不同:
mysignal 发生异常之后被信号处理终止,并且在当前目录下生成一个以 core 开头,以异常进程的 pid 为后缀的二进制文件,这就是 Term 和 Core 的区别!
除此之外,我们可以通过 gdb 调试这个二进制文件,这样子在可以直接通过 core-file + 文件名 定位到程序的错误位置**(需要在 makefile 文件中的 g++ 语句中加上 -g 选项!)**
Ⅱ. 调用系统函数向进程发信号
一、kill()函数 -- 向任意进程发送信号
还记得我们的 kill 指令吗,我们在命令行中通过 kill 指令可以将对应 pid 的进程进行发送不同的信号!但不仅仅是命令行指令,操作系统也给我们提供了系统调用函数 kill() 让我们给进程发送信号!
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
// 作用:向指定进程发送信号
// 返回值:成功调用的话发送信号,并且返回0,失败的话返回-1,并且设置对应错误码errno
// 参数:
// pid:即将接收信号的进程pid
// sig:准备发送的信号代码,假如其值为0则没有任何信号送出,但是系统会执行错误检查,通常会利用sig值为零来检验某个进程是否仍在执行。 其中参数 pid 是多种情况的,具体如下:
1、pid >= 0时,pid 是信号欲送往的进程的标识。
2、**pid == 0**时,信号将送往所有与调用 kill() 的那个进程属同一个使用组的进程。
3、**pid == -1**时,信号将送往所有调用进程有权给其发送信号的进程,除了进程 1 号进程。
4、**pid < -1**时,信号将送往以 -pid 为组标识的进程。
来解释一下,首先 test.cpp 是一个死循环,并且我们打印出它的 pid,然后 signal.cpp 是一个使用系统调用 kill() 的一个程序,我们通过命令行参数传递 mytest 的 pid,并且带上我们要让它发送的信号编号 9,执行起来后 mytest 接收到了该信号后就立刻被中断了!
所以我们也能印证两个观点:
① 我们平时使用的命令行中的 kill 指令其实底层就是调用的 kill() 函数去实现的。
② 大多数时候发送信号都是通过用户来发送的,操作系统只是为我们提供了接口!(有些时候比如下面的硬件异常就不是用户来发送)
二、raise()函数 -- 向当前进程发送任意信号
其实这个函数相当于是 kill() 函数中,发送信号的目标进程是当前进程,所以我们并不难去实现这个 raise() 函数!
#include <signal.h>
int raise(int sig);
// 作用:发送信号个调用进程
// 返回值:成功返回0,失败返回非0
// 参数:sig表示要向当前进程发送的信号编号 这个函数非常简单,下面我们写一段代码测试一下:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
int main(int argc, char* argv[])
{
int cnt = 1;
while(true)
{
cout << "cnt = " << cnt++ << endl;
sleep(1);
// 调用raise给当前进程发送信号
if(cnt >= 6)
raise(8); // 相当于是kill(getpid(), 8);
}
return 0;
}
三、abort()函数 -- 向当前进程发送六号信号
其实在语言层面中不同的信号编号有不同的接口调用,我们就以这里的 abort() 为例,它是向当前进程发送六号信号,该信号为 SIGABRT!相当于是调用了 raise() 函数后指定了 6 号信号!
#include <stdlib.h>
void abort(void);
// 作用:造成进程不正常的终止 其使用也是非常简单,下面给个例子:
#include <iostream>
#include <stdlib.h>
using namespace std;
int main(int argc, char* argv[])
{
int cnt = 1;
while(true)
{
cout << "cnt = " << cnt++ << endl;
sleep(1);
// 调用abort给当前进程发送六号信号
if(cnt >= 6)
abort(); // 相当于是kill(getpid(), 6)或者arise(6);
}
return 0;
}
Ⅲ. 硬件异常产生信号
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
例如当前进程执行了除 0 的指令,CPU 的运算单元会产生异常,内核将这个异常解释为 SIGFPE 信号发送给进程。再比如当前进程访问了非法内存地址,MMU 会产生异常,内核将这个异常解释为 SIGSEGV 信号发送给进程。
为什么除 0 和非法访问会终止进程呢❓❓❓
这是因为进程会收到来自 OS 的信号,但是 OS 又是如何知道需要发送该信号给进程的呢❓❓❓
这是因为 CPU 的原理,它在执行程序的时候,是通过 pc 寄存器,将程序中每行代码读取到 CPU 中进行运算等操作,因为除 0 对于 CPU 来说是无法完成的,所以 OS 此时识别完之后会立马发送八号信号也就是 SIGFPE 给该进程。
那么下面我们来证明一下当前进程中变量除 0 就收到了所谓的终止信号:
#include <iostream>
#include <signal.h>
using namespace std;
void catchSig(int signo)
{
cout << "catch a sign, the signo is " << signo << endl;
}
int main()
{
// 通过自定义函数来捕捉这个信号
signal(SIGFPE, catchSig);
while(true)
{
cout << "我是一个运行中的进程......" << endl;
sleep(1);
int a = 10;
a /= 0; // 除以0的情况
}
return 0;
}
首先我们知道,调用 signal 函数只是注册一个处理信号的方法,只有当执行到 a/=0 的时候才会真正的执行这个方法!
下面我们把这个除 0 的操作放到循环外面,其它的不变:
int main()
{
// 通过自定义函数来捕捉这个信号
signal(SIGFPE, catchSig);
int a = 10;
a /= 0; // 除以0的情况
while(true)
{
cout << "我是一个运行中的进程......" << endl;
sleep(1);
}
return 0;
}
我们可以看到很奇怪的现象,为什么我们把除 0 这个会导致错误的操作放到了循环外面,但是 catchSig 函数还是会无限循环的去被调用呢❓❓❓
其实这还是和硬件是有关系的!在 CPU 中我们去读取程序中的代码的时候,不仅仅要取它的代码,其中还包括一个状态寄存器,状态寄存器中有个溢出标记位,默认为 0,而当这次代码数据运算溢出的话,则其就会将 0 变成 1。最后 OS 检查到运算异常,则直接向该进程发送信号!
除此之外,还记得我们讲过 CPU 的时间片知识吗,我们说过,CPU 执行完这轮时间片之后,会将本次的代码数据进行上下文保护,当重新轮到的时候就会进行上下文恢复!而我们又知道收到信号,进程不一定会退出,我们上面的例子中就是这样子,所以我们的进程并不会退出,这样子导致 CPU 轮转的时候,进行上下文恢复,也就是回到上一次代码执行的位置,而每次检查状态寄存器的时候,发现溢出位一直都是 1,所以会不断地去调用 catchSig 函数,而我们不对其处理完进行退出的话,它就会一直保存这样子的状态持续下去直到被手动终止!
Ⅳ. 软件产生信号
其实我们之前就遇到过由软件条件而发送信号的场景,就是管道!不要只认为软件是我们平时使用的 app 啊等软件,因为操作系统也是软件,所以管道问题发送的信号就是与软件有关系!
之前说过父子进程,一个读管道一个写管道,此时读端关闭,那么写端就没有任何意义了,那么操作系统就会检查到这一情况,进而向进程发送 13 号信号也就是 SIGPIPE 信号!
下面我们主要介绍一下 alarm 函数和 14 号信号 SIGALRM 信号:
#include <unistd.h>
unsigned alarm(unsigned seconds);
// 作用:设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 且该信号的默认处理动作是终止当前进程。
// 返回值:返回在任何先前计划的警报发出之前剩余的秒数,如果没有先前计划的报警,则返回0。
// 参数:seconds表示要设置的秒数,如果秒数为0,则取消任何未决报警。 💥注意当执行到 alarm 函数的时候才可能设置闹钟,而程序也会继续往下走,并不会因为闹钟而阻塞着!
下面写段代码测试一下:
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
// 设置闹钟
alarm(5);
int cnt = 1;
while(true)
{
cout << "我是一个运行中的进程, cnt: " << cnt++ << endl;
sleep(1);
}
return 0;
}
利用这个硬件的原理,我们可以用 alarm() 来计算一秒内一条命令可以被执行多少次,所以我们可以来测试一下 IO 次数对效率的影响:
int main()
{
// 设置闹钟
alarm(1);
int cnt = 0;
while(true)
{
cout << "catch a sign, the cnt is " << cnt++ << endl;
}
return 0;
}
接下来我们再将打印语句放到自定义捕捉方法中,也就是等到捕捉的时候才进行打印:
int cnt = 0;
void catchSig(int signo)
{
cout << "catch a sign, the cnt is " << cnt << endl;
alarm(1); // 因为主函数中闹钟只响应一次,所以这里需要反复设置闹钟
cnt = 0; // 并且将计数重新设为0
}
int main()
{
// 注册自定义捕捉信号方法
signal(SIGALRM, catchSig);
// 设置闹钟
alarm(1);
while(true)
{
cnt++;
}
return 0;
}
对比上述两个程序,我们可以明显看到,第一个程序中我们不断的去 IO,这很大程度上导致了效率低下的问题,而第二个程序只有在收到信号后才会去 IO,所以这也印证了一个结论:IO 相对于内存是很慢的!不仅如此,我们使用的还是云服务器,那么还包括了网络 IO,这就更慢了!
除此之外,我们还看到第二个程序中,虽说我们每次将 cnt 置为 0,但是还是出现了累加的情况,这其实就是因为信号其实是存在延迟的,进而导致了程序运行时候和信号的延迟就不同,所以可能在极短的时间内,cnt 已经被重复了两遍!