
在操作系统的长河中,Linux 进程如同并发世界中的浮萍,在调度器的波涛中起伏。而信号(Signal),正是内核赋予这些进程的一缕清风,或是一声惊雷。它们悄然降临,又或猛烈袭来,带来命运的转折。
本篇报告,将带你领略 Linux 信号机制的诗意与科学,并以代码为笔墨,勾勒出它的精妙运行。
直奔主题,谈谈信号的 ·处理时机·
普通情况
所谓的普通情况就是指 信号没有被阻塞,直接产生,记录未决信息后,再进行处理
在这种情况下,信号是不会被立即递达的,也就无法立即处理,需要等待合适的时机
特殊情况
当信号被阻塞 后,信号 产生 时,记录未决信息,此时信号被阻塞了,也不会进行处理
当阻塞解除后,信号会被立即递达,此时信号会被立即处理
特殊情况 很好理解,就好比往气球里吹气,当气球炸了,空气会被立即释放,因为空气是被气球 阻塞 的,当气球炸了之后(阻塞 解除),空气立马往外跑,这不就是 立即递达、立即处理 吗?
通情况 就有点难搞了,它需要等待 “合适” 的时机,才能被 递达,继而被 处理
信号的产生是 异步 的
也就是说,信号可能随时产生,当信号产生时,**进程可能在处理更重要的事,**此时贸然处理信号显然不够明智
比如进程正在执行一个重要的 IO,突然一个终止信号发出,IO 立即终止,对进程、磁盘都不好
因此信号在 产生 后,需要等进程将 更重要 的事忙完后(合适的时机),才进行 处理
合适的时机:进程从 内核态 返回 用户态 时,会在操作系统的指导下,对信号进行检测及处理
至于处理动作,分为:默认动作、忽略、用户自定义
搞清楚 “合适” 的时机 后,接下来需要学习 用户态 和 内核态 相关知识
对于 用户态、内核态 的理解及引出的 进程地址空间 和 信号处理过程 相关知识是本文的重难点
先来看看什么是 用户态和内核态
用户态
内核态
自己写的代码被执行很好理解,操作系统的代码是什么?
也就是说,用户态 与 内核态 是两种不同的状态,必然存在相互转换的情况
用户态 切换为 内核态:
内核态 切换为 用户态:
信号的处理时机就是 内核态 切换为 用户态,也就是 当把更重要的事做完后,进程才会在操作系统的指导下,对信号进行检测、处理
下面来结合 进程地址空间 深入理解 操作系统的代码 及 状态切换 的相关内容(拓展知识)
首先简单回顾下 进程地址空间 的相关知识:
进程地址空间 是虚拟的,依靠 页表+MMU机制 与真实的地址空间建立映射关系进程地址空间 中地址可能冲突,但实际上地址是独立的进程地址空间 可以让进程以统一的视角看待自己的代码和数据

不难发现,在 进程地址空间 中,存在 1 GB 的 内核空间,每个进程都有,而这 1 GB 的空间中存储的就是 操作系统相关代码和数据,并且这块区域采用 内核级页表 与 真实地址空间 进行映射
为什么要区分 用户态 与 内核态 ?

所谓的 执行操作系统的代码及系统调用,就是在使用这 1 GB 的内核空间
进程间具有独立性,比如存在用户空间中的代码和数据是不同的,难道多个进程需要存储多份 操作系统的代码和数据 吗?
内核级页表 不同于 用户级页表,专注于对 操作系统代码和数据 进行映射,是很特殊的当我们执行诸如 open 这类的 系统调用 时,会跑到 内核空间 中调用对应的函数
而 跑到内核空间 就是 用户态 切换为 内核态 了(用户空间切换至内核空间)
这个 跑到 是如何实现的呢?
寄存器 的作用就是用来表征当前处于 用户态 还是 内核态
用户态
内核态
寄存器,表征当前所处的 状态,修改其中的 值,就可以表示不同的 状态,这是很聪明的做法

重谈 进程地址空间 后,得到以下结论
用户级页表 进行不同的映射那么进程又是如何被调度的呢?
1. 操作系统的本质
2. 进程被调度,就意味着它的时间片到了,操作系统会通过时钟中断,检测到是哪一个进程的时间片到了,然后通过系统调用函数 schedule() 保存进程的上下文数据,然后选择合适的进程去运行
当在 内核态 完成某种任务后,需要切回 用户态,此时就可以对信号进行 检测 并 处理 了
情况1:信号被阻塞,信号产生/未产生
信号都被阻塞了,也就不需要处理信号,此时不用管,直接切回 用户态 就行了
下面的情况都是基于 信号未被阻塞 且 信号已产生 的前提
情况2:当前信号的执行动作为
默认
大多数信号的默认执行动作都是 终止 进程,此时只需要把对应的进程干掉,然后切回 用户态 就行了

情况3:当前信号的执行动作为
忽略
当信号执行动作为 忽略 时,不做出任何动作,直接返回 用户态

