1. 信号的概念
1.1 生活角度的信号
1. 生活中的信号
- 红绿灯、下课铃声、狼烟、闹钟、防控劲爆、狼烟、快递电话。
2. 深入理解“红绿灯”
- 大家不妨思考一个问题,我们为什么认识红绿灯,什么叫做“认识”?
- 认识红绿灯,即我们知道,对应的灯亮了,意味着什么,要做什么。
- 我们之所以“认识”红绿灯,是因为有人告诉过我们。
- 所以,输出一个结论:信号没有产生的时候,其实我们已经知道,如何处理这个信号了。
3. 深入理解“快递电话”
- 当快递小哥给你打电话,通知你快递到了时,你可能正在打团,此时你没办法即使处理这个快递,只能打完团再去拿。
- 你并不清楚,快递小哥何时会打来电话。
- 所以,输出两个结论:
- 信号的到来,相对于我正在做的工作,是异步(互不影响)产生的。
- 信号产生了,我们不一定要立即处理它,而是合适的时候处理。这就要求我们要有一种能力,将已经到来的信号暂时保存。
“我们”就是进程。
1.2 技术应用角度的信号
1. 什么叫做信号?
- 信号是一种向目标进程发送通知消息的一种机制。
2. 使用kill -l查看系统中的信号列表
- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在
signal.h
中找到,例如其中有#define SIGINT 2
。
- 编号31以上的是实时信号,本章只讨论编号31及以下的普通信号,不讨论实时信号。这些信号各自在什么条件下产生,默认的处理动作是什么,在
signal(7)
中都有详细说明(使用指令man 7 signal
查看)。
- 细节:没有0号信号,0号位置用于标识进程正常退出。没有32和33号信号。
1.3 前台进程和后台进程
1. 进程在运行时,前台进程只能有一个,后台进程可以有多个
- 判断一个进程是前台进程还是后台进程,只需要看它有没有能力接收用户输入(因为键盘只有一个)。
ctrl + c
可以终止前台进程,无法终止后台进程。不过这只是一般情况,shell
大多数时候也是前台进程,但是ctrl + c
无法终止shell
自己(后面会讲,这是因为进程可以对信号做特殊处理)。- 前台进程不能被暂停(
ctrl + z
),如果被暂停,该前台进程必须立即被放到后台。因为前台进程只有一个,如果被暂停了,就没人来接收用户的信号了,OS就挂掉了。 - 当我们启动一个前台进程时,
shell
进程会被OS自动挂到后台;当我们想暂停一个前台进程时,shell
进程会被OS自动挂到前台,然后将被暂停的进程挂到后台。
2. 小实验,熟悉前后台切换的操作
- 实验用代码:
#include <stdio.h>
#include <unistd.h>
int main()
{
while(1)
{
printf("I am a process, pid: %d\n", getpid());
fflush(stdout);
sleep(1);
}
return 0;
}
./[可执行程序] &
可以将进程放到后台执行:
- 使用
jobs
指令,可以查看后台进程(每个后台进程都有一个任务编号)。
- 使用
fg [任务编号]
可以将后台进程放到前台运行。
ctrl + z
可以暂停当前进程,使用bg [任务编号]
可以将暂停的后台进程在后台重新启动。
1.4 中断
1. 问题引入
- 我们都知道,OS在一些情况下需要等待硬件资源就绪,比如说等待键盘输入。但是OS又是如何知道键盘上有数据输入了呢?
2. 解答上述问题
- 以前我们说过,CPU不直接和外设相连,现在我们要将这种说法纠正一下。在数据层面上,CPU是不直接和外设相连的,但是在控制信息层面上,CPU有一圈针脚,通过一个8259集成电路版,和外设相连(每一个针脚对应一个外设)。
- 这样一来,外设就可以直接向CPU发送信息了,我们把这样的信息称为中断信息。
- CPU上的针脚是有编号的,称为中断号。当键盘被按下时,键盘会向CPU发送光电信号,点亮对应的针脚。CPU会将这个针脚编号(中断号)记录下来,放在寄存器中,进而通知操作系统,该硬件已经就绪了。
3. 中断向量表
- 为了能够更快速的对外设进行响应,操作系统内部设置了一张中断向量表,本质就是一个指针数组。数组的下标就是对应设备的中断号,数组中存的是特定硬件的读取方法。
- 当一个外设就绪,向CPU发送中断信息后,OS会将手头的工作全停下来,然后读取中断号。之后拿着这个中断号,在中断向量表中进行索引,找到该硬件的读取方法,并执行它。
4. 信号的本质,其实就是用软件来模拟中断的行为
- 在收到信号之前,OS已经知道了如何处理这些信号,即OS“认识”这些信号。这些处理方法,同样被储存在一个函数指针数组中。信号的编号和数组的下标一一对应。
- 信号的处理方法有三种:
- a. 默认行为;
- b. 忽略;
- c. 自定义行为(信号捕捉)。
2. 信号的产生
2.1 认识接口signal
1. 作用
- 捕获信号,自定义处理方法。
2. 参数
signum
:信号编号。handler
:这是一个函数指针类型的数据,传我们自定义的信号处理方法。
3. 返回值
- 返回该信号的前一个处理方法,或者在出错时返回
SIG_ERR
。如果出现错误,errno
会被设置以指示原因。
2.2 通过键盘产生信号
1. 小实验
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handler(int signo)
{
std::cout << "signo: " << signo << std::endl;
exit(0);
}
int main()
{
signal(2, handler); // 捕获2号信号
while (true)
{
std::cout << "running... pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
- 编译并运行:
- 现象:当
ctrl + c
按下的一瞬间,进程收到了2号信号,并且执行了自定义handler
方法。
2. 实验结论
- 键盘可以通过组合键的方式,向前台进程发送信号。
当
ctrl + c
被按下时,CPU首先收到中断信息,得知键盘被按下了,进而读取键盘上的数据。OS对收到的数据进行解析,发现是ctrl + c
组合键,OS将其解释成2号信号,发送给当前的前台进程。触发自定义处理方法,最终exit
退出。
3. 键盘常见组合键对应的信号,及其默认处理方法
ctrl + c
:向前台进程发送2号信号SIGINT
,默认动作为终止进程;ctrl + \
:向前台进程发送3号信号SIGQUIT
,默认动作为让该进程退出;ctrl + z
:向前台进程发送20号信号SIGTSTP
,默认动作为暂停该进程,并将其挂到后台。
大家可以自己动手,捕获这些信号,做实验。
2.3 通过系统调用产生信号
1. 认识kill接口
- 作用:
- 向任意进程发送任意信号。
- 参数:
pid
:目标进程pid
;sig
:信号编号。
- 返回值:
- 成功返回0,失败返回-1。
2. 小实验:模拟实现kill
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <unistd.h>
// 输入非法时,提示使用方法
static void Usage(const std::string &proc)
{
std::cout << "\nUsage: " << proc << " -signumber process\n" << std::endl;
}
int main(int argc, char *argv[])
{
if (argc!=3)
{
Usage(argv[0]);
exit(0);
}
int signumber = std::stoi(argv[1]+1);
int processpid = std::stoi(argv[2]);
kill(processpid, signumber);
return 0;
}
3. raise接口
- 给自己发送指定信号,成功返回0,失败返回非0值。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handler(int signo)
{
std::cout << "get signo: " << signo << std::endl;
}
int main()
{
signal(2, handler);
while (true)
{
raise(2);
sleep(1);
}
return 0;
}
- 编译并运行:
- 进程不断给自己发送2号信号,并且由于我们自定义了2号信号的处理方法,所以即使收到了2号信号,
ctrl + c
了,该进程也不会终止。
4. abort函数(在3号手册中)
- 给自己发送6号信号,默认情况下,直接
abort
终止进程。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
void handler(int signo)
{
std::cout << "get signo: " << signo << std::endl;
}
int main()
{
signal(6, handler);
abort();
while (true)
{
std::cout << "running... pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
- 编译并运行:
- 发现6号信号虽然被捕捉了,我们也自定义了6号信号的处理方法,但是程序依然终止了。
2.4 异常
1. 做实验:模拟除0异常,观察现象
void handler(int signo)
{
std::cout << "get signo: " << signo << std::endl;
sleep(1);
}
int main()
{
signal(8, handler);
int a = 10;
a /= 0; // 除0错误
return 0;
}
- 编译并运行:
- 发现当前在死循环处理这个8号信号
SIGFPE
,这是为什么?
2. 理解异常的本质
a /= 0
的运算是在CPU内执行的,假设寄存器eax
存a
的值10,ebx
存操作数0,ecx
存运算结果。CPU内还有专门的状态寄存器status
来检测各硬件的状态,执行该/=
操作后,ecx
寄存器中的值明显溢出,该溢出状态被status
寄存器记录,修改溢出标记位为1。- 随后,CPU内部触发中断,将该溢出信息传给OS,OS将其解释为给目标进程发送对应异常信号,进而执行对应的处理方法。
- 寄存器中的内容只属于当前进程,不属于CPU。将当前异常进程直接干掉,就是OS默认处理异常的手段。这样下一个进程被调度时,会直接覆盖CPU内的进程上下文数据(其中就包括状态数据),异常信号也就被恢复了。
- 小实验中,之所以在死循环处理8号信号,是因为我们提供的自定义处理方法中,并没有将进程终止。这样一来,该进程就还会被调度。当该进程再次被调度时,其中的上下文状态数据还是异常的(溢出标志位一直是1),CPU就会又触发中断,通知操作系统来处理异常,如此循环往复,就形成了死循环。
异常的本质就是硬件异常,然后CPU触发中断,告知OS处理异常,中断进程。
3. 理解野指针异常
- 空指针指向0号地址,而0号地址在页表中,是不存在映射关系的,及没有对应的物理内存。虚拟地址到物理地址的转化,是通过CPU中的一个硬件,MMU实现的,当发生空指针的解引用问题时,MMU就会转化失败,找不到对应的物理内存,这时MMU就异常了,CPU也就要触发中断了。
- 野指针异常一般会报段错误(Segmentation fault),OS会向目标进程发送11号信号
SIGSEGV
。
2.5 由软件条件产生信号
SIGPIPE
就是一种由软件条件产生的信号,在“管道”中已经介绍过了。本节主要介绍alarm
函数和14号SIGALRM
信号。
1. alarm接口
- 调用
alarm
函数可以设定一个闹钟,也就是告诉内核在seconds
秒之后给当前进程发SIGALRM
信号, 该信号的默认处理动作是终止当前进程。
2. 实验一:外设有多慢
- 代码1:
int cnt = 0;
void handler(int signo)
{
std::cout << "get signo: " << signo << " cnt: " << cnt << std::endl;
exit(0);
}
int main()
{
alarm(1);
signal(14, handler);
while(true)
{
std::cout << "alarm: " << cnt++ << std::endl;
}
return 0;
}
- 编译并运行:
cnt
加到了9万多次。
- 代码2:
int cnt = 0;
void handler(int signo)
{
std::cout << "get signo: " << signo << " cnt: " << cnt << std::endl;
exit(0);
}
int main()
{
alarm(1);
signal(14, handler);
while(true)
{
cnt++;
}
return 0;
}
- 编译并运行:
cnt
直接加到了5亿多。
- 可见外设的速度有多慢,所以代码中应尽量减少和外设的交互。
3. 实验二:alarm的返回值
int n = 0;
void handler(int signo)
{
n = alarm(0); // 取消上一个闹钟,拿到剩余时间
std::cout << "get signo: " << signo << " n: " << n << std::endl;
exit(0);
}
int main()
{
std::cout << "pid: " << getpid() << std::endl;
n = alarm(30); // 设置一个30秒的闹钟
std::cout << "n: " << n << std::endl;
signal(14, handler);
while(true)
{}
return 0;
}
- 该函数的返回值是0,或上一个闹钟的剩余时间。
3. OS发送信号的底层原理(补充知识)
对于普通信号来讲,进程收到信号之后,会用位图来表示自己是否收到该信号。
- 比特位的位置决定信号编号;
- 比特位的内容决定是否收到信号(1表示收到,0表示没收到)。
这个位图存储在进程PCB中。
struct task_struct
{
// ...
// 例:0000 0010 收到2号信号
uint32_t sigmap; // 信号位图
}
OS向进程发送信号,本质就是修改PCB中的位图。 无论信号有多少种产生方式,永远只能让OS向目标进程发送信号。这是因为,OS是进程的管理者。
每一个进程还都有一个自己的函数指针数组,数组的下标和信号编号强相关。
4. Core Dump 核心转储
当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁
盘上,文件名通常是core.[pid]
,这叫做Core Dump。进程异常终止通常是因为有Bug
,比如非法内存访问导致段错误,事后可以用调试器检查core
文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。
使用ulimit -a
来查看系统中的相关配置:
当前服务器默认不允许产生core
文件,core
文件大小设置为0。可以使用ulimit -c [szie]
来修改core
的文件大小。这个修改是内存级的,退出再登入它就复原了,并且只在当前窗口有效。
将当前shell
的core file size
改为10240,打开生成core
文件功能。
编译并运行如下会产生异常的代码,生成core
文件:
int main()
{
int a = 10;
a /= 0;
return 0;
}
core
文件的使用:
- 先编译形成可调试的可执行程序,然后使用gdb调试生成的可执行程序,使用
core-file
指令,刚刚生成的core
文件加载进入,就可以看到出错的位置了。
为什么Core Dump功能默认是关闭的?故事时刻:
- 一般而言,部署在服务器上的服务一旦自己挂掉了,是会有对应的软件,帮助该服务重启的。因为有一些服务,我们希望他24小时在线,即使挂掉了,也要快速重启。
- 大家可以看到,
core
文件是很大的,如果一个比较挫的程序员,写了一个很挫的服务,动不动就挂掉,然后该服务还被一直重启。此时就会生成一大堆的core
文件,一晚上过后直接把磁盘打满,服务器最后也挂掉了。 - 所以云服务器上,Core Dump的功能默认是关闭的。
5. 信号的保存
5.1 信号其他相关常见概念
1. 实际执行信号的处理动作,称为信号递达(Delivery)
- 实际的处理动作又可以细分成三种:
- 信号的忽略;
- 信号的默认处理动作;
- 信号的自定义捕捉。
- 以处理2号信号为例,上代码:
signal(2, SIG_IGN); // 忽略2号信号
signal(2, SIG_DFL); // 以默认动作处理2号信号
signal(2, handler); // handler是自定义方法,用自定义方法处理2号信号
- 其中,
SIG_DFL
和SIG_IGN
为两个宏,其定义如下:
#define SIG_DFL ((__sighandler_t) 0) /* Default action. */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */
忽略信号,也是对信号的一种处理,处理动作就是忽略他。
2. 信号从产生到递达之间的状态,称为信号未决(Pending)
- 将信号存在信号位图中,就叫信号未决。
3. 进程可以选择阻塞(Block)某个信号
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
5.2 信号在内核中的表示
task_struct
中存了三张表,分别是阻塞block
表,未决pending
表,函数指针表handler
。数组下标对应信号编号,看对应信号的状态时,要横着看。
每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志(bit位由0置1),直到信号递达才清除该标志。
在上图的例子中:
- SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数
sighandler
。
如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?
- POSIX.1允许系统递送该信号一次或多次。
- Linux是这样实现的:普通信号(31号及之前)在递达之前产生多次只计一次,而实时信号(31号之后)在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号。
5.3 信号集操作
1. sigset_t类型
sigset_t
本质上就是一个位图,但是对于使用者来说,只需要把它当成是一种数据类型即可。sigset_t
称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
2. 信号集操作函数
sigset_t
类型对于每种信号用一个bit
表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit
则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t
变量,而不应该对它的内部数据做任何解释。
#include <signal.h>
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
- 函数
sigemptyset
初始化set
所指向的信号集,使其中所有信号的对应bit
清零,表示该信号集不包含任何有效信号。 - 函数
sigfillset
初始化set
所指向的信号集,使其中所有信号的对应bit
置1,表示该信号集的有效信号包括系统支持的所有信号。注意,在使用sigset_ t
类型的变量之前,一定要调用sigemptyset
或sigfillset
做初始化,使信号集处于确定的状态。 - 初始化
sigset_t
变量之后就可以再调用sigaddset
和sigdelset
在该信号集中添加或删除某种有效信号。 sigismember
可以判断当前信号集set
中,是否有signo
信号。
前四个函数都是成功返回0,出错返回-1。sigismember
是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
3. sigprocmask
- 调用函数
sigprocmask
可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值:若成功则为0,若出错则为-1
- 如果
oset
是非空指针,则读取进程的当前信号屏蔽字通过oset
参数传出。如果set
是非空指针,则更改进程的信号屏蔽字,参数how
指示如何更改。如果oset
和set
都是非空指针,则先将原来的信号屏蔽字备份到oset
里,然后根据set
和how
参数更改信号屏蔽字。 - 假设当前的信号屏蔽字为
mask
,下表说明了how
参数的可选值。
如果调用
sigprocmask
解除了对当前若干个未决信号的阻塞,则在sigprocmask
返回前,至少将其中一个信号递达。
- 实验:屏蔽2号信号。
- 如果2号信号被屏蔽成功了,则
handler
方法无法执行。
- 如果2号信号被屏蔽成功了,则
void handler(int signal)
{
std::cout << "signal: " << signal << std::endl;
}
int main()
{
signal(2, handler);
sigset_t block, oblock;
// 初始化信号集
sigemptyset(&block);
sigemptyset(&oblock);
sigaddset(&block, 2); // 将2号信号添加进信号集
sigprocmask(SIG_BLOCK, &block, &oblock); // 屏蔽2号信号
while(true)
{
std::cout << "I am running..." << std::endl;
sleep(1);
}
return 0;
}
有一些信号是无法被屏蔽的,例如9号无法被屏蔽。
4. sigpending
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过set参数传出。调用成功则返回0,出错则返回-1。
- 小实验:打印当前进程
pending
表。
void handler(int signal)
{
std::cout << "signal: " << signal << std::endl;
}
void PrintPending(const sigset_t &pending)
{
for(int signo = 31; signo > 0; signo--)
{
if (sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
int main()
{
std::cout << "pid: " << getpid() << std::endl;
signal(2, handler);
// 1. 屏蔽2号信号
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2);
sigprocmask(SIG_BLOCK, &set, &oset);
// 2. 让进程不断获取当前进程的pending表
int cnt = 0;
sigset_t pending;
while(true)
{
sigpending(&pending);
PrintPending(pending);
sleep(1);
cnt++;
if(cnt==10)
{
std::cout << "解除对2号信号的屏蔽,2号信号准备递达" << std::endl;
sigprocmask(SIG_SETMASK, &oset, nullptr);
}
}
return 0;
}
pending
表刚开始为全0,没有收到信号。后来我们给该进程发送了2号信号,但是2号信号被阻塞,此时pending
表中2号信号的位置由0置1。程序运行10秒钟后,解除对2号信号的阻塞,2号信号被递达,pending
表由1置0。
如果一个信号被递达了,那么在该信号被处理之前,
pending
表中的位图就会被修改。
6. 信号的处理
6.1 用户态和内核态
1. 进程如何找到操作系统的代码?
- 之前我们所学习的程序地址空间,都是用户空间。今天我们就来认识一下内核空间。
- 进程要想使用系统调用,就需要访问OS代码。每个进程都有1GB的内核地址空间,这一部分空间和通过内核级页表,映射到OS代码在物理内存中的位置。
- 就如同曾经的库函数调用一样,调用系统调用接口,也是在进程的地址空间中进行跳转的。但是这个跳转过程,涉及用户态和内核态身份的变换。
- 内核级页表在整个OS中仅有一份,所有进程共用一份内核级页表。所以无论进程如何调度,CPU都可以通过进程地址空间的内核空间,直接找到OS。
- 用户态:只能访问自己的【0,3GB】。
- 内核态:可以让用户以OS的身份访问到操作系统的【3,4GB】。
2. 如何控制用户态和内核态的身份变换?
- CPU一些寄存器(如CS寄存器)的最后两个
bit
位,保存了该进程是出于用户态还是内核态。2bit
一共有4个值,但是我们只使用两位,1表示内核态,3表示用户态。切换用户态和内核态只需要修改这两个bit
位即可。 - CPU中还有一个CR3寄存器,它保存的是当前进程的一些页表信息(存的直接是页表的物理地址)。
- CR1保存的是最后一次引发缺页中断的地址。
3. 进程从内核态返回用户态时,进行信号的检测和处理(下面模拟了一次信号检测和处理的流程)
- 用户态是一种受控的状态,能够访问的资源是有限的;内核态是一种操作系统的工作状态,能够访问大部分的系统资源。系统调用的背后,就隐藏了身份的变化。
- 先在用户态执行用户代码,遇到系统调用后,切换为内核态,进入内核。主要任务还是执行系统调用,在系统调用返回时,会顺便做一次信号的检测和处理。
- 进入内核态后,可以访问到进程关于信号的那三张表。先看
pending
表,如果pending
表为0,表示没有收到该信号,直接返回。如果pending
表为1,需要再看block
表,为1就直接返回,为0就再看handler
表。大多数默认处理方法都是终止进程,对于处于内核态的进程来说,这很好实现。如果处理方法是SIG_IGN
忽略,则直接将pending
表由1置0即可。最难处理的是自定义方法。 - 大家思考一个问题,进程是以用户态还是内核态执行自定义方法?答案是用户态,因为操作系统不信任任何用户。自定义方法是用户自己定义的,万一在里面写了一些越权行为,以内核态去执行该方法,就是一个巨大的
bug
。所以进程在执行自定义方法之前,要先切换为用户态。 - 当进程执行完自定义方法之后,无法直接留在用户态,继续向后执行代码。因为该
handler
方法根本不知道程序已经运行到哪一行了,以及一些在内核态才能看到的信息。所以要先通过系统调用sigreturn
返回内核态,拿到相关信息后,再由内核态返回用户态,继续向后执行代码。
4. 总结
- 信号检测和处理时,一共进行了四次用户态和内核态之间的切换。
5. 要是不调用系统调用,是不是就不会发生用户态和内核态之间的切换?那么信号的检测是否就无法发生?
- 进程是会不断被调度的,一旦一个进程被从CPU上剥离下来了,再想重新调度它时,就需要将之前的上下文信息交给CPU,这是内核态。然后再回到用户态,执行自己的代码。
- 结论:无论进程是否调用系统调用,整个进程的生命周期里,一定会涉及非常多次的进程间切换,一旦切换了,就一定会涉及到从内核态返回到用户态,所以当前进程,依旧有无数次机会,进行信号的捕捉处理。
6.2 sigaction
1. sigaction函数的介绍
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
-
sigaction
函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1。signo
是指定信号的编号。若act
指针非空,则根据act
修改该信号的处理动作。若oact
指针非 空,则通过oact
传出该信号原来的处理动作。 -
act
和oact
指向sigaction
结构体:
struct sigaction
{
void (*sa_handler)(int);
void (*sa_sigaction)(int, siginfo_t *, void *);
sigset_t sa_mask;
int sa_flags;
void (*sa_restorer)(void);
};
- 将
sa_handler
赋值为常数SIG_IGN
传给sigaction
表示忽略信号,赋值为常数SIG_DFL
表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void
,可以带一个int
参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main
函数调用,而是被系统所调用。 - 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
- 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用
sa_mask
字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。sa_flags
字段包含一些选项,本章的代码都把sa_flags
设为0,sa_sigaction
是实时信号的处理函数,本章不详细解释这两个字段。
2. 小实验:自定义处理2号信号
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "get a sig: " << signo << std::endl;
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
sigaction(2, &act, &oact);
while(1) sleep(1);
return 0;
}
- 编译并运行:
3. 验证对当前信号的自动屏蔽功能
#include <iostream>
#include <unistd.h>
#include <signal.h>
void Print(const sigset_t& pending)
{
for (int signo = 31; signo > 0; signo--)
{
if (sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
void handler(int signo)
{
std::cout << "get a sig: " << signo << std::endl;
while(1)
{
sigset_t pending;
sigpending(&pending);
Print(pending);
sleep(1);
}
}
int main()
{
std::cout << "pid: " << getpid() << std::endl;
struct sigaction act, oact;
act.sa_handler = handler;
sigaction(2, &act, &oact);
while(1) sleep(1);
return 0;
}
- 编译并运行,运行几秒后不断向进程发送2号信号:
- 可以发现,调用信号处理函数时,再次向进程发送该信号,
pending
表中该信号的值会变为1,说明收到了该信号,并且该信号被阻塞了,无法递达。 - OS不允许对同一个信号嵌套处理,但是在处理2号信号的同时,不影响3号信号的处理。
3. 验证sa_mask对其他信号的屏蔽
#include <iostream>
#include <unistd.h>
#include <signal.h>
void Print(const sigset_t& pending)
{
for (int signo = 31; signo > 0; signo--)
{
if (sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
void handler(int signo)
{
std::cout << "get a sig: " << signo << std::endl;
while(1)
{
sigset_t pending;
sigpending(&pending);
Print(pending);
sleep(1);
}
}
int main()
{
std::cout << "pid: " << getpid() << std::endl;
struct sigaction act, oact;
act.sa_handler = handler;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3); // 将3号信号加入sa_mask
sigaction(2, &act, &oact);
while(1) sleep(1);
return 0;
}
6.3 如何处理同时收到多个信号的情况?
写一段代码,让进程先屏蔽2,3,4,5
号信号,然后过20秒后再解除屏蔽。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signo)
{
std::cout << "get a sig: " << signo << std::endl;
sleep(1);
}
void Print(const sigset_t& pending)
{
for (int signo = 31; signo > 0; signo--)
{
if (sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
int main()
{
std::cout << "pid: " << getpid() << std::endl;
signal(2, handler);
signal(3, handler);
signal(4, handler);
signal(5, handler);
sigset_t mask, omask;
sigemptyset(&mask);
sigemptyset(&omask);
sigaddset(&mask, 2);
sigaddset(&mask, 3);
sigaddset(&mask, 4);
sigaddset(&mask, 5);
sigprocmask(SIG_SETMASK, &mask, &omask);
int cnt = 20;
while(true)
{
sigset_t pending;
sigpending(&pending);
Print(pending);
cnt--;
sleep(1);
if (cnt == 0)
{
sigprocmask(SIG_SETMASK, &omask, nullptr);
std::cout << "cancel 2, 3, 4, 5 block" << std::endl;
}
}
return 0;
}
解除屏蔽之前,给该进程一次性发送2,3,4,5
信号,观察现象:
可以发现,当一次性收到多个信号时,即pending
表中有多个位置值为1时,需要先处理完所有的信号,再继续向后执行代码。信号的处理是有优先级的,并不是先收到哪个信号就先处理哪个,这个我们不做研究。
拿这个图来说,收到多个信号时,就是在右边那个圈里多转几圈,把所有信号处理完后再继续执行代码。
7. 信号的其他补充问题
7.1 可重入函数
main
函数调用insert
函数向一个链表head
中插入节点node1
,插入操作分为两步,刚做完第一步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler
函数,sighandler
也调用insert
函数向同一个链表head
中插入节点node2
,插入操作的两步都做完之后从sighandler
返回内核态,再次回到用户态就从main
函数调用的insert
函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main
函数和sighandler
先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert
函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert
函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了
malloc
或free
,因为malloc
也是用全局链表来管理堆的。 - 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用了全局数据结构。
7.2 volatile关键字
1. 写代码,看实验现象
#include <iostream>
#include <signal.h>
#include <unistd.h>
int flag = 0;
void handler(int signo)
{
std::cout << "signo: " << signo << std::endl;
flag = 1;
std::cout << "change flag to: " << flag << std::endl;
}
int main()
{
signal(2, handler);
std::cout << "getpid: " << getpid() << std::endl;
while(!flag);
std::cout << "quit normal!" << std::endl;
}
-
有些同学可能多少了解过,编译器是会对代码做优化的,一般的优化级别是
-O0
,默认不做任何优化。但是更高的优化级别还有-O1
和-O2
,我们来分别使用不同的优化级别,跑一下上述代码: -
使用
-O0
级别的优化,进程在收到2号信号后,修改flag
,正常退出。
- 使用
-O1
级别的优化,进程在收到2号信号后,也确实修改了flag
,但是死循环卡住了,这是为什么?
2. 解释
- 优化情况下,键入
CTRL-C
,2号信号被捕捉,执行自定义动作,修改flag=1
,但是while
条件依旧满足,进程继续运行!但是很明显flag
肯定已经被修改了,为何循环依旧执行? - 因为,
while
循环检查的flag
,并不是内存中最新的flag
。不做优化的情况下,CPU会一直访问内存,将内存中flag
的值更新到寄存器中,进行逻辑判断。但是一旦做了优化,CPU发现main
函数中,没有人对flag
变量做修改,所以它就不访问内存了更新寄存器中的值了,而是每次根据寄存器中旧的flag
值,做逻辑判断。
- 如此一来,就在CPU和内存之间,形成了一道内存屏障。为了避免这种现象,防止编译器进行过度的优化,就设计出了
volatile
关键字。
3. volatile保持内存可见性
#include <iostream>
#include <signal.h>
#include <unistd.h>
volatile int flag = 0;
void handler(int signo)
{
std::cout << "signo: " << signo << std::endl;
flag = 1;
std::cout << "change flag to: " << flag << std::endl;
}
int main()
{
signal(2, handler);
std::cout << "getpid: " << getpid() << std::endl;
while(!flag);
std::cout << "quit normal!" << std::endl;
}
volatile
作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
7.3 SIGCHLD信号
1. 验证SIGCHLD信号的存在
- 子进程退出时,是要给父进程发送信号的。
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void handler(int signo)
{
std::cout << "get a signo: " << signo << std::endl;
}
int main()
{
signal(SIGCHLD, handler);
pid_t id = fork();
if (id == 0)
{
std::cout << "child running...." << std::endl;
sleep(5);
std::cout << "child exit" << std::endl;
exit(0);
}
// 不能直接写sleep(10);
int cnt = 10;
while(cnt--)
{
std::cout << "cnt: " << cnt << std::endl;
sleep(1);
}
waitpid(-1, nullptr, 0);
return 0;
}
- 子进程先跑五秒后退出,父进程跑十秒。可以看到,子进程退出后,父进程收到了来自子进程的17号信号。
小细节:想让父进程休眠10秒,不能直接写
sleep(10);
,因为处理信号的动作,会自动中断休眠。只能分10次一秒一秒的休眠,这样它只会中断十次中的一秒,影响不大。
2. 基于SIGCHLD信号回收子进程
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
void handler(int signo)
{
std::cout << "get a signo: " << signo << std::endl;
pid_t id = 0;
while ((id = waitpid(-1, nullptr, WNOHANG)) > 0)
{
std::cout << "回收进程:" << id << std::endl;
}
}
int main()
{
signal(SIGCHLD, handler);
for (int i = 0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
std::cout << "child is running..." << std::endl;
sleep(5);
exit(0);
}
}
while(true) sleep(1); // 父进程不退出
return 0;
}
- 细节:
- 我们一般会遇到多进程的情况,并且这多个子进程,可能同时结束。这就会导致信号之间出现覆盖,父进程只收到一个
SIGCHLD
信号,而wait
需要执行多次。 所以在handler
方法中,需要循环执行waitpid
,直到没有子进程,waitpid
返回-1,循环结束。 - 还有一种情况,多个子进程中,只退出一部分,还有一部分永远不退出。此时如果
waitpid
采取阻塞等待的方式,将会让父进程卡死在handler
方法中,所以我们给waipid
传WONHANG
,让其以非阻塞方式等待。
- 我们一般会遇到多进程的情况,并且这多个子进程,可能同时结束。这就会导致信号之间出现覆盖,父进程只收到一个
3. Linux支持手动忽略SIGCHLD信号,不需要手动等待子进程
- 事实上,由于
UNIX
的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction
将SIGCHLD
的处理动作置为SIG_IGN
,这样fork
出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction
函数自定义的忽略通常是没有区别的,但这是一个特例。 - 此方法对于Linux可用,但不保证在其它UNIX系统上都可用。请编写程序验证这样做不会产生僵尸进程。
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
// Linux支持手动忽略SIGCHLD信号,所有的子进程都不需要父进程等待了,退出时自动回收z状态
signal(SIGCHLD, SIG_IGN);
for (int i =0; i < 10; i++)
{
pid_t id = fork();
if (id == 0)
{
std::cout << "child is running..." << std::endl;
sleep(5);
exit(0);
}
}
while(true) sleep(1); // 父进程不退出
return 0;
}