【Linux:进程间信号】(二)

简介: 【Linux:进程间信号】(二)

4.2在内核中的表示

997a21cbfff04dd4907fc8a6a26d3ebc.png

每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。

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

SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号

4.3 sigset_t

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号
的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有
效”和“无效”的含义是该信号是否处于未决状态。下面将详细介绍信号集的各种操作。 阻塞信号集也叫做当
前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

4.4信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。

#include <signal.h>

#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置满,表示该信号集的有效信号包括系统支持的所有信号。注意,在使用sigset_ t类型的变量之前,一定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号。

这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。

4.5 sigprocmask

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)

#include <signal.h>

int sigprocmask(int how, const sigset_t *set, sigset_t *oset);

返回值:若成功则为0,若出错则为-1

如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。

27d0f59ecf484ef9bc3f38201455e3f7.png

4.6 sigpending

#include <signal.h>

sigpending

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

现在我们可以用刚才介绍的函数做实验:实验内容为屏蔽2号信号,对2号信号进行自定义捕捉,获取pending信号集并打印,过一段时间后解除对2号信号的屏蔽,再次打印pending信号集。

代码实现:

#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
void printSigpending(sigset_t& pending)
{
    for(int i=1;i<=31;++i)
    {
        if(sigismember(&pending,i)) cout<<"1";
        else cout<<"0";
    }
    cout<<endl;
}
void hander(int signo)
{
    cout<<"执行对"<<signo<<"号信号的自定义捕捉动作"<<endl;
}
int main()
{
    signal(2,hander);
    sigset_t set,oset;
    sigemptyset(&set);
    sigemptyset(&oset);
    sigaddset(&set,2);
    sigprocmask(SIG_SETMASK,&set,&oset);
    int cnt=0;
    while(true)
    {
        sigset_t pending;
        sigemptyset(&pending);
        int n=sigpending(&pending);
        printSigpending(pending);
        sleep(1);
        if(cnt++==10)
        {
            cout<<"解除对2号信号的屏蔽"<<endl;
            sigprocmask(SIG_SETMASK,&oset,nullptr);
        }
    }
    return 0;
}

效果展示:

c407126de9fe4112bb61e1d2683d7518.gif

我们不难从上面发现当我们发送了多次2号信号并且2号信号处于屏蔽状态时只会保留一次。

此时我们想终止该进程时可以用ctrl+\

当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。

5 信号的处理

5.1 问题引入

我们之前讲解过,信号可以不是被立即处理的,而是在一个合适的时候,这个合适的时候是什么时候呢?

当进程从内核态转换到用户态的时候会在OS的指导下进行信号的检测。

那什么是用户态,什么是内核态呢?

5.2内核态和用户态

先用通俗易懂语言来描述下:

用户态:当执行用户自己的代码时,进程所处于的状态;

内核态:当执行OS的代码时,进程所处于的状态。

那么在哪些情况下进程会从用户态转换到内核态呢?

进程的时间片到了,需要进行进程的切换时;

进行系统调用等。

我们还可以从地址空间上来理解,不知道大家忘记了下面这张图片了没有?


22eb87ef7dcd44ce84ceaea60a1d2d10.png


在32位的地址下,内存有4GB大小,其中【0,3】是用户空间,【3,4】是内核空间。执行用户空间的代码的进程就处于用户态,执行内核空间的代码的进程就处于内核态。

在用户空间中每一个进程都有一张用户级别的页表,用户级别的页表在不同的进程下有可能是不相同的;除此之外每一个进程还会为内核空间分配一个内核级别的页表,而不同的进程的内核级页表是相同的,所以不同进程就看到了同一份页表。所以OS运行的本质是在进程的地址空间上运行的,当我们调用OS的代码是直接在进程地址空间进行跳转即可。

那么问题来了,OS是如何判别用户态和内核态呢?

在CPU中有一种叫做CR3的寄存器,寄存器中3表示进程处于用户态,0表示进程处于内核态。

所以我们现在可以解释一下进程调度的过程:OS时钟硬件每隔一段时间都会给OS发送时钟中断,OS会执行对应中断的处理方法(检测时间片),然后将进程的上下文进程保存和切换,选择合适的进程,OS处理时采用的是schedule函数。

