【Linux】进程信号
目录
一、什么是信号
二、信号产生的条件
1、键盘产生
2、进程异常
3、命令产生
4、软件条件
三、信号保存的方式
四、信号处理的方式
1、信号处理接口
2、信号处理时机
3、进程为什么要切换成为用户态才进行信号的捕获方法?
4、sigaction
五、可重入函数
六、volatile
七、SIGCHLD
总结
一、什么是信号
生活中有很多的信号
闹钟,红绿灯等等,这些信号还没有发出,我们就知道要干什么,对于信号的处理动作我们是早于信号产生就知道了,我们知道的原因是因为我们早就记住了“信号”
进程对于信号的处理也是这样,进程收到某种信号,它不一定会立即处理,在它合适的时机才会处理信号,所以在处理信号之前,我们需要保存信号,信号的本质是数据,向进程发送信号本质是向进程的PCB中写入信号数据。
二、信号产生的条件
在说明信号产生的条件之前,先看看Linux都有哪些信号
前31个信号称之为普通信号,后31个称之为实时信号
接下来引入一个函数signal
它是用来修改信号的默认行为的,第一个参数sig是信号编号,可以传入上面的宏,也可以传入数字,第二个参数是一个函数指针,返回值是void,参数是int
1、键盘产生
我们前面知道ctrl + c是用来终止进程,ctrl + z是暂停进程
我们可以验证ctrl + c是向进程发送什么信号 : ctrl + c是向进程发送2号信号SIGINT
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "get a signal " << signo << std::endl;
}
int main()
{
signal(2, handler);
while(true)
{
std::cout << "Hello World" << std::endl;
sleep(1);
}
return 0;
}
注意:signal是注册函数,并不是调用函数,只有当信号到来的时候,这个函数才会被调用
信号产生的方式之一便是通过键盘产生
接下来我们将9号信号修改为自定义动作
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "get a signal " << signo << std::endl;
}
int main()
{
signal(9, handler);
while(true)
{
std::cout << "pid: > " << getpid() << std::endl;
sleep(1);
}
return 0;
}
然后我们就发现,9号信号没有被捕捉(自定义)
原因是9号信号是管理员信号,OS不允许存在一个刀枪不入的进程
总结:一般而言,进程收到信号有三种处理方案
1、默认动作
2、忽略动作
3、自定义动作(signal捕获信号)
2、进程异常
进程异常也会发送信号,我们常见的段错误,除零错误的本质都是OS发送信号将对应进程杀掉
int main()
{
int a = 10 / 0;
std::cout << a << std::endl;
return 0;
}
int main()
{
int* p = nullptr;
*p = 100;
return 0;
}
那么进程异常OS是怎么检测到的呢?
在Linux中,当一个进程退出的时候,它的退出码和退出信号都会被设置
当一个进程异常退出的时候进程退出信号被设置,表明当前进程退出的原因
如果有必要,OS会设置退出信息中的core dump标志位,并将进程在内存中的数据转储到磁盘当中,方便后期调试
某些平台会将core dump关闭,打开方式:输入命令ulimit -a
我们观察到core file size 大小是0
我们使用命令
ulimit -c 10240
将core file size大小调整为10240
int main()
{
int a = 10 / 0;
std::cout << a << std::endl;
}
21291是进程pid
我们打开gdb来获取错误的行号
注意:并不是所有的信号都会产生core dump
3、命令产生
kill用来向任意进程发送信号
我们写两个程序来验证
//proc.cpp 将来接受信号的进程
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "get a signal " << signo << std::endl;
}
int main()
{
for (size_t i = 1; i < 32; i++)
{
signal(i, handler);
}
while (true)
{
std::cout << "pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
将来发送信号的进程
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "get a signal " << signo << std::endl;
}
int main()
{
pid_t id = 0;
int signo = 0;
std::cout << "请输入要发送信号进程的pid";
std::cin >> id;
std::cout << "请输入要发送的信号" << std::endl;
std::cin >> signo;
kill(id, signo);
}
另一个系统调用接口是raise
raise是向自己发送信号
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "get a signal " << signo << std::endl;
}
int main()
{
for (size_t i = 1; i < 32; i++)
{
signal(i, handler);
}
std::cout << "pid: " << getpid() << std::endl;
sleep(5);
raise(11);
return 0;
}
最后一个系统调用接口是abort
功 能: 异常终止一个进程。中止当前进程,返回一个错误代码。错误代码的缺省值是3。
该函数产生SIGABRT信号并发送给自己,默认情况下导致程序终止不成功的终止错误代码返回到主机环境。
自动或静态存储持续时间的对象,而无需调用任何atexit函数,析构函数不执行程序终止。函数永远不会返回到其调用者。
#include <iostream>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "get a signal " << signo << std::endl;
}
int main()
{
for (size_t i = 1; i < 32; i++)
{
signal(i, handler);
}
std::cout << "pid: " << getpid() << std::endl;
sleep(5);
abort();
return 0;
}
4、软件条件
通过某种软件(OS),来触发信号的发送,系统层面设置定时器,或者某种操作而导致的条件不就绪等这样的场景下,触发信号发送
例如进程间的通信,当读端不光不读,而且还关闭fd的时候,写端一直在写,最终写端进程会受到sigpipe(13)信号,将该进程杀掉
另一种实现方法是alarm
三、信号保存的方式
OS给进程发送信号 -> OS发送信号数据给task_struct -> 本质是OS向指定进程的task_struct中的信号位图写入比特位1,即完成信号的发送
信号的编号是有规律的[1, 31]
进程中,采用位图标识该进程是否收到信号
所谓的比特位的位置(第几个比特位),代表的就是哪一个信号比特位的内容(0,1),代表的就是是否收到信号
递达-忽略 VS 阻塞 的区别
忽略是递达的一种方式,阻塞是没有被递达,它是独立状态
四、信号处理的方式
1、信号处理接口
sigprocmask可以读取或者更改进程的信号屏蔽字,它是用来修改block位图的
它的第一个参数是怎样修改
SIG_BLOCK | set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask = mask | set |
SIG_UNBLOCK | set包含了我们希望从当前信号屏蔽字中解除阻塞信号,相当于mask = mask &~ set |
SIG_SETMASK | 设置当前信号屏蔽字为set所指向的值,相当于mask = set |
第二个参数和第三个参数的类型是sigset_t,sigset_t是一个位图结构,但是不同的OS的实现细节是不一样的,不能让用户直接修改该变量,需要使用特定的函数
这个函数不是对pending位图进行修改,因为前面我们已经说明了,如何向进程发送信号,向进程发送信号的本质就是修改该进程的pending位图
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
void show_pending(const sigset_t* pending)
{
std::cout << "cur process pending" << std::endl;
for(size_t i = 1; i < 32; i++)
{
if(sigismember(pending, i))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
}
int main()
{
sigset_t set;
sigset_t oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2);
sigprocmask(SIG_BLOCK, &set, &oset);
sigset_t pending;
while(true)
{
sigemptyset(&pending);
sigpending(&pending);
show_pending(&pending);
sleep(1);
}
return 0;
}
这段代码的含义是,将二号信号block,然后向该进程发送二号信号,同时打印该进程的pending位图
进程预先屏蔽二号信号,不断获取进程的pending位图并始终打印0000 0000 0000 0000
然后手动发送二号信号,不断获取pending位图01000 0000 0000 0000
2、信号处理时机
信号什么时候被处理?因为信号是被保存在进程PCB中的pending位图里面,要处理前面说到是在合适的时机处理,那么什么是合适的时机?
当进程从内核态返回到用户态的时候,对信号进行检测,处理动作
内核态:执行操作系统的代码和数据时所处的状态,操作系统的所有代码的执行全部在内核态
用户态:用户执行自己的代码和数据所处的状态,我们写的代码全部在用户态执行的
它们两个的本质区别在于:权限不同
在CPU中有一个CR3寄存器,它保存了当前进程的状态,它被置为0时是内核态,它被置为3时是用户态
前面我们知道:在32位系统下,[0G, 3G]是用户空间,[3G, 4G]是内核空间
在用户空间中,是有一个用户级页表,同时在内核中也有一个内核级页表,这也就说明:进程具有地址空间是能够看到用户和内核的所有内容,但是不一定能够被访问
内核级页表是被所有的进程所共享的,整个操作系统只有一份。
进程间无论如何切换,我们能够保证一定能找到同一个操作系统,因为我们每一个进程都有3G~4G内核空间,使用同一张内核页表
所谓的系统调用,就是进程的身份由用户态转化为内核态,然后根据内核页表找到系统函数,执行。
信号处理流程:
进程收到信号,放到PCB中的pending位图中,当进程从用户态切换到内核态之后,开始查找pending位图,寻找被pending位图中比特位为1的信号,然后查看该信号是否被block,没有被block,然后执行handler函数指针数组的方法,如果是自定义(捕获)就切换到用户态,执行handler函数,然后切换回内核态,接着遍历PCB中的三张表,如果还有信号重复上述过程,最后,返回到用户态的执行代码的下一条命令
内核态 < - > 用户态,不单单是发生在信号部分,进程的时间片到了,操作系统切换进程时,会从用户态到内核态,将该进程从操作系统上拔下来。当再次执行该进程时,操作系统会唤醒该进程,从内核态切换回用户态
3、进程为什么要切换成为用户态才进行信号的捕获方法?
操作系统在理论上能够直接执行用户代码,但是操作系统并不相信任何人,它不能直接执行用户代码,降低权限,切换回用户态在执行,因为用户可能会写一些恶意代码,使操作系统崩溃
4、sigaction
sigaction函数与signal类似,不过它的功能更加强大
它的第二个第三个参数与sigprocmask类似,第二个是输入型参数,第三个是输出型参数
不过它是sigaction结构体
这里添加一个补充知识:当进程处理一个信号时,操作系统会将处理的信号block,当处理完该信号,才会解除block。
结构体第一个成员就是signal的函数指针,sa_mask是用来设定需要额外屏蔽的信号
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "get a signal " << signo << std::endl;
}
int main()
{
struct sigaction act;
memset(&act, 0, sizeof(struct sigaction));
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);
sigaction(2, &act, nullptr);
while (true)
{
std::cout << "pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
五、可重入函数
先看一个场景:
我们实现了一个函数用来对链表进行头插
void push_front(ListNode* p)
{
head->_next = p;
head = p;
}
当我们执行完head->_next = p时,进程接收到信号,跳转到handler函数
void handler()
{
push_front(&node2);
}
执行完handler之后跳转回push_front函数的head = p; 这就会导致了内存泄漏
六、volatile
我们在这里通过信号的角度理解
int flag = 0;
void handler(int signo)
{
flag = 1;
std::cout << "change flag 0 to 1" << std::endl;
}
int main()
{
signal(2, handler);
while (!flag);
std::cout << "进程正常退出" << std::endl;
return 0;
}
我们这里的逻辑是将2号信号修改为将flag置为1,然后让进程正常退出
不过我们这里还要做一个特殊处理
signal:test.cpp
g++ -o $@ $^ -std=c++11 -O4
.PHONY:clean
clean:
rm -f signal
将g++的编译器优化等级调整到O4
这里发送2号信号没有将该进程终止,不过进程捕获到了该信号
这是为什么呢?
因为g++在main函数中没有找到修改flag的代码,所以将flag的值直接放到了寄存器中,而我们的handler函数是修改了内存中的flag,而实际上进程根本不会去内存中读,所以出现了这个问题
我们在flag前面加上关键字volatile,让进程每次都去内存中读数据
volatile int flag = 0;
七、SIGCHLD
void handler(int signo)
{
pid_t id = 0;
while((id == waitpid(-1, nullptr, WNOHANG)) > 0)
{
std::cout << "wait child success" << std::endl;
}
std::cout << "child process quit success" << std::endl;
}
int main()
{
signal(17, handler);
if(fork() == 0)
{
std::cout << "I am child " << std::endl;
sleep(3);
exit(1);
}
while(true)
{
std::cout << "I am parent" << std::endl;
sleep(1);
}
return 0;
}
另一种方法是将17号信号SIGCHLD信号屏蔽,当进程退出之后,自动释放僵尸进程
int main()
{
//signal(17, handler);
signal(17, SIG_IGN);
if(fork() == 0)
{
std::cout << "I am child " << std::endl;
sleep(3);
exit(1);
}
while(true)
{
std::cout << "I am parent" << std::endl;
sleep(1);
}
return 0;
}
子进程运行了几秒之后就不在打印证明子进程已经退出
总结
以上就是今天要讲的内容,本文仅仅简单介绍了进程间的信号