跳到主要内容 深入理解 Linux 信号机制:从 task_struct 到信号递达全过程 | 极客日志
C++
深入理解 Linux 信号机制:从 task_struct 到信号递达全过程 Linux 信号机制涉及信号发送、保存与递达过程。内核通过 task_struct 中的位图记录未决 (Pending) 和阻塞 (Block) 状态,维护 handler 表处理动作。信号递达发生在进程从内核态返回用户态时。使用 sigprocmask 可屏蔽信号,sigpending 查看未决信号,sigaction 设置处理函数。SIGCHLD 用于子进程回收,需循环等待防止僵尸进程。理解信号机制对系统编程至关重要。
什么是信号的发送
一个进程在同一时刻可以接收并保存多个信号,但同一种普通信号最多只会被记录一次。操作系统采用位图方式对信号进行保存,用一个整数即可表示所有的信号。
往具体点讲就是操作系统给进程的 PCB 发送信号,即修改 task_struct(PCB)的信号位图中对应的比特位。因此,更准确的说法是向进程写入信号。
信号的保存
信号的处理方式有三种:默认处理方式、忽略和自定义方式。无论哪种方式的信号处理方式,都将实际执行信号时的处理动作称为信号递达。
将信号保存到进程的 PCB 中,用位图的方式表示信号存储,表示信号从产生到递达之间的状态,称为信号未决 (Pending)。
信号在内核中的表示示意图
在进程的 PCB 中,操作系统会为进程维护一个 handler 表,这是一个指针数组。每个元素都是一个函数指针,默认情况下执行操作系统设定好的函数方法,如果用户自己设置了一个新的方法,就将对应位置的函数指针指向用户自己设置的函数地址。
signal 函数调用的第一个参数是 int 类型,充当数组下标快速定位 handler 表中的位置,第二个参数是函数指针。填入对应的 handler 表中,就可以实现信号的自定义行为。
在进程 PCB 中存在两张表:一张是信号未决 (Pending) 表,用来保存哪些产生了哪些信号;另一张是 handler 表,用来记录当信号进行递达的时候,该使用何种方法进行处理。
进程可以选择阻塞某个信号。一旦该信号被屏蔽了,即使信号处于未决状态,信号也不会被处理,直到进程解除对这个信号的阻塞之后,才会执行信号递达的动作。
注意:阻塞和忽略是不同的。只要信号被阻塞就不会递达,而忽略是递达之后可选的一种处理动作。
并不是只有信号产生了,我们才会对信号进行阻塞。即使信号没有产生,我们也可能会对该信号进行阻塞。所以在进程中除了有 pending 表和 handler 表,还有一张表就是 block 表。这个表和 pending 表一模一样,都是位图表。当对应信号的 block 表为 0 时,表示不阻塞该信号;当对应信号的 block 表为 1 时,表示阻塞该信号。
每个信号都有两个标志位分别表示阻塞 (block) 和未决 (pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
SIGINT 信号产生过,但正在被阻塞,所以暂时不能递达。
SIGQUIT 信号未产生过,一旦产生 SIGQUIT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler。
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online
sigset_t 未决和阻塞标志可以用相同的数据类型 sigset_t 来存储。sigset_t 称为信号集,这个类型可以表示每个信号的'有效'或'无效'状态。在阻塞信号集中'有效'和'无效'的含义是该信号是否被阻塞,而在未决信号集中'有效'和'无效'的含义是该信号是否处于未决状态。
上面的三张表都是属于操作系统的内核数据结构,不允许用户直接修改。操作系统提供了对应的函数调用接口供我们使用。用户想要获取对应的 block 和 pending 表,操作系统必须在用户层设置一种数据类型,所以 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 做初始化。
sigismember 是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号。
sigprocmask 调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字 (阻塞信号集)。
如果 oldset 是非空指针,则读取进程的当前信号屏蔽字通过 oldset 参数传出。如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何更改。
sigpending 这个系统调用接口读取当前进程在内核中的未决信号级 (保存的信号内容),通过 set 参数传出。调用成功返回 0,失败则返回 -1。
下面通过代码来演示具体操作。先将 2 号信号进行屏蔽,然后通过键 ctrl+c 的组合键向进程输入 2 号信号。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void printPending (sigset_t &pending) {
for (int i = 31 ; i >= 1 ; i--) {
if (sigismember (&pending, i) == 1 ) {
std::cout << "1" ;
} else {
std::cout << "0" ;
}
}
std::cout << std::endl;
}
int main () {
sigset_t set, oldset;
sigemptyset (&set);
sigemptyset (&oldset);
sigaddset (&set, 2 );
sigprocmask (SIG_SETMASK, &set, &oldset);
sigset_t pending;
while (1 ) {
sigpending (&pending);
printPending (pending);
sleep (1 );
}
return 0 ;
}
可以看到 pending 信号集确实由于我们屏蔽了 2 号信号,导致 2 号信号一直处于未决状态。为了验证解除屏蔽后信号会递达,需要对 2 号信号的信号处理方式进行自定义。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void printPending (sigset_t &pending) {
for (int i = 31 ; i >= 1 ; i--) {
if (sigismember (&pending, i) == 1 ) {
std::cout << "1" ;
} else {
std::cout << "0" ;
}
}
std::cout << std::endl;
}
void myhandler (int signum) {
std::cout << "get a signal : " << signum << std::endl;
}
int main () {
signal (2 , myhandler);
sigset_t set, oldset;
sigemptyset (&set);
sigemptyset (&oldset);
sigaddset (&set, 2 );
sigprocmask (SIG_SETMASK, &set, &oldset);
int count = 1 ;
sigset_t pending;
while (1 ) {
sigpending (&pending);
printPending (pending);
count++;
if (count == 20 ) {
sigprocmask (SIG_SETMASK, &oldset, nullptr );
}
sleep (1 );
}
return 0 ;
}
可以看到整个流程符合预期。除了 9 号和 19 号信号是无法被屏蔽之外,剩下的普通信号都是可以被屏蔽的。
这就是信号保存的内容。接下来我们来看看信号的捕捉。
信号的捕捉处理 信号产生之后,可能不能立即处理这个信号,这个时候就要将这个信号暂时的保存起来 (信号未决)。当到了合适的时候,这个信号就会根据相应的 handler 表执行相应的动作。合适的时候是进程从内核态返回用户态时,进行对信号的检测和处理!
进程只有从用户态切换为内核态的时候,才有访问这三张表的权限。进程对信号的处理就是在当进程从内核态返回用户态之前进行处理。
进程从用户态转变为内核态有三种,分别是异常,中断,和系统调用。大家经常使用的 C 标准库中的 scanf 和 printf 函数,它们两个的底层调用是系统调用接口 read 和 write。
在 Linux 系统中,每个进程都都会拥有属于自己的一份虚拟地址空间,这个空间的管理核心由 task_struct 和 mm_struct 两个结构体承担。task_struct 是进程的'身份信息',mm_struct 则具体描述了进程的整个虚拟地址空间布局。
在 32 位 Linux 下,虚拟地址空间通常划分为 4GB:其中低 3GB 为用户空间,高 1GB 为内核空间。用户空间内存从低地址向高地址依次分布为:代码段、字符常量区、已初始化数据区、未初始化数据区(BSS)、堆、共享区以及栈。
总之就是有几个进程就有几个用户级页表,而内核级页表只有一份。当我们站在进程的视角,我们使用系统调用,其实就是在进程自己的虚拟地址空间就可以执行。
之前我们了解的共享空间,以及动静态库等等,它们都是在进程地址空间的内存映射区 (共享区),都是属于用户空间,所以不涉及到权限的问题。但是今天我们要访问的是内核中的三张信号表,所以我们必须先要获取对应的权限,才能够进行内核,完成信号的处理。
其实在我们的 CPU 中有一个 CS 寄存器,叫做代码段寄存器,在这个寄存器的低 2 位中就可以表示用户态还是内核态。当 CS 寄存器的低 2 位为 00 时,表示进程处于内核态,而当 CS 寄存器的低 2 位为 11 时,表示进程处于用户态。
当进程在执行主程序中,由于发生中断 (时钟中断,I/O 中断等),异常,或者系统调用等进入内核。
当进程处理完相应的事件之后,准备返回用户态之前,处理当前进程中可以递达的信号。先查看 pending 表,哪些信号已经产生了,然后再看 block 表,再看哪些信号被阻塞了,最后根据 handler 表进行信号的处理。
当信号的处理动作为自定义行为,这个时候就需要进程从内核态转变为用户态,执行对应的自定义信号处理函数。
当执行完自定义信号处理函数之后再次进入内核。
最后返回用户态再次从主程序中中断的地方继续向下执行。
为什么执行用户自定义信号处理函数的时候,需要我们从内核态转变为用户态?
为什么返回用户态执行完信号的自定义处理函数之后要返回内核态。
对于第一个问题,内核态的权限肯定是可以执行用户态的程序的。操作系统选择返回用户态,是为了防止你在自定义的处理函数中使用一些不合法的手段。
对于第二个问题,内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数。sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler 函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。
这就是信号的捕捉处理。我们现在知道了,信号产生之后,我们会先将在内核中的 pending 表中的对应的信号比特位由 0 置 1,然后根据 block 表,查看时候信号被阻塞,最后我们通过 handler 表中对应的方法实现信号的处理。
那么什么时候再次由 1 变为 0 呢?是在信号处理之前由 0 变为 1,还是在信号处理之后由 0 变为 1 呢?
sigaction 参数 含义 signum信号编号(如 SIGINT、SIGTERM) act新的信号处理方式 oldact保存旧的处理方式(可为 NULL)
sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回 0,失败则返回 -1。
我们用 2 号信号做实验,我们可以在信号的自定义行为中进行打印 pending 表,如果这个时候对应 pending 表中对应的 2 号信号已经变为 0 了,说明在信号递达前就已经变为 0 了。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void printPending () {
sigset_t pending;
sigemptyset (&pending);
sigpending (&pending);
for (int i = 31 ; i >= 1 ; i--) {
if (sigismember (&pending, i) == 1 ) {
std::cout << "1" ;
} else {
std::cout << "0" ;
}
}
std::cout << std::endl;
}
void myhandler (int signum) {
printPending ();
std::cout << "get a signal : " << signum << std::endl;
}
int main () {
struct sigaction act;
act.sa_handler = myhandler;
sigaction (2 , &act, nullptr );
while (1 ) {
std::cout << "this is a process : " << getpid () << std::endl;
sleep (1 );
}
return 0 ;
}
从结果来看,我们可以确定,pending 由 1 变为 0 是在信号处理之前就已经完成的。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。
为了避免这种情况的发生,当我们的进程在处理 2 号信号的时候,如果再来 2 号信号,这个时候 2 号信号是被屏蔽的,我们只有在 pending 表中看到 2 号信号被保存了。
void myhandler (int signum) {
std::cout << "get a signal : " << signum << std::endl;
while (1 ) {
printPending ();
sleep (1 );
}
}
如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望可以屏蔽另外一些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号。
int main () {
struct sigaction act;
act.sa_handler = myhandler;
sigemptyset (&act.sa_mask);
sigaddset (&act.sa_mask, 1 );
sigaddset (&act.sa_mask, 3 );
sigaddset (&act.sa_mask, 4 );
sigaction (2 , &act, nullptr );
while (1 ) {
std::cout << "this is a process : " << getpid () << std::endl;
sleep (1 );
}
return 0 ;
}
这样我们就可以在我们的信号进行处理的时候同时屏蔽掉另外一些信号。
可重入函数
main 函数调用 insert 函数向一个链表 head 中插入节点 node1,插入操作分为两步,刚做完第一步的时候,因为时钟中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到 sighandler 函数,sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2,插入操作的两步都做完之后从 sighandler 返回内核态,再次回到用户态就从 main 函数调用的 insert 函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main 函数和 sighandler 先后向链表中插入两个节点,而最后只有一个节点真正插入链表中了。这就导致节点丢失,内存泄漏了。
像上面这样,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入 (Reentrant) 函数。
SIGCHLD 信号 当我们使用 fork 系统调用创建子进程之后,父进程必须等待子进程结束,然后父进程对子进程进行回收,因为如果不这样做,子进程就会变为僵尸进程,而关于子进程的相关资源就会一直无法得到释放,这就会造成内存泄漏的问题。
子进程在退出的时候,是会给我们的父进程发送信号的,这个信号就是 SIGCHLD(17 号信号)。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void myhandler (int signum) {
std::cout << "get a signal : " << signum << std::endl;
}
int main () {
signal (SIGCHLD, myhandler);
pid_t id = fork();
if (id == 0 ) {
int cnt = 5 ;
while (cnt--) {
std::cout << "this is child process , pid : " << getpid () << std::endl;
sleep (1 );
}
std::cout << "child process quit!" << std::endl;
exit (1 );
}
while (1 ) {
std::cout << "this is father process, pid : " << getpid () << std::endl;
sleep (1 );
}
return 0 ;
}
可以看到,子进程在退出之后,我们的父进程确实是收到了 17 号信号。
结合信号的内容,我们可以自定义 17 号信号的信号处理方式,当子进程退出之后,给父进程发送信号之后,然后这个时候我们在信号的自定义处理方式中增加进程等待,这样我们的父进程就不需要被子进程牵着鼻子走了。
void myhandler (int signum) {
sleep (5 );
int rid = waitpid (-1 , nullptr , 0 );
std::cout << "get a signal : " << signum << " , rid : " << rid << std::endl;
}
但是如果当我们的父进程通过 fork() 调用一下子创建了许多的子进程,并且如果这些子进程同时退出的时候,这个时候就会有大问题。因为我们的执行一次信号处理的同时,会屏蔽掉当前信号,并且我们的进程在多个子进程发送的信号之后,由于 pending 图是记录比特位的,所以只会记录一次,这个意味着可能只会有一两个子进程被回收,剩下的子进程都会变为僵尸进程,造成内存泄漏。
其实很简单,既然信号解决不了这个问题,我们就不用信号了,我们可以在接收到 17 号信号之后,让父进程一直回收子进程,知道将所有的子进程回收之后,再结束掉信号的处理。
void myhandler (int signum) {
sleep (5 );
int rid = waitpid (-1 , nullptr , 0 );
while (rid > 0 ) {
rid = waitpid (-1 , nullptr , 0 );
std::cout << "get a signal : " << signum << " , rid : " << rid << std::endl;
}
}
只要 waitpid 的返回值大于 0,就一直进行进程等待,这样就可以将所有的子进程全部回收。
那么现在还有一个问题就是,假如我们的子进程一半退出,一半还在执行,这个时候又应该如何处理呢?
其实这个也十分的简单,我们可以使用非阻塞的方式进行等待,这样将想要退出的子进程进行回收之后,我们非阻塞查询到没有子进程想要退出了,这个时候 waitpid 就会返回 0,我们的信号处理也就结束了。
void myhandler (int signum) {
sleep (5 );
int rid = waitpid (-1 , nullptr , WNOHANG);
while (rid > 0 ) {
rid = waitpid (-1 , nullptr , 0 );
std::cout << "get a signal : " << signum << " , rid : " << rid << std::endl;
}
}
事实上,要想不产生僵尸进程还有另外一种办法:就是将 SIGCHLD 的处理动作设置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。
以上就是 Linux 信号机制的核心内容,包括信号的发送、保存、递达及处理流程,以及 SIGCHLD 在子进程管理中的应用。