5.3 信号的捕捉

当我们自定义了信号的捕捉方式时,整个过程中进程从用户态到内核态的转换:


f3822ca32ae04e6f9063dbe040842868.png

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态而不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

这时我们可能就会有一些小问题:当执行了某种信号时,pending位图是在hander方法处理完之前还是处理完之后被置为0的呢?

这个问题很好验证,我们只需要在自定义捕捉时打印一下即可,我们放在sigaction一起验证。

5.4 sigaction

我们可以查一下man手册:



28f73cf35b0146daa56c6b3eb9fd2bcf.png

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:

将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数。这里就不在过于多说,感兴趣的同学可以自己去查询。

我们可以写代码来验证下:

static void PrintPending(const sigset_t &pending)
{
    cout << "当前进程的pending位图: ";
    for(int signo = 1; signo <= 31; signo++)
    {
        if(sigismember(&pending, signo)) cout << "1";
        else cout << "0";
    }
    cout << "\n";
}
static void handler(int signo)
{
    cout << "对特定信号:"<< signo << "执行捕捉动作" << endl;
    int cnt = 30;
    while(cnt)
    {
        cnt--;
        sigset_t pending;
        sigemptyset(&pending); // 不是必须的
        sigpending(&pending);
        PrintPending(pending);
        sleep(1);
    }
}
int main()
{
    struct sigaction act, oldact;
    memset(&act, 0, sizeof(act));
    memset(&oldact, 0, sizeof(oldact));
    act.sa_handler = handler;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask,3);
    sigaddset(&act.sa_mask,4);
    sigaddset(&act.sa_mask,5);
    sigaction(2, &act, &oldact);
    while(true)
    {
        cout << getpid() << endl;
        sleep(1);
    }
}

效果展示:

3cb1d8b4ef054223b4b55c442bed1a71.gif

通过上面的演示我们可以验证pending位图是在hander方法处理完之前就已经清0了


6 可重入函数

首先我们先来看看这样一种场景:


b78bcf6f49ec4b0c98dbcc21803ad32f.png

当我们执行其中一个进程代码的insert方法时,执行了第一行代码后该进程的时间片到了,调用了另外进程同样也执行了insert方法的第一行代码,然后切回到最开始的进程执行第二行代码,此时就造成了node2的内存泄漏,而这种函数就叫做不可重入函数

反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。但是使用局部变量就不会造成上面的混乱问题。

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

调用了malloc或free,因为malloc也是用全局链表来管理堆的。

调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

7 volatile

这个关键字我们在C语言时就已经涉猎过,现在我们在从系统的角度再来理解一下。

我们先来看这样一段代码:

#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
int g_val=1;
void hander(int signo)
{
    cout<<"g_val form 1 to 0"<<endl;
    g_val=0;
    cout<<g_val<<endl;
}
int main()
{
    signal(2,hander);
    while(g_val);
    cout<<" success quit"<<endl;
    return 0;
} 

Makefile中代码的编译我们加了O2优化


17d48f322812440a821d72f5391dbb1c.png

当我们运行时:

31933d1f8171473da15c6ed971401d9b.png

我们发现当我们在终端下一直敲Ctrl+C时程序并不会运行,最后敲了Ctrl+\才退出,为什么呢?

这是由于编译器做了优化,它是怎么优化的呢?

我们知道编译器取数据都是到内存上面去取到的,当编译器识别到变量g_val时,由于在主函数里面没有直接修改该变量的值,所以编译器认为你没有修改这个变量,那我就将变量放到寄存器中,我们使用该变量时就不用在到内存上面去取了,直接在寄存器中取出数据即可,而寄存器中的数据一直保存的是1,所以该程序就死循环出不来了。

有什么办法去掉编译器的优化吗?我们可以加volatile关键字在变量前面,这就告诉编译器不要做优化了,也就是不要将该变量放在寄存器中,每次你取数据时还是老老实实到内存上面来取,这样做可不可行呢?我们接下来试试:



a02dde328692472c8a215355732b941c.png

运行结果:


dacdbfdc917d4e9eb461937df5c57dc9.png

我们可以观察到这种方式是可行的。

其实不仅仅在Linux上,在VS中也会出现这样的优化,来看看下面的代码:


7b48f561fd9a4f8898e01822eac0205a.png

大家可以猜猜结果:

我们来调试一下:


1e22cb93ae614d918dccc6847dc28f01.png

在内存窗口中发现他们都是20,但是当我们看打印结果时就傻眼了:


b66c0de1b52940ebb5e9f2c70fbc5583.png

打印结果居然是10和20,这里其实也是编译器做了优化,编译器认为既然你a加了const属性,那么我就认为你不可以直接修改,就直接把变量a放在了寄存器中,所以在打印结果中我们看到的是10而内存窗口看到的是20,我们加了volatile关键字后看到的结果都为20了:


0a2a44ff4c9b4a35a09b1c2b488a31be.png

相关实践学习
CentOS 7迁移Anolis OS 7
龙蜥操作系统Anolis OS的体验。Anolis OS 7生态上和依赖管理上保持跟CentOS 7.x兼容,一键式迁移脚本centos2anolis.py。本文为您介绍如何通过AOMS迁移工具实现CentOS 7.x到Anolis OS 7的迁移。
目录
相关文章
|
22天前
|
网络协议 Linux
Linux查看端口监听情况,以及Linux查看某个端口对应的进程号和程序
Linux查看端口监听情况,以及Linux查看某个端口对应的进程号和程序
99 2
|
22天前
|
Linux Python
linux上根据运行程序的进程号,查看程序所在的绝对路径。linux查看进程启动的时间
linux上根据运行程序的进程号,查看程序所在的绝对路径。linux查看进程启动的时间
39 2
|
16天前
|
Linux C语言
C语言 多进程编程(四)定时器信号和子进程退出信号
本文详细介绍了Linux系统中的定时器信号及其相关函数。首先,文章解释了`SIGALRM`信号的作用及应用场景,包括计时器、超时重试和定时任务等。接着介绍了`alarm()`函数,展示了如何设置定时器以及其局限性。随后探讨了`setitimer()`函数,比较了它与`alarm()`的不同之处,包括定时器类型、精度和支持的定时器数量等方面。最后,文章讲解了子进程退出时如何利用`SIGCHLD`信号,提供了示例代码展示如何处理子进程退出信号,避免僵尸进程问题。
|
17天前
|
NoSQL
gdb中获取进程收到的最近一个信号的信息
gdb中获取进程收到的最近一个信号的信息
|
23天前
|
消息中间件 Linux
Linux进程间通信
Linux进程间通信
32 1
|
24天前
|
Linux 调度
Linux0.11 信号(十二)(下)
Linux0.11 信号(十二)
19 1
|
24天前
|
C语言
Linux0.11 系统调用进程创建与执行(九)(下)
Linux0.11 系统调用进程创建与执行(九)
20 1
|
6天前
|
存储 监控 安全
探究Linux操作系统的进程管理机制及其优化策略
本文旨在深入探讨Linux操作系统中的进程管理机制,包括进程调度、内存管理以及I/O管理等核心内容。通过对这些关键组件的分析,我们将揭示它们如何共同工作以提供稳定、高效的计算环境,并讨论可能的优化策略。
13 0
|
18天前
|
Unix Linux
linux中在进程之间传递文件描述符的实现方式
linux中在进程之间传递文件描述符的实现方式
|
19天前
|
开发者 API Windows
从怀旧到革新:看WinForms如何在保持向后兼容性的前提下,借助.NET新平台的力量实现自我进化与应用现代化,让经典桌面应用焕发第二春——我们的WinForms应用转型之路深度剖析
【8月更文挑战第31天】在Windows桌面应用开发中,Windows Forms(WinForms)依然是许多开发者的首选。尽管.NET Framework已演进至.NET 5 及更高版本,WinForms 仍作为核心组件保留,支持现有代码库的同时引入新特性。开发者可将项目迁移至.NET Core,享受性能提升和跨平台能力。迁移时需注意API变更,确保应用平稳过渡。通过自定义样式或第三方控件库,还可增强视觉效果。结合.NET新功能,WinForms 应用不仅能延续既有投资,还能焕发新生。 示例代码展示了如何在.NET Core中创建包含按钮和标签的基本窗口,实现简单的用户交互。
43 0

热门文章

最新文章