情况4:当前信号的执行动作为
用户自定义
这种情况就比较麻烦了,用户自定义的动作位于 用户态 中,也就是说,需要先切回 用户态,把动作完成了,重新坠入 内核态,最后才能带着进程的上下文相关数据,返回 用户态
在 内核态 中,也可以直接执行 自定义动作,为什么还要切回 用户态 执行自定义动作?
内核态 可以访问操作系统的代码和数据,自定义动作 可能干出危害操作系统的事用户态 中可以减少影响,并且可以做到溯源为什么不在执行完 自定义动作 直接后返回进程?
自定义动作 和 待返回的进程 属于不同的堆栈,是无法返回的
==注意: 用户自定义的动作,需要先切换至 用户态中执行,执行结束后,还需要坠入 内核态
通过一张图快速记录信号的 处理 过程

接下来谈谈 信号 是如何被 捕捉 的
如果信号的执行动作为 用户自定义动作,当信号 递达 时调用 用户自定义动作,这一动作称为 信号捕捉
用户自定义动作 是位于 用户空间 中的
内核态 中任务完成,准备返回 用户态 时,检测到信号 递达,并且此时为 用户自定义动作,需要先切入 用户态 ,完成 用户自定义动作 的执行;用户自定义动作 和 待返回的函数 属于不同的 堆栈 空间,它们之间也不存在 调用与被调用 的关系,是两个 独立的执行流,需要先坠入 内核态 (通过 sigreturn() 坠入),再返回 用户态 (通过 sys_sigreturn() 返回)
sigaction 也可以 用户自定义动作,比 signal 功能更丰富

#include <signal.h>
int sigaction(int signum, const struct sigaction *act,
struct sigaction *oldact);
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 结构体
其中部分字段不需要管,因为那些是与 实时信号 相关的,我们这里不讨论
重点可以看看 sa_mask 字段
sa_mask:当信号在执行 用户自定义动作 时,可以将部分信号进行屏蔽,直到 用户自定义动作 执行完成
也就是说,我们可以提前设置一批 待阻塞 的 屏蔽信号集,当执行 signum 中的 用户自定义动作 时,这些 屏蔽信号集 中的 信号 将会被 屏蔽(避免干扰 用户自定义动作 的执行),直到 用户自定义动作 执行完成
可以简单用一下 sigaction 函数
#include <iostream>
#include <cassert>
#include <cstring>
#include <signal.h>
#include <unistd.h>
using namespace std;
static void DisplayPending(const sigset_t pending)
{
// 打印 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);
(void)ret; // 欺骗编译器,避免 release 模式中出错
DisplayPending(pending);
sleep(1);
}
}
int main()
{
cout << "当前进程: " << getpid() << endl;
//使用 sigaction 函数
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);
//给 2号 信号注册自定义动作
sigaction(2, &act, &oldact);
// 死循环
while (true);
return 0;
}
当 2 号信号的循环结束(10 秒),3、4、5 信号的 阻塞 状态解除,立即被 递达,进程就被干掉了
注意: 屏蔽信号集 ·sa_mask· 中已屏蔽的信号,在 ·用户自定义动作· 执行完成后,会自动解除 ·阻塞· 状态
截至目前,信号 处理的所有过程已经全部学习完毕了
信号产生阶段:有四种产生方式,包括 键盘键入、系统调用,软件条件、硬件异常
信号保存阶段:内核中存在三张表,blcok 表、pending 表以及 handler 表,信号在产生之后,存储在 pending 表中
信号处理阶段:信号在 内核态 切换回 用户态 时,才会被处理

下面是一些补充知识
可以被重复进入的函数称为 可重入函数
比如单链表头插的场景中,节点 node1 还未完成插入时,node2 也进行了头插,最终导致 节点 node2 丢失,造成 内存泄漏

导致 内存泄漏 的罪魁祸首:对于 node1 和 node2 来说,操作的 单链表 是同一个,同时进行并发访问(重入)会出现问题的,因为此时的 单链表 是临界资源
我们学过的函数中,90% 都是 不可重入的
函数是否可重入是一个特性,而非缺点,需要正确看待
不可重入的条件:
volatile 关键字可以避免 编译器 的优化,保证内存的 可见性
比如在下面这个例子中
借助全局变量 flag 设计一个死循环的场景,在此之前将 2 号信号进行自定义动作捕捉,具体动作为:将 flag 改为 1,可以终止 main 函数中的循环体
#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 test2 test2.c -O1编译成功后,再次运行程序

此时得到了不一样的结果:2 号信号发出后,对于 flag 变量的修改似乎失效了
将优化级别设为更高是一样的结果,如果设为 O0 则会符合预期般的运行,说明我们当前的编译器默认的优化级别是 O0
查看编译器的版本
gcc --version
那么我们这段代码哪个地方被优化了呢?
答案是 while 循环判断
首先要明白:
load 到 CPU 中的 寄存器 中寄存器 中拿取并判断O0 或更低时,是这样执行的:

在 进程控制 学习时期,我们明白了一个事实:父进程必须等待子进程退出并回收,并为其 “收尸”,避免变成 “僵尸进程” 占用系统资源、造成内存泄漏
那么 父进程是如何知道子进程退出了呢?
在之前的场景中,父进程要么就是设置为 阻塞式专心等待,要么就是 设置为 WNOHANG 非阻塞式等待,这两种方法都需要 父进程 主动去检测 子进程 的状态
如今学习了 进程信号 相关知识后,可以思考一下:子进程真的是安安静静的退出的吗?
SIGCHLD 信号SIGCHLD 信号 通知 父进程,子进程 要退出了,这样可以解放 父进程,不必再去 主动检测 ,而是 子进程 要退出的时候才通知其来 “收尸”
SIGCHLD 信号比较特殊,默认动作 SIG_DEF 是 什么都不做
首先通过程序证明一下子进程会发出 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;
}通过自定义捕捉,打印相关信息

因此可以证明 SIGCHLD 是被子进程真实发出的,当然,我们可以自定义捕捉动作为 回收子进程,让父进程不再主动检测子进程的状态,可以自己忙自己的事
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
pid_t id; // 将子进程的id设为全局变量,方便对比
void handler(int signo)
{
printf("进程 %d 捕捉到了 %d 号信号\n", getpid(), signo);
// 这里的 -1 表示父进程等待时,只要是已经退出了的子进程,都可以进行回收
pid_t ret = waitpid(-1, NULL, 0);
if(ret > 0)
printf("父进程: %d 已经成功回收了 %d 号进程,之前的子进程是 %d\n", getpid(), ret, id);
}
int main()
{
signal(SIGCHLD, handler);
id = fork();
if(id == 0)
{
int n = 5;
while(n)
{
printf("子进程剩余生存时间: %d秒 [pid: %d ppid: %d]\n", n--, getpid(), getppid());
sleep(1);
}
// 子进程退出
exit(-1);
}
// 父进程很忙的话,可以去做自己的事
while(1)
{
// TODO
printf("父进程正在忙...\n");
sleep(1);
}
return 0;
}父进程和子进程各忙各的,子进程退出后会发信号通知父进程,并且能做到正确回收

那么这种方法就一定对吗?
SIGCHLD 也是一个信号啊,它可能也会在 block 表和 pending 表中被置为 1,当多个子进程同时向父进程发出信号时,父进程只能先回收最快发出信号的子进程,并将随后发出信号的子进程 SIGCHLD 信号保存在 blcok 表中,除此之外,其他的子进程信号就丢失了,父进程处理完这两个信号后,就认为没有信号需要处理了,这就造成了内存泄漏
while 循环式回收,有很多进程都需要回收没问题,排好队一个个来就好了,这样就可以确保多个子进程同时发出SIGCHLD信号时,可以做到一一回收
WNOHANG 非阻塞式等待
正确的代码长这样:
#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);
// 这里的 -1 表示父进程等待时,只要是已经退出了的子进程,都可以进行回收
while (1)
{
pid_t ret = waitpid(-1, NULL, WNOHANG);
if (ret > 0)
printf("父进程: %d 已经成功回收了 %d 号进程\n", getpid(), ret);
else
break;
}
printf("子进程回收成功\n");
}
int main()
{
signal(SIGCHLD, handler);
// 创建10个子进程
int n = 10;
while (n--)
{
pid_t id = fork();
if (id == 0)
{
int n = 5;
while (n)
{
printf("子进程剩余生存时间: %d秒 [pid: %d ppid: %d]\n", n--, getpid(), getppid());
sleep(1);
}
// 子进程退出
exit(-1);
}
}
// 父进程很忙的话,可以去做自己的事
while (1)
{
// TODO
printf("父进程正在忙...\n");
sleep(1);
}
return 0;
}
其实还有一种更加优雅的子进程回收方案
由于 UNIX 历史原因,要想子进程不变成 僵尸进程,可以把 SIGCHLD 的处理动作设为 SIG_IGN 忽略,这里的忽略是个特例,只是父进程不对其进行处理,但只要设置之后,子进程在退出时,由 操作系统 对其负责,自动清理资源并进行回收,不会产生 僵尸进程
也就是说,直接在父进程中使用 signal(SIGCHLD, SIG_IGN) 就可以优雅的解决 子进程回收问题,父进程既不用等待,也不需要对信号做出处理
原理:在设置 SIGCHLD信号的处理动作为忽略后,父进程的 PCB 中有关僵尸进程处理的标记位会被修改,子进程继承父进程的特性,子进程在退出时,操作系统检测到此标记位发生了改变,会直接把该子进程进行释放
SIGCHLD 的默认处理动作是忽略(什么都不做),而忽略动作是让操作系统帮忙回收,父进程不必关心
注意: 这种情况很特殊,只能保证在 Linux 系统中有效,其他类UNIX系统中可能没啥用
信号在 Linux 世界中,是一场场悄然到来的变奏。它让进程更加灵动、系统更加优雅。而每一个信号处理函数,都是程序在面对突发时的一种从容回应。
如同在风中舞蹈的诗人,程序员在信号处理的细节中,写下的不仅是代码,更是与系统对话的诗行。
本篇关于信号处理的介绍就暂告段落啦,希望能对大家的学习产生帮助,欢迎各位佬前来支持斧正!!!