目录
一、什么是信号
二、信号产生的条件
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是怎么检测到的呢?
硬件异常被硬件以某种方式被硬件检测到并通知内核
,
然后内核向当前进程发送适当的信号。例如当前进程执行了除以0
的指令
,CPU
的运算单元会产生异常
,
内核将这个异常解释 为
SIGFPE
信号发送给进程。再比如当前进程访问了非法内存地址,,MMU
会产生异常
,
内核将这个异常解释
SIGSEGV
信号发送给进程
程序中存在异常问题,导致我们收到信号退出
当程序崩溃的时候我们最想要知道的是崩溃的原因,及在哪一行崩溃了
崩溃的原因:该进程的父进程一定会通过进程等待的方式来获取子进程的退出状态和退出信号
在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
调用
alarm
函数可以设定一个闹钟
,
也就是告诉内核在
seconds
秒之后给当前进程发
SIGALRM
信号
,
该信号的默认处理动作是终止当前进程。
这个函数的返回值是
0
或者是以前设定的闹钟时间还余下的秒数。打个比方
,
某人要小睡一觉
,
设定闹钟为
30
分钟之后响,20
分钟后被人吵醒了
,
还想多睡一会儿
,
于是重新设定闹钟为
15
分钟之后响
,“
以前设定的闹钟时间还余下的时间
”
就是10
分钟。如果
seconds
值为
0,
表示取消以前设定的闹钟
,
函数的返回值仍然是以前设定的闹钟时间还余下的秒数
三、信号保存的方式
OS给进程发送信号 -> OS发送信号数据给task_struct -> 本质是OS向指定进程的task_struct中的信号位图写入比特位1,即完成信号的发送
信号的编号是有规律的[1, 31]
进程中,采用位图标识该进程是否收到信号
所谓的比特位的位置(第几个比特位),代表的就是哪一个信号比特位的内容(0,1),代表的就是是否收到信号
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作
递达-忽略 VS 阻塞 的区别
忽略是递达的一种方式,阻塞是没有被递达,它是独立状态
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里
四、信号处理的方式
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的实现细节是不一样的,不能让用户直接修改该变量,需要使用特定的函数
函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号。
函数sigfifillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
注意,在使用sigset_ t类型的变量之前,一定要调 用sigemptyset或sigfifillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,
出错返回
-1
。
sigismember
是一个布尔函数
,
用于判断一个信号集的有效信号中是否包含某种 信号,
若包含则返回
1,
不包含则返回
0,
出错返回
-1
。
在回到sigprocmask函数的参数,第二个参数是输入型参数,它是用来发送屏蔽信号的
第三个参数是输入型参数,它返回旧的block位图
这个函数不是对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; 这就会导致了内存泄漏
?
如果一个函数符合以下条件之一则是不可重入的:
调用了malloc或free,因为malloc也是用全局链表来管理堆的。
调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
六、volatile
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
进程一章讲过用
wait
和
waitpid
函数清理僵尸进程
,
父进程可以阻塞等待子进程结束
,
也可以非阻 塞地查询是否有子进程结束等待清理(
也就是轮询的方式
)
。采用第一种方式
,
父进程阻塞了就不 能处理自己的工作了
;
采用第二种方式
,
父进程在处理自己的工作的同时还要记得时不时地轮询一 下,
程序实现复杂。
其实
,
子进程在终止时会给父进程发
SIGCHLD
信号
,
该信号的默认处理动作是忽略
,
父进程可以自 定义
SIGCHLD
信号的处理函数,
这样父进程只需专心处理自己的工作
,
不必关心子进程了
,
子进程 终止时会通知父进程
,
父进程在信号处理函数中调用wait
清理子进程即可。
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;
}
子进程运行了几秒之后就不在打印证明子进程已经退出
?
总结
以上就是今天要讲的内容,本文仅仅简单介绍了进程间的信号
|