目录
一、信号保存
1.1 信号相关的概念名词
1.2 在内核中的表示
1.3 sigset_t与操作函数
1.4 信号设定
二、信号处理
2.1 内核空间与用户空间
2.2 内核态和用户态
2.3 信号的捕捉流程
2.4 sigaction 函数
三、可重入函数
四、volatile
五、SIGCHLD信号
一、信号保存
1.1 信号相关的概念名词
当信号产生时,信号的处理可选操作有:1.忽略;2.默认处理;3.自定义处理。
- 而实际执行信号的处理动作称为信号递达(Delivery)。
- 信号从产生到递达之间的状态,称为信号未决(Pending)。
- 进程可以选择阻塞(Block)某个信号。
- 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意,阻塞和忽略是不同的,只要信号被则阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
1.2 在内核中的表示
在内核中,信号的产生、处理与下面两张位图和一个函数指针数组有关,示意图如下:
block 和 pending 位图就表示着信号状态,接下来我们就来分析这些结构存在的意义以及相应的信号处理动作。
pending 位图
该位图称为 pending 信号集
- 用映射的比特位位置来表示对应的信号编号,用0和1来表示是否收到信号。
- OS就是通过修改pending表中对应的比特位来进行信号的发送。
block 位图
block 位图 称之为阻塞信号集,也称为当前进程的信号屏蔽字(Signal Mask),这里的屏蔽应理解为阻塞。
block 位图与peding相同,位图中的内容代表的含义是对应的信号是否被阻塞。
handler 数组
handler 数组被称为 handler 处理方法表。
handler 本质就是一个函数指针数组,其中存放着对应信号的默认处理函数。当 pending 位图中某个比特位被修改时,就会去对应的 handler 数组调用该函数进行信号的处理。
所以,signal函数的本质:
根据信号编号将数组下标处的处理函数换为我们自定义的函数。示意图如下:
而其中这些SIG_DEL、SIG_IGN是用于让OS进行信号判断的,确定接下来的处理动作。
(typedef void (*__sighandler_t) (int),__sighandler_t 被声明定义为函数指针)
步骤如下:
一个信号被处理,是怎样的一个处理过程呢?
- 发送信号:本质就是OS修改 pending 位图。
- 处理信号:检测 pending 位图是否有信号产生,再检查block位图判断该信号是否被阻塞,阻塞则不处理该信号;如果没有被阻塞再去对应的 handler 数组中判断是哪种处理方式,然后进行处理。
1.3 sigset_t与操作函数
是操作系统为我们提供的一种位图结构。
sigset_t位图只能通过系统调用接口进行操作,不允许用户自己的接口进行操作。
sigset的接口与普通位图操作非常相似,常用接口如下:
#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 所指向的信号集,使其中所有信号对应的比特位清零,表示该信号不包含任何有效信息。
- sigfillset函数:初始化 set 所指向的信号集,使其中所有信号对饮的比特位置1,表示该信号集的有效信号包括系统支持的所有信号。
- siaddset函数:在 set 所指向的信号集中添加某种有效信号。
- sigdelset函数:在 set 所指向的信号集中删除某种有效信号。
- sigismember函数:判断 set 所指向的信号集中是否包含某种信号,若包含则返回1,不包含则返回0,调用返回-1;
以上是 signet_t 的操作接口,那signet_t 能完成什么功能呢?
接下来我们再来学习一些接口:
sigpending 接口:
功能:
获取当前进程的 pending 信号集。即可以拿到内核中的pending位图
返回值:
成功返回0,失败返回-1,错误码被设置。
sigprocmask 接口:
功能:
检测并更改当前进程的 block信号集。
参数1: (进行以下哪种操作)
参数2:
参与操作的位图,与参数1搭配使用
参数3:
是一个输出型参数,返回修改前的位图结构,用于记录保存,不需要可以设为 NULL 。
返回值:
成功返回0,失败返回-1,错误码被设置。
1.4 信号设定
有了上面的一系列接口,我们可以就可以实现一些信号相关的问题:
- 如果我们对所有的信号都进行了自定义捕捉,那这个进程是不是就不能被任何信号终止?可以实现吗?结果如何?
- 如果将2号信号block,并且不断获取当前进程的pending信号集;此时我们突然发送2号信号,可以看到pending信号集中有一个比特位由0变为1吗?
- 如果对所有的信号进行block,那这个进程是不是就不能被任何信号终止?可以实现吗?结果如何?
关于问题一的代码:
void catchSig(int signum)
{
cout << "捕捉到一个信号" << signum << endl;
}
int main()
{
// 对所有信号进行自定义捕捉
for (int i = 1; i <= 31; i++)
{
signal(i, catchSig);
}
while (1)
sleep(1);
return 0;
}
结果如下:
结论:
- 无法实现不受信号的控制的进程,虽然我们尝试设定了所有信号的自定义捕捉方式,但是9号信号是管理员信号,无法进行自定义捕捉,所以该假设无法实现。
问题二:
int main()
{
// 1.定义信号集
sigset_t bset, obset, pending;
// 2.初始化
sigemptyset(&bset);
sigemptyset(&obset);
// 3.添加要进行屏蔽的信号---屏蔽2号信号
sigaddset(&bset, 2);
// 4.设置set到内核对应的block信号集中,(默认情况进程不会对任何信号进行block)
sigprocmask(SIG_BLOCK, &bset, &obset);
// 5.重复打印当前进程的pending 信号集
while (1)
{
// 初始化pending位图
sigemptyset(&pending);
// 将当前进程的pending信号集放置到pending位图中
sigpending(&pending);
// 打印pending位图.
for (int sig = 1; sig <= 31; sig++)
{
// 如果该位是1,则打印1,反之亦然
if (sigismember(&pending, sig))
cout << "1";
else
cout << "0";
}
cout << endl;
sleep(1);
}
return 0;
}
打印结果:
结论:
- 可以看到比特位由0置1,因为pending位图就是信号的记录位图,而我们使用系统调用接口实时地打印pengding位图的情况,就可以看到2号信号比特位由0置1。
问题三:
将所有信号都block,能实现不受信号控制的进程吗?
void blockSig(int sig)
{
sigset_t bset;
sigemptyset(&bset);
sigaddset(&bset, sig);
sigprocmask(SIG_BLOCK, &bset, nullptr);
}
int main()
{
// block所有信号
for (int sig = 1; sig <= 31; sig++)
{
blockSig(sig);
}
// 打印block信号表
sigset_t pending;
while (1)
{
sigpending(&pending);
for (int sig = 1; sig <= 31; sig++)
{
// 如果该位是1,则打印1,反之亦然
if (sigismember(&pending, sig))
cout << "1";
else
cout << "0";
}
cout << endl;
sleep(1);
}
return 0;
}
一个简单获取进程pid的命令:pidof + 进程名
接下来我们再写一个脚本,让脚本帮助我们发送1-32号信号
i=1; id=$(pidof mysignal); while [ $i -le 31 ] ; do kill -$i $id; echo "send signal $i" ; let i++; sleep 1; done
运行结果如下: (9号信号仍然不受影响)
接下来再写一段脚本,跳过发送9号信号。
i=1
id=$(pidof mysignal)
while [ $i -le 31 ]
do
if [ $i -eq 9 ];then
let i++
continue
fi
kill -$i $id
echo "kill -$i $id"
let i++
sleep 1
done
运行结果如下:
一个小现象是:19号和20号都是和暂停相关的信号,也是不允许阻塞的。
二、信号处理
2.1 内核空间与用户空间
每一个进程都有自己的进程地址空间,该进程地址空间由内核空间和用户空间构成:
- 用户代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
- 操作系统代码和数据存储在内核空间,通过内核级页表与物理内存之间建立映射关系。
其中,内核级页表是一个全局页表,它是用来维护操作系统的代码和进程之间的关系的。
因此,每个进程的进程地址空间中,用户空间是属于当前进程的,每个进程看到的代码和数据是完全不同的,但内核间所存放的都是操作系统的代码和数据,所有进程看到的都是一样的内容。
每个进程都能看到内核空间,但并不意味着每个进程都能随时对其进行访问。
那如何理解进程切换呢?
- 在当进程的进程地址空间中的内核空间,找到OS的代码和数据。
- 执行OS的代码,将当前进程的代码和数据剥离下来,并换上另一个进程的代码和数据。
当访问用户空间时处于用户态,而当你访问内核空间时必须处于内核态。
2.2 内核态和用户态
内核态与用户态:
- 内核态通常用来执行操作系统的代码,是一种权限非常高的状态。
- 用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。
进程收到信号后,并不是立即处理信号,而是在合适的时候,进行信号的处理。
这个合适的时候就是指从内核态切换回用户态的时候。
内核态和用户态之间是如何进行切换的?
从用户态切换为内核态通常有以下几种情况:
- 需要进行系统调用时。
- 当进程进行时间片轮转时,导致进程切换进入内核态。
- 产生异常、中断、陷阱等情况时。
从内核态切换为用户态通常有以下几种情况:
- 系统调用结束返回。
- 进程切换完毕。
- 异常、中断、陷阱处理完毕。
2.3 信号的捕捉流程
信号的捕捉流程其实可以被分为两种:
- 一种是系统直接执行默认或忽略动作;
- 另一种是执行我们的自定义动作;
默认处理或忽略处理时:
如果待处理信号的处理动作是默认或者忽略,则执行该信号的处理动作后清楚对应的pending标志位,如果没有新的信号进行递达,则直接返回用户态,从主控制流程中上次被中断的地方继续向下执行。
执行自定义动作:
如果待处理的信号是自定义捕捉的,即该信号的处理动作是用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理动作,执行完后再通过特殊的系统调用 sigretur 再次陷入内核并清除对应的 pending 位图标志位,如果没有新的信号要递达,就直接返回用户态,继续执行主流程的代码。
注意:
sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用与被调用的关系,是两个独立了的控制流程。
通俗理解自定义捕捉流程与结论总结:
结论:
其中该图形与直线有几个交点就代表在这期间有几次状态切换,而箭头的方向就代表着此次状态切换的方向,圆形中间的圆点就代表着检查 pending 位图表。
此时引入一个问题:
当识别到信号的处理动作是自定义时,能直接在内核态直接执行用户空间的代码吗?
- 理论上是可以的,因为内核态是一种权限非常高的状态,但是绝对不能这样设计。
- 如果允许内核态直接执行用户空间的代码,那么用户就可以在代码中设计一些非法操作,比如清空数据等,虽然用户态没有足够的权限做到清空数据,但是内核态有足够的权限能执行此类代码。
- 所以为了防止此类操作,操作系统不会在内核态下执行用户代码。因为操作系统无法保证用户的代码是合法代码,即操作系统不信任用户的行为。
2.4 sigaction 函数
捕捉信号除了前面用过的 signal 函数之外,我们还可以使用 sigaction 函数对信号进行捕捉:
功能:
检查并更改信号的处理动作。简而言之就是捕捉信号
参数:
参数1: 要自定义的信号编号,传入宏或信号编号。
参数2:输入型参数,传入信号的新处理方法。
参数3:输出型参数,返回信号旧的处理方法。
返回值:
成功返回0,失败返回-1,并设置错误码。
sigaciton 结构体:
成员1 (sa_handler):
将sa_handler赋值为常数SIG_IGN传给 sigaction 表示忽略信号,赋值为常数 SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数。
即回调函数,传入我们自定义的信号处理函数即可,例act.sa_handler=handler(handler是一个函数)。
成员2 (sa_sigaction):
实时信号的处理函数接口,因为暂时不处理实时信号,不用设置~
成员3 (sa_mask):
是一个sigset_t(系统位图)结构,直接使用sigemptyset(&act.sa_mask)清空即可。
成员4 (sa_flags):
与实时信号相关,暂时无关,设置为0即可。
成员5 (sa_restorer):
暂时不用设置~
接下来我们使用一下 sigaction 函数,目的如下:
使用 sigaction 捕捉2号信号,并查看handler数组中的处理动作是什么:
void handler(int signum)
{
cout << "获取了一个信号" << signum << endl;
}
int main()
{
struct sigaction act, oact;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
//设置自定义信号处理函数
act.sa_handler = handler;
// 设置进当前进程的pcb中
sigaction(2, &act, &oact);
cout << "default action " << (int)(oact.sa_handler) << endl;
while (1)
sleep(1);
return 0;
}
运行结果:
block位图的意义:
- 当某个信号的处理函数被调用时,内核自动将当前信号对应的 block 位图比特位置为1,表示阻塞,当信号处理函数返回时自动恢复原来的bloc位图状态,这样就保证了在处理莫格信号时,如果这个信号再次发生,那么它就会阻塞到当前处理结束为止。
- 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还系统自动屏蔽另外一些信号,就可以使用 sigaciton 结构体 中的 sa_mask 字段进行设置额外屏蔽的信号,则当一个信号发生时,这些被添加的信号再发生时,对应 block 位图的会被设置为1,而当信号处理函数返回时会自动恢复原来的状态,这便是sa_mask字段的作用以及block位图的意义。
接下来有一段代码可以验证sa_mask的作用:
设置了2号信号的自定义函数,并将sa_maks中设置3、4、5、6、7信号。
即,2号信号产生时,3、4、5、6、7信号的 block 位图被置1,通过打印pending位图,即使3、4、5、6、7号信号发生,这些信号也不会被处理。
代码如下:
// block位图的意义:
void showPending(sigset_t *pending)
{
for (int sig = 1; sig <= 31; sig++)
{
if (sigismember(pending, sig))
cout << "1";
else
cout << "0";
}
cout << endl;
}
void handler(int signum)
{
cout << "获取了一个信号: " << signum << endl;
cout << "获取了一个信号: " << signum << endl;
cout << "获取了一个信号: " << signum << endl;
cout << "获取了一个信号: " << signum << endl;
sigset_t pending;
int c = 20;
while (true)
{
// 获取pending位图并打印
sigpending(&pending);
showPending(&pending);
c--;
if (!c)
break;
sleep(1);
}
}
int main()
{
cout << "getpid: " << getpid() << endl;
// 内核数据类型,用户栈定义的
struct sigaction act, oact;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
// 设置自定义函数
act.sa_handler = handler;
// sa_maks中设置3、4、5、6、7信号,即2号信号发生会阻塞这些信号
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaddset(&act.sa_mask, 6);
sigaddset(&act.sa_mask, 7);
// 设置进当前调用进程的pcb中
sigaction(2, &act, &oact);
cout << "default action : " << (int)(oact.sa_handler) << endl;
while (true)
sleep(1);
return 0;
}
结果:
注意,信号捕捉,并没有创建新的进程或线程。
三、可重入函数
一个函数在一个时间段内被多个执行流重复进入,这种情况就叫做重入函数;
而重入时没有发生问题的叫可重入函数,会发生问题的叫做不可重入函数。可重入和不可重入是函数的一种特征,我们大部分编写的函数都是不可重入函数。
那什么特征的函数是不可重入函数?
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标志I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
- 比如函数用了全局数据, errno 错误码就是一个全局变量,大部分函数不可重入函数。
举一个链表结点的插入例子来形象理解可重入/不可重入:
如果是一个执行流该代码不会有什么问题,如果是一段时间被多个执行流反复跳转,则下面这个普通的链表插入结点都会产生错误。
四、volatile
volatile是C语言的一个关键字,该关键字的作用是保持内存的可见性。
首先我们写一段代码进行引入。
因为flag==0,所以main函数处于死循环状态,我们自定义设置2号信号的处理函数,当2号信号产生时我们将flag置为1,则主函数跳出死循环。
int flag = 0;
void changFlag(int signum)
{
cout << "change flag:" << flag;
flag = 1;
cout << "->" << flag << endl;
}
int main()
{
signal(2, changFlag);
while (!flag)
;
cout << "进程正常退出:" << flag << endl;
return 0;
}
我们使用的g++,有不同级别的优化选项:
接下来我们不使用默认的优化策略,设置优化选项为-O3,结果如下:
发现,使用Ctrl+C发送2号信号无法终止该进程。
必然是优化选项对flag做了特殊处理,导致该代码收到2号信号后仍无法终止。
原因如下:
因为我们对 flag 的频繁访问,编译器将flag放入了寄存器中。正常的优化是:需要检测flag时去内存中 将其读入寄存器然后进行检测,而2号信号产生时,改动了内存中!的flag,而寄存器中的flag没有被改动,所以检测时一直检测的寄存器中的flag,所以该死循环无法被终止。
总结一句话是,cpu无法看到内存中的flag了。
所以我们要使用关键字volatile显性地告诉编译器,不要将一些变量放入寄存器中,保持内存的可见性。
现在我们使用volatile修饰flag,再观察结果:
那这个优化是在编译时进行的还是执行时进行优化的呢?
编译时进行优化的,因为编译后,gcc让CPU将flag放入寄存器中,这个举动是在编译后就确定了,只不过是运行时才能体现出效果。
五、SIGCHLD信号
子进程暂停或退出时会主动向父进程发送SIGCHLD(17号)信号。而父进程对17号信号的默认处理动作是忽略。
- 为了避免出现僵尸进程,父进程需要使用wait或waitpid函数等待子进程结束,父进程可以阻塞等待子进程结束,也可以非阻塞地查询的是否有子进程结束等待清理,即轮询的方式。采用第一种方式,父进程阻塞就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。
- 其实,子进程在终止时会给父进程发生SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理动作,这样父进程就只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait或waitpid函数清理子进程即可。
首先我们写一段代码验证一下子进程退出是否会给父进程发送17号信号:
//子进程退出会向父进程发送信号
void handler(int signum)
{
cout << "子进程退出" << signum << endl;
}
int main()
{
signal(SIGCHLD, handler);
if (fork() == 0)
{
sleep(1);
exit(0);
}
while (1)
{
sleep(1);
}
}
SIGCHLD与waitpid使用场景:
那我们可以在捕捉信号中可以进行子进程的 等待wait 操作。那接下来,父进程下有10个子进程,
比如同时有5个子进程退出,位图中只有一个比特位记录退出的情况,而不会记录退出的子进程个数,所以我们要进行遍历检查10个子进程是否有退出的情况,而此时我们不能使用阻塞式等待,因为如果有一个子进程没有退出,那父进程就一直阻塞等待该进程退出了。
所以我们要使用while遍历所有的子进程,并使用waitpid采取非阻塞的方式进行等待。这样只要有子进程退出,父进程就会将该子进程回收,并且父进程自身不会阻塞。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
#include <sys/wait.h>
void handler(int signo)
{
printf("get a signal: %d\n", signo);
int ret = 0;
while ((ret = waitpid(-1, NULL, WNOHANG)) > 0){
printf("wait child %d success\n", ret);
}
}
int main()
{
signal(SIGCHLD, handler);
if (fork() == 0){
//child
printf("child is running, begin dead: %d\n", getpid());
sleep(3);
exit(1);
}
//father
while (1);
return 0;
}
接下来我们会设置一下对SIGCHLD的忽略动作,我们实现的忽略动作是用户层的,而默认的忽略是系统层的,两者会有一些区别。
子进程退出会对父进程发送信号,然后父进程执行默认的忽略。所以我们设置当子进程退出时,捕捉该信号,然后对子进程进行回收,
观察操作系统的忽略动作和用户层设定的忽略动作有何不同:
// 系统默认忽略动作:
int main()
{
if (fork() == 0)
{
cout << "child:" << getpid() << endl;
sleep(5);
exit(0);
}
while (1)
{
cout << "parent:" << getpid() << "father process:执行任务......" << endl;
sleep(1);
}
return 0;
}
脚本监视代码:
while :; do ps ajx | head -1 && ps axj | grep SIGCHLD | grep -v grep ; sleep 1; echo "--------------------------------"; done
操作系统默认的忽略动作现象(子进程处于僵尸状态):
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用signal或sigaction函数将SIGCHLD信号的处理动作设置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用signal或sigaction函数自定义的忽略通常是没有区别的,但这是一个特列。此方法对于Linux可用,但不保证在其他UNIX系统上都可用。
接下来就是我们设置当SIGCHLD信号产生时对SIGCHLD进行忽略(代码):
用户层设置对子进程退出SIGCHLD信号的忽略(子进程被回收):
由上面对比发现,OS默认的忽略就是忽略,不进行子进程僵尸状态的回收,而我们设置的忽略动作进行了僵尸状态的回收。
可以理解为操作系统的忽略和用户级的忽略程度不同。
操作系统的忽略就是什么都不做,即使子进程进入了僵尸状态也不做处理,如果我们设置了忽略操作,操作系统会先进行回收子进程,然后进行忽略,两者忽略的程度不一样。