[Linux]:信号(下)

✨✨ 欢迎大家来到贝蒂大讲堂✨✨

🎈🎈养成好习惯,先赞后看哦~🎈🎈

所属专栏:Linux学习

贝蒂的主页:Betty's blog

1. 信号的阻塞

1.1 基本概念

信号被操作系统发送给进程之后,进程可能并不会立即处理该信号,此时为了让进程之后能够执行相应的信号,我们必须将对应的信号保存下来,在 Linux中,是通过位图结构保存的。而在了解信号的保存原理之前,我们需要先明白几个重要的概念:

  1. 实际执行信号的处理动作,称为信号递达(Delivery)
  2. 信号从产生到递达之间的状态,称为信号未决(pending)
  3. 进程可以选择**阻塞(Block)**某个信号。
  4. 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

需要注意的是,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后的一种处理动作。

1.2 内核示意图

信号在内核中是通过两个位图与一个函数指针数组表示的,其中 block位图每一个比特位代表对应信号是否被阻塞,pending位图每一个比特位表示对应信号是否未决,handler数组表示存放每个信号处理的默认或者自定义方法。并且这两个位图结构与函数指针数组也是被我们的进程控制块 task_struct所管理的。

根据上图,内核在进程控制块中设置该信号的未决标志为 1,直到信号递达才清除该标志为 0。在上图中,SIGHUP 信号未阻塞也未产生过,当它递达时执行默认处理动作(SIG_DEL)。

SIGINT 信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略(SIG_IGN),但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会在改变处理动作之后再接触阻塞。

SIGQUIT 信号未产生过,但一旦产生 SIGQUIT 信号,该信号将被阻塞,它的处理动作是用户自定义函数 myhandler

其中需要注意的是如果在进程解除对某信号的阻塞之前,这种信号产生过多次(POSIX.1 允许系统递达该信号一次或多次)。那么多次相同的信号又该如何处理呢?在 Linux中, 普通信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。

1.3 信号集操作函数

1.3.1 信号集

sigset_t 被称为信号集,也叫做信号屏蔽字(Signal Mask),是操作系统给用户提供的一种数据类型,用来描述和 blockpending 一样的位图,其结构具体如下:

c 复制代码
#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
	unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
typedef __sigset_t sigset_t;

于此同时,操作系统还给我们提供了很多信号集操作函数,并且我们只能通过这些函数去修改信号集。

c 复制代码
#include <signal.h>
int sigemptyset(sigset_t *set); // 将位图全部设置为 0
int sigfillset(sigset_t *set); // 将位图全部都设置为 1
int sigaddset (sigset_t *set, int signo); // 将位图中的某一位设置为 1
int sigdelset(sigset_t *set, int signo); // 将位图中的某一位设置为 0
int sigismember(const sigset_t *set, int signo); // 判断一个信号是否在信号集中,不在返回0,在返回1,出错返回-1

但是这些都只是对我们自己定义的变量进行了修改,并没有对我们的内核数据有任何影响,为了能让我们真正意义上修改内核中的 blockpending位图,我们还需要借助以下两个接口。

1.3.2 sigprocmask

我们可以使用 sigprocmask函数读取或者修改阻塞信号集(block),其具体用法如下:

  1. 函数原型:int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
  2. 参数:
  • 如果 oldset 是非空指针,则读取进程当前的信号屏蔽字,然后通过 oldset 参数传出。
  • 如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how决定如何更改。
  • 如果 oldsetset 都是非空指针,则先将原来的信号屏蔽字备份到 oldset 里,然后根据 sethow 参数更改信号屏蔽字。
  1. 返回值:如果调用成功返回0,出错返回-1。

如果我们假设当前的信号屏蔽字为 mask,下表说明了 how 参数的可选值。

选项 含义
SIG_BLOCK set 包含了我们希望添加到当前信号屏蔽字的信号,相当于 `mask=mask
SIG_UNBLOCK set 包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 `mask=mask
SIG_SETMASK 设置当前信号屏蔽字为 set 所指向的值,相当于 mask=set
1.3.3 sigpending

