目录
1.信号的处理时机
在学习了信号的产生、阻塞与递达的机制后,我们接下来要讨论的是 信号的处理时机。信号的处理时机对于信号的响应和进程的稳定性有着直接影响。让我们详细解析这一部分内容。
1.1、信号处理情况
普通情况
在 普通情况 下,当信号没有被阻塞时,信号产生后会记录未决信息,但不会被立即递达或处理。进程会在 合适的时机 对信号进行处理。
特殊情况
在 特殊情况 下,信号被阻塞。当信号产生时,会记录未决信息,但信号不会立即递达。只有当信号的阻塞被解除时,信号才会递达并进行处理。就像气球被堵住空气无法释放,只有气球爆裂后,空气才会释放一样。
1.2、“合适” 的时机
信号的产生是 异步 的,即信号可能随时产生,而进程可能正在处理其他更重要的任务。为了避免信号立即中断进程正在做的工作(如 I/O 操作),信号需要在一个合适的时机进行处理。
合适的时机 发生在进程从 内核态 返回 用户态 时。这是信号的检测和处理时机。
2、用户态与内核态
信号的处理时机和 用户态 与 内核态 之间的转换密切相关,理解这两者的概念对于深入理解信号的处理至关重要。
2.1、用户态与内核态的概念
用户态:执行用户编写的代码时,CPU 处于用户态。
内核态:执行操作系统代码时,CPU 处于内核态。操作系统的内核代码负责进程调度、系统调用、异常处理中断、文件管理等任务。
用户态和内核态是两种不同的状态,它们之间会发生相互切换。当进程需要执行系统调用、发生异常或处理中断时,它会从用户态切换到内核态。
用户态切换为内核态:
进程时间片到期,需要进行进程切换。
调用系统调用(如
open
、read
、write
)。发生异常、中断或陷阱。
内核态切换为用户态:
进程切换完毕后,操作系统返回到用户态,继续执行用户进程。
系统调用处理完成后,返回用户态。
异常、中断等处理完毕后,恢复用户态。
2.2、重谈进程地址空间
首先简单回顾下 进程地址空间 的相关知识:
- 进程地址空间 是虚拟的,依靠 页表+MMU机制 与真实的地址空间建立映射关系
- 每个进程都有自己的 进程地址空间,不同 进程地址空间 中地址可能冲突,但实际上地址是独立的
- 进程地址空间 可以让进程以统一的视角看待自己的代码和数据
不难发现,在 进程地址空间 中,存在 1 GB 的 内核空间,每个进程都有,而这 1 GB 的空间中存储的就是 操作系统 相关 代码 和 数据,并且这块区域采用 内核级页表 与 真实地址空间 进行映射
为什么要区分 用户态 与 内核态 ?
- 内核空间中存储的可是操作系统的代码和数据,权限非常高,绝不允许随便一个进程对其造成影响
- 区域的合理划分也是为了更好的进行管理
所谓的 执行操作系统的代码及系统调用,就是在使用这 1 GB 的内核空间
进程间具有独立性,比如存在用户空间中的代码和数据是不同的,难道多个进程需要存储多份 操作系统的代码和数据 吗?
- 当然不用,内核空间比较特殊,所有进程最终映射的都是同一块区域,也就是说,进程只是将 操作系统代码和数据 映射入自己的 进程地址空间 而已
- 而 内核级页表 不同于 用户级页表,专注于对 操作系统代码和数据 进行映射,是很特殊的
当我们执行诸如 open 这类的 系统调用 时,会跑到 内核空间 中调用对应的函数
而 跑到内核空间 就是 用户态 切换为 内核态 了(用户空间切换至内核空间)
这个 跑到 是如何实现的呢?
在 CPU 中,存在一个 CR3 寄存器,这个 寄存器 的作用就是用来表征当前处于 用户态 还是 内核态
- 当寄存器中的值为 3 时:表示正在执行用户的代码,也就是处于 用户态
- 当寄存器中的值为 0 时:表示正在执行操作系统的代码,也就是处于 内核态
通过一个 寄存器,表征当前所处的 状态,修改其中的 值,就可以表示不同的 状态,这是很聪明的做法
重谈 进程地址空间 后,得到以下结论
- 所有进程的用户空间 [0, 3] GB 是不一样的,并且每个进程都要有自己的 用户级页表 进行不同的映射
- 所有进程的内核空间 [3, 4] GB 是一样的,每个进程都可以看到同一张内核级页表,从而进行统一的映射,看到同一个 操作系统
- 操作系统运行 的本质其实就是在该进程的 内核空间内运行的(最终映射的都是同一块区域)
- 系统调用 的本质其实就是在调用库中对应的方法后,通过内核空间中的地址进行跳转调用
那么进程又是如何被调度的呢?
1.操作系统的本质
- 操作系统也是软件啊,并且是一个死循环式等待指令的软件
- 存在一个硬件:操作系统时钟硬件,每隔一段时间向操作系统发送时钟中断2.进程被调度,就意味着它的时间片到了,操作系统会通过时钟中断,检测到是哪一个进程的时间片到了,然后通过系统调用函数 schedule() 保存进程的上下文数据,然后选择合适的进程去运行
2.3、信号的处理过程
当在 内核态 完成某种任务后,需要切回 用户态,此时就可以对信号进行 检测 并 处理 了
情况1:信号被阻塞,信号产生/未产生
信号都被阻塞了,也就不需要处理信号,此时不用管,直接切回 用户态 就行了
下面的情况都是基于 信号未被阻塞 且 信号已产生 的前提
情况2:当前信号的执行动作为 默认
大多数信号的默认执行动作都是 终止 进程,此时只需要把对应的进程干掉,然后切回 用户态 就行了
情况3:当前信号的执行动作为 忽略
当信号执行动作为 忽略 时,不做出任何动作,直接返回 用户态
情况4:当前信号的执行动作为 用户自定义
这种情况就比较麻烦了,用户自定义的动作位于 用户态 中,也就是说,需要先切回 用户态,把动作完成了,重新坠入 内核态,最后才能带着进程的上下文相关数据,返回 用户态
在 内核态 中,也可以直接执行 自定义动作,为什么还要切回 用户态 执行自定义动作?
- 因为在 内核态 可以访问操作系统的代码和数据,自定义动作 可能干出危害操作系统的事
- 在 用户态 中可以减少影响,并且可以做到溯源
为什么不在执行完 自定义动作 直接后返回进程?
- 因为 自定义动作 和 待返回的进程 属于不同的堆栈,是无法返回的
- 并且进程的上下文数据还在内核态中,所以需要先坠入内核态,才能正确返回用户态
注意: 用户自定义的动作,需要先切换至 用户态 中执行,执行结束后,还需要坠入 内核态
通过一张图快速记录信号的 处理 过程
3. 信号的捕捉
3.1 内核如何实现信号的捕捉
当信号到达进程时,如果信号的执行动作是用户自定义的(即信号捕捉),内核会在适当时机进入用户态执行自定义动作。由于信号和待返回的函数属于不同的堆栈空间,它们之间是独立的执行流,所以在执行用户自定义动作时,内核需要先进入内核态(通过 sigreturn()
),然后再返回用户态(通过 sys_sigreturn()
)以完成信号处理。
3.2 sigaction
sigaction
是处理信号的更强大函数,相比 signal
函数,它提供了更多的功能,允许用户设置信号的处理方式。
sigaction
结构体包含几个字段,其中最重要的是sa_handler
,它指向一个用户定义的信号处理函数。sa_mask
是一个信号集,它指定了在执行信号处理函数时需要屏蔽的信号。也就是说,信号处理函数在执行过程中,可以选择阻止某些信号的到达,直到处理完成。
下面是 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); // 用于实时信号,不用管
};
返回值:成功返回 0
,失败返回 -1
并将错误码设置
参数1:待操作的信号
参数2:sigaction
结构体,具体成员如上所示
参数3:保存修改前进程的 sigaction
结构体信息
这个函数的主要看点是 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); //实时信号相关,不用管
};
sigaction
主要通过设置 sa_mask
来阻塞某些信号的递达,直到当前信号的处理完成。这样可以避免信号之间的干扰。
#include <iostream>
#include <cassert>
#include <cstring>
#include <signal.h>
#include <unistd.h>
using namespace std;
static void DisplayPending(const sigset_t pending) {
cout << "当前进程的 pending 表为: ";
int i = 1;
while (i < 32) {
if (sigismember(&pending, i))
cout << "1";
else
cout << "0";
i++;
}
cout << endl;
}
static void handler(int signo) {
cout << signo << "号信号确实递达了" << endl;
int n = 10;
while (n--) {
sigset_t pending;
sigemptyset(&pending);
int ret = sigpending(&pending);
assert(ret == 0);
DisplayPending(pending);
sleep(1);
}
}
int main() {
cout << "当前进程: " << getpid() << endl;
struct sigaction act, oldact;
memset(&act, 0, sizeof(act));
memset(&oldact, 0, sizeof(oldact));
act.sa_handler = handler;
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaction(2, &act, &oldact);
while (true); // 死循环
return 0;
}
运行结果
在程序中注册了一个自定义的信号处理函数 handler
,当信号 2 到达时,它会触发这个函数。在函数执行过程中,信号集 sa_mask
会阻塞信号 3、4、5,直到 handler
函数执行完毕。
重点注意:
信号屏蔽集
sa_mask
中设置的信号,会在信号处理函数执行过程中被阻塞。信号处理完成后,阻塞的信号会被解除屏蔽并立即递达。
4.信号部分小结
信号处理的过程可以总结为以下几个阶段:
信号产生阶段:信号可以通过四种方式产生:
键盘键入(如 Ctrl+C)
系统调用(例如调用
kill()
)软件条件(如通过
raise()
发送信号)硬件异常(如除以零、访问非法内存等)
信号保存阶段:当信号产生后,内核会将其保存在一个叫做
pending
的信号表中,待后续处理。同时,还有一个block
表和handler
表来管理信号的屏蔽和处理动作。信号处理阶段:信号的处理在内核态切换回用户态时进行。进程在接收到信号时会根据设定的信号处理程序进行相应的处理。
5. 补充知识
5.1 可重入函数
可以被重复进入的函数称为 可重入函数
比如单链表头插的场景中,节点 node1
还未完成插入时,node2
也进行了头插,最终导致 节点 node2
丢失,造成 内存泄漏
可重入函数指的是能够在中断或递归调用的情况下继续执行的函数。一般而言,如果函数调用了与内存管理或标准 I/O 相关的函数,它就变得不可重入。比如在多线程环境下,如果多个线程同时访问同一个资源而没有加锁,就会导致资源竞态和内存泄漏等问题。
不可重入函数的条件:
调用了内存管理函数(例如
malloc
、free
)调用了标准 I/O 函数(例如
printf
、scanf
,因为它们内部会使用静态数据结构)
5.2 volatile
关键字
volatile
关键字用于告诉编译器不要对变量进行优化,确保每次访问该变量时,都会直接从内存中读取它的值。通常在处理硬件寄存器、信号处理等情况时,使用 volatile
以确保变量的值能实时反映外部环境的变化。
下面是一个使用 volatile
的例子:
#include <stdio.h>
#include <signal.h>
int flag = 0; // 一开始为假
void handler(int signo)
{
printf("%d号信号已经成功发出了\n", signo);
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag); // 故意不写 while 的代码块 { }
printf("进程已退出\n");
return 0;
}
初步结果符合预期,2
号信号发出后,循环结束,程序正常退出
这段代码能符合我们预期般的正确运行是因为 当前编译器默认的优化级别很低,没有出现意外情况
通过指令查询 gcc
优化级别的相关信息
man gcc
: /O1
其中数字越大,优化级别越高,理论上编译出来的程序性能会更好
事实真的如此吗?
让我们重新编译上面的程序,并指定优化级别为 O1
gcc mySignal mySignal.c -O1
编译成功后,再次运行程序
此时得到了不一样的结果:2
号信号发出后,对于 falg
变量的修改似乎失效了
将优化级别设为更高是一样的结果,如果设为 O0
则会符合预期般的运行,说明我们当前的编译器默认的优化级别是 O0
查看编译器的版本
gcc --version
当前版本为 gcc(GCC) 4.8.5
(不同版本编译器的默认优化级别可能略有不同)
那么我们这段代码哪个地方被优化了呢?
- 答案是
while
循环判断
首先要明白:
- 对于程序中的数据,需要先被
load
到CPU
中的 寄存器 中 - 判断语句所需要的数据(比如
flag
),在进行判断时,是从 寄存器 中拿取并判断 - 根据判断的结果,判断代码的下一步该如何执行(通过
PC
指针指向具体的代码执行语句)
所以程序在优化级别为 O0
或更低时,是这样执行的:
5.3 SIGCHLD
信号
SIGCHLD
信号用于通知父进程其子进程已经结束。当子进程结束时,会向父进程发送 SIGCHLD
信号,父进程可以捕获该信号并通过调用 wait()
或 waitpid()
来回收子进程的资源,从而避免产生“僵尸进程”。
示例:
下面的程序演示了父进程如何捕捉SIGCHLD
信号并回收子进程资源:#include <stdio.h> #include <stdlib.h> #include <signal.h> #include <unistd.h> #include <sys/types.h> #include <sys/wait.h> void handler(int signo) { printf("进程 %d 捕捉到了 %d 号信号\n", getpid(), signo); } int main() { signal(SIGCHLD, handler); pid_t id = fork(); if (id == 0) { int n = 5; while (n) printf("子进程剩余生存时间: %d秒 [pid: %d ppid: %d]\n", n--, getpid(), getppid()); exit(-1); // 子进程退出 } waitpid(id, NULL, 0); // 父进程等待子进程结束 return 0; }
多子进程回收:
如果父进程有多个子进程需要回收,可以通过 waitpid()
使用 WNOHANG
选项进行非阻塞式回收,确保不会因为一个子进程的退出阻塞了对其他子进程的回收。
while (1) {
pid_t ret = waitpid(-1, NULL, WNOHANG);
if (ret > 0) {
printf("父进程: %d 已经成功回收了 %d 号进程\n", getpid(), ret);
} else {
break;
}
}
5.4 SIGCHLD 的忽略方式
另一种简洁的方法是将 SIGCHLD
信号的处理方式设置为忽略(SIG_IGN
)。这样,操作系统会自动处理子进程的回收,父进程不需要主动进行回收操作:
signal(SIGCHLD, SIG_IGN);
这使得子进程在退出后不会成为僵尸进程,操作系统会自动进行清理。
其实还有一种更加优雅的子进程回收方案
由于 UNIX 历史原因,要想子进程不变成 僵尸进程,可以把 SIGCHLD 的处理动作设为 SIG_IGN 忽略,这里的忽略是个特例,只是父进程不对其进行处理,但只要设置之后,子进程在退出时,由 操作系统 对其负责,自动清理资源并进行回收,不会产生 僵尸进程
也就是说,直接在父进程中使用 signal(SIGCHLD, SIG_IGN) 就可以优雅的解决 子进程回收问题,父进程既不用等待,也不需要对信号做出处理
原理:在设置 SIGCHLD 信号的处理动作为忽略后,父进程的 PCB 中有关僵尸进程处理的标记位会被修改,子进程继承父进程的特性,子进程在退出时,操作系统检测到此标记位发生了改变,会直接把该子进程进行释放
SIGCHLD 的默认处理动作是忽略(什么都不做),而忽略动作是让操作系统帮忙回收,父进程不必关心