【Linux】信号(上):一个进程是怎么“被通知”的

自律是普通人最大的武器

系列专栏 直达链接
🐧 Linux 进入专栏 →
🔤 C语言 进入专栏 →
🔧 C++ 进入专栏 →
🐬 MySQL 进入专栏 →
🧩 算法刷题 进入专栏 →

🏠 博客主页:虚无的纽扣

📦 作者仓库:github | gitee

目录

引言

  上一篇我们聊了进程间通信,管道、共享内存、信号量,解决的都是"两个进程怎么看到同一份资源"的问题。其实除了传递数据,还有一类很常见的需求:只是想通知对方发生了某件事------比如告诉它"该退出了""你出错了",这类需求的主角,就是我们这篇要讲的信号。

  信号的内容比较多,我们分上下两篇:上篇讲信号的产生与保存,也就是信号从哪来、进程怎么把它记下来;下篇讲信号的捕捉,以及藏在它背后的内核态与用户态。

1、什么是信号

  信号是一种给进程发送的,用来进行事件异步通知的机制。异步的意思是同一时间两件事情同时发生,互不干扰,就好比人正在睡觉,闹钟也正在慢慢走,两者各干各的,直到闹钟响了再通知起床;信号的产生,相对于进程的运行就是异步的。

  关于信号,先给四条基本结论:1、信号处理,进程在信号没有产生的时候,早就知道信号该如何处理了;2、信号的处理,不是立即处理,而是可以等一会再处理,在合适的时候进行信号的处理;3、进程能识别信号,是提前被"教育过的",操作系统程序员设计的进程,早就已经内置了对于信号的识别和处理方式;4、信号源非常多,给进程产生信号的信号源也非常多。这四条是整篇的主线,特别是前两条------信号没到就知道怎么处理、到了也不马上处理,后面的内容都是围绕它们展开的。

2、信号的产生

键盘产生

  产生信号的方式非常多,第一种就是键盘。比如 Ctrl + C 就是给目标进程发送信号的,相当一部分信号的处理动作就是让自己终止。那么信号都有哪些?Ctrl + C 发送的又是哪个信号?在命令行输入 kill -l 就可以显示所有的信号:

  可以看到,计算机中信号就是一个整数,未来给进程发送信号就是发送数字,因为数字可读性不高,所以就将数字进行宏定义。1~31 号信号是普通信号,是这次要学习的;34~64 为实时信号,暂时不考虑,实时信号就是必须立即处理的信号,普通信号是可以不用立即处理的信号。而 Ctrl + C 给目标进程发送的,就是 2 号信号。

  当一个进程收到信号后就要处理信号,处理信号有三种方式:第一种是默认处理方式,执行操作系统为这个信号规定好的默认动作,大部分信号的默认动作就是直接将进程终止;第二种是自定义信号处理动作,自定义收到信号时如何处理;第三种是忽略处理,忽略信号。所以 Ctrl + C 将 2 号信号传给某一进程,进程就执行默认处理,直接将进程终止。那怎么验证 Ctrl + C 传的确实是 2 号信号呢?我们可以试着更改信号的默认处理动作,使用系统调用 signal 就可以做到:

c 复制代码
#include <signal.h>

typedef void (*sighandler_t)(int);

sighandler_t signal(int signum, sighandler_t handler);

  第一个参数就是普通信号的数字或宏,第二个参数为一个函数指针类型,要传递一个返回值为 void、参数为 int 的函数,所以使用这个系统调用后,对应的信号就不再执行默认的动作,而是执行传递进去的函数方法。我们就可以自己写一个函数来替代默认处理动作,这样的处理方式就对应上面的第二种,也叫自定义捕捉。想查每种信号默认的处理动作,可以输入 man 7 signal:

  其中 2 号信号的默认处理动作为 Term,就是终止进程的意思,后面的说明表示键盘输入终止;Ign 表示默认忽略;Core 也是终止,不过与 Term 有区别,后面我们会讲到;还有 Stop 表示暂停,Cont 表示继续。另外还有一个特别的信号:如果我们把所有信号全都自定义捕捉了,那这个进程不就永远杀不掉了吗?万一是病毒怎么办?所以为了防止这种情况,有一个信号是禁止自定义捕捉的,就是 9 号信号,无论在什么情况下,进程收到 9 号信号都会直接杀掉整个进程。

