Linux-进程信号
一,信号入门
信号的概念
🚀信号:是进程间事件异步通知的一种方式,属于软中断。
生活中的信号
- 你在网上购买了许多商品,在等待快递的到来。即便快递没有到来,但是你也很清楚当快递来的时候,你要怎样处理快递。也就是说你能识别快递。
- 当快递到来的时候,你收到快递到的信息,但是你正在做一件比较重要的事情,所以你并没有直接去取快递,但是你已经知道有快递来了。也就是说取快递的行为不一定要立即执行,可以理解为在“合适的时间”去取。
- 在收到通知有快递要取,再到你拿到快递期间,是有一个时间窗口期的,在这段时间中,你并没有去取快递,但是你已经知道有一个快递已经来了。本质上是你记住了“有一个快递要去取”。
- 到了合适的时间,你来取快递了。取到快递后就要对快递做处理。处理快递的情况有三种:1,执行默认动作(拆开快递立马使用商品)2,执行自定义动作(这个快递你是送给别人的礼物,所以你会把它送给别人)3,忽略快递(你对快递不感兴趣扔到一边继续做其他事情)。
- 快递到来的这个过程对你来说是异步的,你不能准确断定快递具体什么时候到来。
技术应用角度的信号
#include <iostream>
#include <unistd.h>
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信号而终止,所以信号相对于进程的控制流程来说是异步的。
使用kill -l 查看信号列表
🚀1-31号信号是普通信号,其余的信号是实时信号,我们讨论的是普通信号。
🚀这些信号本质就是用#define 定义的宏。例如SIGINT 就是 #define SIGINT 2。
🚀可以通过man 7 signal 查看信号详细信息
🚀验证Ctrl + C控制键产生的是SIGINT信号,下面介绍以下signal这个接口。
#include <signal.h>
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 Dump
🚀对于云服务器来说是关闭核心转储功能的
ulimit -a #查看系统特定资源上限
ulimit -c #设置core file文件的大小
实验:
将核心转储功能打开后,让进程发生除0错误,那么此时OS就会给该进程发送SIGFPE信号(浮点数异常)让进程终止,由于SIGFPE信号的行为是终止进程并且发生核心转储,所以实验的现象应为,发生除0错误的进程被终止并且形成Core.pid文件。
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
int main()
{
int a = 10 / 0;
return 0;
}
🚀可以通过使用gdb并借助Core.pid文件进行事后调试
core-file core.pid #打开core文件定位错误位置
code dump标志位
🚀子进程终止后会变成僵尸进程,父进程可以通过进程等待的方式来释放子进程的资源避免内存泄漏,同时还可以获取子进程的退出信息。获取子进程的退出信息是通过一个输出型参数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。
通过系统调用向进程发信号
kill
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信号。
raise
int raise(int sig);
🚀只有一个参数为发送的信号,成功时返回0,失败是返回非0。
🚀与kill函数不同的是raise只能给当前进程发送信号,kill是可以给指定的进程发送信号。
abort
void abort(void);
🚀abort函数是让当前进程收到SIGABRT信号而异常终止。与exit函数一样,abort函数总是会成功的,所以没有返回值。
由软件条件产生信号
SIGPIPE
🚀在之前介绍使用管道来实现进程间通信时提到,如果管道的读端被关闭那么写端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;
}
SIGALRM
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
🚀这个函数的返回值为0,或者是上次闹钟的剩余时间。就好比你中午准备睡一个小时的午觉,害怕睡过头所以你定了一个1小时的闹钟,但是刚过了30分钟就被人吵醒了,接着你又重新定了一个40分钟的闹钟,那么此时的返回值就是上次闹钟剩余的那三十分钟。如果给alarm的参数设置为0的话,表示取消掉之前的闹钟,返回值仍然是之前闹钟所剩余的时间。
可以写段代码来验证一下alarm的返回值。
🚀时钟的本质
🚀顺便通过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;
}
🚀为了解释为什么会一直死循环打印,先要了解野指针访问的是如何引起硬件异常的。
🚀除0操作产生的异常
三,信号阻塞
信号的相关概念
- 实际执行信号的处理动作称为信号递达。
- 信号从产生到递达之间的状态,称为信号未决。
- 进程可以选择阻塞某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞时,才会执行递达动作。
- 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是递达之后一种可选的处理动作。
在内核中的表示
🚀在内核中存在三张与信号相关的表结构,分别是block表,pending表,和信号处理函数的函数指针数组。
🚀每个信号都有两个状态标志位,分别表示阻塞和未决,还有一个函数指针表示处理信号的动作。信号产生时,OS会在进程pcb中设置该信号的未决标志,直到信号递达后才删除该标志。
🚀对于上图中的1号信号,没有产生过,也没有被阻塞,当器被递达时会执行默认的处理动作。
🚀对于图中的2号信号,已经产生过其pending表中的位置被置1,并且也被阻塞block表中的位置被置1,其递达后的处理动作为忽略。但是没有接触阻塞之前并不能忽略此信号,因为进程有机会改变对这个信号的处理动作。
🚀对于图中的三号信号,没有产生过但是已经被阻塞,对信号的处理动作是用户自定义的。
🚀如果在对一个信号解除阻塞之前,收到了多次该信号,OS是这样处理的:首先系统允许给一个进程发送同一信号一次或多次。在Linux中,常规信号在递达之前产生多次那么系统只记录一次,而实时信号在递达之前产生多次,系统会都记录下来放在一个队列中。
sigset_t类型
🚀操作系统内定义了一种新的数据类型,这种数据类型就是一种位图结构用于我们获取或修改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);
将sigset_t类型的变量所有比特位清零。int sigfillset(sigset_t *set);
将所有比特位置1。int sigaddset(sigset_t *set, int signum);
在位图结构中将该信号对应的比特位置1。int sigdelset(sigset_t *set, int signum);
在位图结构中将该信号对应的比特位置0。int sigismember(const sigset_t *set, int signum);
判断信号集合中是否包含此信号。
🚀对于前四个函数成功返回0失败返回-1,最后一个函数如果信号在信号集合中返回1不在返回0,失败返回-1。
sigprocmask
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
第一个参数有三个选项:
1,SIG_BLOCK:将set集合中信号添加到阻塞信号集中。
2,SIG_UNBLOCK:将set集合中的信号在阻塞信号集中取消。
3,SIG_SETMASK:将阻塞信号集设置为set集合中的信号。
第二个参数是一个输入型参数,将传入的set设置阻塞信号集。
第三个参数是一个输出型参数,将老的阻塞信号集传出。
🚀使用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;
}
sigpending
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;
}
四,信号捕捉
内核态与用户态
🚀重新认识一下进程地址空间:
🚀用户态与内核态:
🚀操作系统的本质:
🚀进程是如何被调度的呢?
信号处理过程
🚀前面一直说信号会在合适的时候被处理,这个合适的时候是指进程从内核态切换到用户态的过程,进程从内核态切换到用户态之前会去检查block位图pending位图,如果某个信号的block位图为0pending位图为1,那么在此时就会处理该信号执行信号的处理动作,如果在handler表中对应的位置为SIG_DFL
表示执行默认方法一般就是终止或暂停进程,执行完信号处理函数后恢复进程的上下文切换到用户态。如果handler表中对应的处理方法是SIG_IGN
表示忽略该信号,那么进程直接切换回用户态。如果handler表中对应位置为用户自定义方法
,那么由于用户自定义的处理方法是在用户态的,所以进程先从内核态切换到用户态执行用户自定义的处理方法,(执行完后不能直接返回用户态,因为此时进程的上下文保存在内核中),执行完后调用sigreturn系统接口返回内核态,最终再从内核态切换回用户态。
信号捕捉过程图解
🚀如果信号的处理动作是用户自定义时,那么信号捕捉的过程中会有两次检查处理信号的时间结点,第一次是由内核态切换回用户态前会检测信号,第二次是执行完用户自定义方法后经过sigreturn返回用户态,再经过内核态返回用户态的时候会第二次进行信号的检测。
sigaction
🚀之前提到可以通过使用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);//与实时信号有关不讨论
};
验证pending位图是处理函数执行前被置0的
#include <iostream>
using namespace std;
#include <signal.h>
#include <unistd.h>
#include <cassert>
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关键字
🚀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后的汇编代码
SIGCHLD
🚀我们之前处理僵尸进程的方法就是父进程使用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操作系统。