我们同样可以通过 sigpending函数来修改对应的未决信号集(pending),其原型如下:

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

sigpending 函数读取当前进程的未决信号集,并通过 set 参数传出。该函数调用成功返回0,出错返回-1。

知道如上接口的用法的作用后,我们就可以编写一段程序来验证以下阻塞信号集:

cpp 复制代码
#include<iostream>
#include <stdio.h>
#include<unistd.h>
#include <signal.h>
using namespace std;
void printPending(const sigset_t&pending)
{
	for(int i=31;i>=1;i--)
	{
		cout<<sigismember(&pending,i);
	}
	cout<<endl;
}
void handler(int sign)
{
	cout<<"get a signal :"<<sign<<endl;
}
int main()
{
	sigset_t set,oldset;
	signal(2,handler);
	sigemptyset(&set);
	sigemptyset(&oldset);
	sigaddset(&set,2);//添加2号信号
	sigprocmask(SIG_SETMASK,&set,&oldset);
	sigset_t pending;
	sigemptyset(&pending);
	while(true)
	{
		sigpending(&pending);
		printPending(pending);
		sleep(1);
	}
	return 0;
}

并且如果程序在中途解除对 2 号新号的阻塞,那么 2 号新号就会从未决变为递达。

2. 信号的捕捉

2.1 内核空间与用户空间

我们知道每一个进程都有自己的进程地址空间(mm_struct),该进程地址空间其实是由内核空间用户空间组成的,如果一个进程地址空间的表示范围为4G,那么内核空间占 1G,用户空间占 3G.

  • 用户所写的代码和数据位于用户空间,通过用户级页表与物理内存之间建立映射关系。
  • 内核空间存储的实际上是操作系统代码和数据,通过内核级页表与物理内存之间建立映射关系。

其中内核级页表是一张全局的页表,它用来维护操作系统的代码与进程之间的关系。因此,在每个进程的进程地址空间中,用户空间是属于当前进程,每个进程的代码和数据可能是不同的,但内核空间所存放的都是操作系统的代码和数据,所有进程中都是一样的内容。

2.2 用户态与内核态

虽然每一个进程中都有对应的操作系统的代码与数据,但并不是所有进程都能访问的,一般只有处于内核态的进程才能访问操作系统的代码与数据,而处于用户态的进程只能访问对应的用户代码与数据。

  • 用户态:用户态是普通程序的运行模式,具有较低的特权级别。在用户态下运行的代码不能直接访问硬件资源和其它受限资源,例如内存管理、设备驱动程序和文件系统等。用户态程序只能通过系统调用与内核态交互,以访问这些受限资源。
  • 内核态:内核态是操作系统内核的运行模式,具有较高的特权级别。在内核态下运行的代码可以访问所有系统资源和设备,并可以执行任何指令。内核态负责管理系统资源、硬件设备和用户程序,以及处理系统中断和异常。

在现代操作系统中,一个进程根据其运行的代码所处的特权级别,可以在用户态和内核态之间切换。例如,当用户程序通过系统调用请求操作系统服务时,进程将从用户态切换到内核态,以允许内核代码执行相应的服务。当内核完成系统调用服务时,进程将切换回用户态,以便继续执行用户代码。

那么问题来了,操作系统为什么不以内核态的方式去执行用户代码呢?

理论上来说是可以的,因为内核态是一种权限非常高的状态,但是绝对不能这样设计。

因为如果允许在内核态直接执行用户空间的代码,那么用户就可以在代码中设计一些非法操作,比如清空数据库,窃取密码等。这种操作在用户态是完全不可行的,但如果以内核态形式去执行,这些非法操作就能被实现。

2.3 内核如何实现对信号的捕捉

我们知道前面这些概念之后,我们就能解释内核是如何实现对信号的捕捉的:

当我们进程执行主控制流程的某条指令时可能因为中断,异常,或系统调用会陷入内核(变为内核态),在内核处理完毕准备返回用户态时,就会进行未决信号 <font style="color:rgb(28, 31, 35);">pending</font> 的检查。

查看 <font style="color:rgb(28, 31, 35);">pending</font> 位图时,若发现有未决信号且该信号未被阻塞,就需要对该信号进行处理。如果待处理信号的处理动作是默认或者忽略,那么执行该信号的处理动作后清除对应的 <font style="color:rgb(28, 31, 35);">pending</font> 标志位,若没有新的信号要递达,就直接返回用户态,从主控制流程中上次被中断的地方继续向下执行。

但如果待处理信号是自定义捕捉的,即该信号的处理方式是由用户提供的,那么处理该信号时就需要先返回用户态执行对应的自定义处理操作,先清除对应的 pending 标志位,执行完后再通过特殊的系统调用 sigreturn 再次陷入内核并,如果没有新的信号要递达,就直接返回用户态,继续执行主控制流程的代码。

其中需要注意的是:sighandlermain 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。

2.4 sigaction 函数

捕捉信号除了我们前面使用过的 signal 函数之外,我们还可以使用 sigaction 函数对信号进行捕捉,其用法如下:

  1. 函数原型: int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
  2. 参数:signum 代表指定信号的编号。若 act 指针非空,则根据 act 修改该信号的处理动作。若 oldact 指针非空,则通过 oldact 传出该信号原来的处理动作。
  3. 返回值:成功返回 0;失败返回 -1。

其中 actoldact的类型是一个结构体指针,这个结构体原型如下:

c 复制代码
struct sigaction {
	void(*sa_handler)(int);
	void(*sa_sigaction)(int, siginfo_t *, void *);
	sigset_t   sa_mask;
	int        sa_flags;
	void(*sa_restorer)(void);
};

其中这五个参数我们只需要关注 sa_handlersa_mask,其他参数默认设为 0.

  • sa_handler:指向自定义的捕捉函数。
  • sa_mask:一个信号集,里面记录了在处理 signum 时需要额外屏蔽掉的信号。

其中需要注意的是:当某个信号的处理函数被调用时,内核会在调用之前自动将当前信号加入进程的信号屏蔽字,待信号处理函数返回时又自动恢复原来的信号屏蔽字,以此保证在处理某个信号时,若该信号再次产生会被阻塞到当前处理结束。

如果在调用信号处理函数时,除当前信号被自动屏蔽外还希望自动屏蔽另外一些信号,可通过 sa_mask 字段说明这些需额外屏蔽的信号,同样在信号处理函数返回时会自动恢复原来的信号屏蔽字。

我们可以通过这个函数来验证一下,在调用对应信号的自定义捕捉方法之前,操作系统会将 pending 表中标记该信号的值,由 1 置 0。

cpp 复制代码
#include<iostream>
#include<signal.h>
#include<cstring>
#include<unistd.h>
using namespace std;
void PrintPending()
{
    sigset_t pending;
    sigemptyset(&pending);
    sigpending(&pending);
    for(int i = 31;i>=1;i--)
    {
        cout<<sigismember(&pending,i);
    }
    cout<<endl;
}
void handler(int sign)
{
    PrintPending();
    cout<<"get a signal:"<<sign<<endl; 
}
int main()
{
    struct sigaction act,oldact;
    memset(&act,0,sizeof(act));
    memset(&oldact,0,sizeof(oldact));
    act.sa_handler = handler;
    sigaction(2,&act,&oldact);
    while(true)
    {
        cout << "process is running, pid: " << getpid() << endl;
        sleep(1);
    }
    return 0;
}

3. 可重入函数

我们知道 main 函数和自定义捕捉方法,属于两个不同的执行流。

如果一个函数,被多执行流重复进入的情况下,会不会发生我们意料之外的情况呢?

比如我下面我们在链表中使用插入功能时,如果在插入逻辑中接受到信号,而自定义信号函数中又会使用插入逻辑就可能造成我们意料之外的情况。