前台与后台进程

  上面说 Ctrl + C 是给"目标进程"发信号,那目标进程是谁?进程分为前台进程和后台进程,还有一种叫做守护进程的我们暂时不做了解:在命令行中直接输入执行的进程叫做前台进程,在执行的后面加一个 & 符号就是后台进程,我们的 shell 进程默认就是前台的。

  键盘产生的信号,只能发给前台进程,后台进程无法处理我们键盘产生的信号。那后台进程如何终止呢?我们可以在另一台终端上输入 kill -9 加对应进程的 pid,就给该进程发送了 9 号信号,杀掉该进程。这里还有一个细节:后台进程无法从标准输入中获取内容,前台进程能从标准输入中获取内容,但它们都可以向标准输出上打印。为什么呢?因为键盘只有一个,输入数据一定是给一个确定的进程的,前台进程必须只有一个,后台进程可以有多个,前台进程的本质就是要从键盘获取数据的。

  最后是几个切换前后台的命令:jobs,查看所有后台任务;fg 任务号,把特定的进程提到前台;Ctrl + Z,暂停进程,并且将任务自动提到后台;bg 任务号,让后台进程恢复运行。

系统调用与命令行

  回到产生信号的方式,第二种就是系统调用。在这之前先想一个问题:信号产生之后并不是立即处理的,所以要求进程必须把信号记录下来,那记录在哪里、如何记录?记录在 task_struct 结构体里,以位图结构存储,比特位的位置为信号编号,比特位的内容表示是否收到信号。所以未来给一个进程发送信号,本质上是向目标进程写信号,就是修改位图,只需要知道进程的 pid 和信号的编号就可以修改。

  但 task_struct 是属于操作系统内的数据结构对象,修改位图的本质就是修改内核的数据,而修改内核的数据只有操作系统可以操作,所以不管信号怎么产生,发送信号在底层必须让操作系统来做,操作系统就会提供发送信号的系统调用。比如上面曾经用过的 kill 命令,底层就是一个系统调用,可以用来给进程发信号:

c 复制代码
#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);

  还有一个函数可以实现自己给自己发信号:

c 复制代码
#include <signal.h>

int raise(int sig);

  如果想要终止自己的进程,还可以使用函数 abort,它可以直接终止掉自己的进程。而直接在命令行用 kill 命令向进程发送信号,就是产生信号的第三种方式。

异常与软件条件

  第四种产生信号的方式,是通过异常。当我们的代码中出现了错误导致进程崩掉时,如除零或者野指针,操作系统就会发出信号,进程收到信号,导致进程退出。那操作系统怎么知道你的代码运行出错了呢?CPU 里有一个寄存器叫做状态寄存器,它会记录你的进程是否出错并记录下来,操作系统就可以知道进程在硬件上出错了,再根据代码上下文判断是什么错误,然后就会给进程发送对应的信号。

  那操作系统又怎么知道硬件出错了呢?CPU 里还有记录其他信息的寄存器,比如当我们访问野指针时,对应存储页表的寄存器在查找页表的时候发现没有对应的映射关系,就会进行硬件报错,操作系统作为软硬件的管理者就会知道硬件报错了,开始查问题。

  第五种方式是软件条件。当两个进程通过管道进行进程间通信时,读端关闭了管道接口,但写端还在继续写,写端进程就会被操作系统终止;当软件写入的条件不具备,操作系统就会发出信号杀掉对应的进程。此外,操作系统还提供了一个函数 alarm,它的作用是给当前进程设定一个闹钟,进程运行对应的秒数后,操作系统会给进程发送一个信号并默认执行,将进程杀掉。

3、信号的保存

三张表与位图操作

  先给三个概念:实际执行信号的处理动作称为信号递达;信号从产生到递达之间的状态,称为信号未决,也就是信号在位图中,还没有处理;进程可以选择阻塞某个信号,被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作,阻塞信号也叫做屏蔽信号。要注意阻塞和忽略是不同的:只要信号被阻塞就不会被递达,而忽略是递达后可选的一种处理动作,只有被递达才能进行忽略。

  进程中与普通信号相关的有三张表。第一张是 pending 表,就是我们前面说到的保存信号的位图,进程收到信号后修改比特位就是修改 pending 表,比特位的位置表示第几个信号,比特位的内容表示是否收到;第二张是 block 表,本质也是位图,比特位的位置也表示第几个信号,比特位的内容为是否阻塞;第三张是 handler 表,我们在讲 signal 函数时它的第二个参数就叫 handler,这个表就是一个函数指针数组,数组下标表示信号编号,里面保存的是各个信号对应的方法。所以所谓的 signal------注册进程处理信号的方法,本质是修改 handler 表;表中的 SIG_DFL、SIG_IGN 等表示进程收到信号时的处理方式,SIG_DFL 表示默认处理,SIG_IGN 表示忽略处理。Linux 提供的信号操作,一定是围绕着这三张表展开的。

  围绕这三张表,先看怎么修改 block 表。系统调用 sigprocmask 可以获取、设置或修改 block 表:

c 复制代码
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

  第一个参数 how 有三种取值:SIG_BLOCK,set 包含了我们希望添加到当前信号屏蔽字的信号,相当于 mask = mask | set;SIG_UNBLOCK,set 包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask = mask & ~set;SIG_SETMASK,设置当前信号屏蔽字为 set 所指向的值,相当于 mask = set。第三个参数 oldset,是将修改前的设置保存下来,用于恢复修改前的 block 表。而读取当前的 pending 表用 sigpending:

c 复制代码
int sigpending(sigset_t *set);

  至于修改 pending 表,就是我们上面讲过的产生信号的五种方式,没有其他的接口让我们修改 pending 表。sigset_t 这种位图结构还有一组配套的位图操作方法:

c 复制代码
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);
int sigismember(const sigset_t *set, int signum);

  sigemptyset 清空位图;sigaddset 添加数据到位图中;sigismember 判断某一数据是否在位图中;顺带把另外两个也说了,sigfillset 是把位图全部置 1,sigdelset 是从位图中删掉一个信号。

  这里有个问题:如果我们将所有信号都设置为屏蔽,那当前进程岂不是无法因收到信号退出了吗?系统编写者也考虑了这种情况,所以 9 号信号无法被屏蔽、无法被忽略,收到 9 号信号无论如何都会退出进程。还有两个细节要交代:当我们准备递达的时候,要首先清空 pending 表中对应的信号位(从 1 改回 0);如果在进程对某信号的阻塞之前,这种信号产生过多次,Linux 的实现是常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

Core 与 Term 的区别

  前面说 man 手册时提到 Core 和 Term 对应的信号都是终止进程的信号,那它们两个有什么区别呢?一个进程如果以 Core 形式退出,会在当前路径下形成一个文件:进程异常退出的时候,进程在内存中的核心数据从内存拷贝到磁盘,形成一个文件,这个过程称为核心转储。为什么要这么设计呢?主要是为了支持 debug 调试,将上面的过程走完了进程才会退出;而 Term 不做任何转储功能,直接退出。

  那为什么我们没有见过 Core 退出形成的转储文件呢?因为在云服务器上,这个功能是被禁止掉的。为什么被禁止掉?因为这个功能一般是在服务端才会使用,在客户端都是禁止的,要使用就去服务端。输入命令 ulimit -a,可以看到 core file size 这个功能是被关掉的。

  还记得进程等待那篇里的 status 位图结构吗?其中高 8 位代表进程退出的退出码,低 7 位代表进程退出的退出信号,第 8 位存储的叫做 core dump 标志,表示进程是否进行了 core dump。

结语

  这节课我们把信号这条线的前半段走完了:信号是给进程的异步通知,五种产生方式------键盘、系统调用、命令行、异常、软件条件,殊途同归,最后都是操作系统去修改目标进程 task_struct 里的 pending 位图;而进程侧用来记录和处理信号的三张表------pending、block、handler,也构成了所有信号操作的骨架。至于收到信号之后,进程要等到"合适的时候"才去处理,这个"合适的时候"到底是什么时候,就是下篇要讲的内容了,还会牵出内核态、用户态和中断这一整条硬核路线。

  如果有哪里讲得不清楚,也欢迎各位大佬在评论区指正,我们一起把这些底层的东西啃透。以后我也会继续创作 Linux 方向的博客,如果觉得有帮助的话,希望各位大佬能三联关注支持一下,看到后我会进行回关和回访。

相关推荐
qetfw1 小时前
Bash 内置与外部命令:type、command 与 builtin 验证
linux·开发语言·bash
大哥您好10 小时前
QEMU 11.1.2 多架构编译指南(x86_64 / aarch64 / riscv64)
linux·服务器
道满科技11 小时前
Function Calling 参数总出错?只修参数就行
linux·windows·python
Gauss松鼠会11 小时前
高斯数据库(openGauss)的物理存储结构
运维·服务器·数据库·sql·opengauss·经验总结
Android系统攻城狮11 小时前
Linux Gstreamer深度解析之gst_audio_ring_buffer_prepare_read调用流程与实战(七十四)
linux·运维·服务器·gstreamer音视频·音视频进阶
杨云龙UP11 小时前
PostgreSQL 18.4 数据库整库迁移实战:pg_dump 备份与 pg_restore 恢复完整操作手册
运维·服务器·数据库·postgresql·pg_dump·pg_restore
我科绝伦(Huanhuan Zhou)12 小时前
Linux 通用压力测试脚本合集:磁盘 + 网络
linux·网络·压力测试
无人生还别怕13 小时前
如何安装任意版本docker
运维·docker·容器
T0uken13 小时前
Colab:SSH 访问免费 GPU Server
运维·ssh