像上例这,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入。 insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数 ,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了 mallocnew,因为 malloc 也是全局链表来管理堆的。
  • 调用了标准 I/O 库函数。标准 I/O 库函数的很多实现都以不可重入的方式使用全局的数据结构。

4. volatile

volatile其实是C语言的一个关键字,该关键字的作用是保持内存的可见性。

比如如下这段代码:

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

using namespace std;

int flag = 0;
void handler(int signum)
{
    cout << "cat a signal: " << signum << endl;
    flag = 1;
}

int main()
{
    signal(2, handler);
    while(!flag);
    
    cout << "process quit normal" << endl;

    return 0;
}

如果正常运行程序会陷入死循环,但是如果发送 2 号信号,flag被修改,程序就会正常结束,结果也应我们所料。

在优化条件下,由于 main 函数与 handler 函数分属两个不同的执行流。而 while 循环处于 main 函数中,此时编译器进行检测,若发现 main 函数中不存在对 flag 值进行修改的操作,那么 flag 变量就可能会被编译器直接优化到 CPU 内的寄存器中。后续在收到信号时调用 handler 方法对 flag 进行修改,修改的是内存中 flag 的值,并未修改寄存器中的 flag 值。而 CPU 一直使用的是寄存器中的 flag,因此就可能陷入死循环中。

需要注意的是,g++ 编译器默认不进行优化,可带选项 -O0、-O1、-O2、-O3 进行这四种优化等级。

解决这种问题很简单,直接用 volatile修饰 flag变量,保证内存的可见性。

5. SIGCHLD信号

其实,子进程在终止时会给父进程发生信号的,这个信号就是 SIGCHLD, 该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD 信号的处理动作(进行进程等待),这样我们也能实现对进程资源的回收。

c 复制代码
#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, pid: %d\n", getpid());
		sleep(3);
		exit(1);
	}
	//father
	while (1);
	return 0;
}
  1. SIGCHLD 属于普通信号,记录该信号的 pending 位只有一个,如果在同一时刻有多个子进程同时退出,那么在 handler 函数当中实际上只清理了一个子进程,因此在使用 waitpid 函数释放子进程资源时需要使用循环不断进行清理。
  2. 使用 waitpid 函数时,需要设置WNOHANG选项,即非阻塞式 轮询等待,否则当所有子进程都已经清理完毕时,由于 while 循环,会再次调用 waitpid 函数,此时就会在这里一直阻塞住。

事实上,由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 signalsigaction 函数将 SIGCHLD 信号的处理动作设置为 SIG_IGN,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。此方法对于 Linux 可用,但不保证在其他 UNIX 系统上都可用。

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>
int main()
{
	signal(SIGCHLD, SIG_IGN);
	if (fork() == 0){
		//child
		printf("child is running, child dead: %d\n", getpid());
		sleep(3);
		exit(1);
	}
	//father
	while (1);
	return 0;
}
相关推荐
pk_xz1234561 小时前
Shell 脚本中变量和字符串的入门介绍
linux·运维·服务器
小珑也要变强2 小时前
Linux之sed命令详解
linux·运维·服务器
Lary_Rock4 小时前
RK3576 LINUX RKNN SDK 测试
linux·运维·服务器
云飞云共享云桌面5 小时前
8位机械工程师如何共享一台图形工作站算力?
linux·服务器·网络
Peter_chq6 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
一坨阿亮7 小时前
Linux 使用中的问题
linux·运维
dsywws8 小时前
Linux学习笔记之vim入门
linux·笔记·学习
幺零九零零9 小时前
【C++】socket套接字编程
linux·服务器·网络·c++
小林熬夜学编程10 小时前
【Linux系统编程】第四十一弹---线程深度解析:从地址空间到多线程实践
linux·c语言·开发语言·c++·算法
程思扬11 小时前
为什么Uptime+Kuma本地部署与远程使用是网站监控新选择?
linux·服务器·网络·经验分享·后端·网络协议·1024程序员节