【Linux手册】Unix/Linux 信号:原理、触发与响应机制实战

文章目录

前言

在计算机程序的运行世界里,进程如同一个个独立运作的生命体,它们在操作系统的调度下各司其职。这些进程间相互是独立的,但是都需要接受操作系统的调度,操作系统需要一种高效的方法来先不同进程传递信息,进程也要能够响应外部事件。

信号是操作系统提供的通信机制,它能够直接控制进程。当用户按下快捷键ctrl + c终止一个程序,当系统资源耗尽需要通知进程,操作系统通过信号来通知相应进程,从而做出反应。它能够打断进程的正常执行流程,迫使进程暂停当前工作转而去处理更紧急或更重要的事件,从而保证了系统正常运行。

本文将从8个方面全面的介绍信号:

  1. 信号是什么;
  2. 操作系统如何读取ctrl + c信号的;
  3. 信号可以通过那些方式产生;
  4. 信号的默认行为种类;
  5. 信号如何发送给进程的;
  6. 信号在进程中怎么保存;
  7. 有哪些接口可以控制信号;
  8. 进程怎么捕捉信号;
  9. 补充。

信号的基本概念

在前言部分我们已经说到,信号是一种高效、及时的方式,用来传递信息、保证进程能够响应外部事件。下面主要介绍一些进程与信号的关联和概念。

  • 当一个进程接收到信号的时候,是允许不立即处理这个信号的;

因为有可能进程正在做更重要的事情,比如当一个进程正在向文件中写入数据,此次给他发送信号让他去做别的事,就有可能导致数据丢失,写入文件中的数据是不完整的。

  • 进程要能够识别信号,并且能处理信号;

每个进程都要有能力识别和处理信号,即使信号没有产生,也具备处理信号的能力,信号的处理1能力是进程内置功能的一部分。

  • 进程允许不立即处理信号,那么在这中间进程就必须有能力存储信号,在后面会详细讲解进程如何保存信号的。

补充:前台进程:获取键盘输入的进程,Linux中只允许有一个前台进程;

后台进程:没有获取键盘输入的进程,通过在执行可执行程序后面加&让可执行程序在后台运行。

当一个程序在后台运行的时候,我们使用ctrl + c就杀不掉了,不仅仅是ctrl + c所有通过键盘组合键的方式向后台进程发送信号都是无效的。

在Linux中通过kill -l可以查看操作系统中的所有信号,其中1 - 31是普通信号,运行不被立即处理,而34 - 64属于实时信号,产生后需要立即被处理:

其中ctrl + c就是2号信号。

在操作系统中为我们提供了一个接口:
sighandler_t signal(int signum , sighandler_t handler)允许我们对信号的处理方法进行修改,即从定义信号的默认动作。

  1. 参数1表示要进行重定义的信号编号;
  2. 参数2的类型是sighandler_t是一个函数指针,typedef void (*__sighandler_t)(int);无返回值,参数是int用来传信号编号。

当然该接口不能对SIGKILL和SIGTOP进行捕捉。

通过该接口我们就可以对2号信号进行重定义,再使用ctrl + c看进程的反应。此处操作简单不再进行演示了。

我们在键盘上的输入是怎么被操作系统知道的???*

操作系统读取ctrl + c信号的流程

CPU不能直接与硬件进行数据交互,但是CPU在控制上可以与外设进行交互,也就是说CPU可以接收外设发送过来的信号。

键盘上的数据读取到内存中主要分5步:

以下是示意图:

  1. 当我们在键盘上输入数据后,键盘会发送中断信号 ,将中断信号交给中断单元。中断单元是用来将接收到的信号进行排序,将优先级高的先发送给CPU;
  2. 当CPU接收到键盘发送(通过CPU上的针脚进行接收,实际上就是高低电压)过来的中断信号之后,CPU知道键盘上有数据要进行读取。所以CPU要找读取键盘的方法;
  3. 在内核中有一个中断向量表 ,就是指针数组,指向各个外设的交互方法,键盘向CPU发送的中断信号中包含中断号,该中断号就指引CPU执行中断向量表中的哪一个方法;
  4. CPU拿着中断号,执行读取键盘数据的方法,将键盘输入的数据读取到内存中;
  5. 对于读取上来的数据,操作系统将进行检查,发现是ctrl + c组合键,就会向前台进程发送2号信号。

信号的产生于我们自己代码的运行是异步的,两个互不相干,进程不知道什么时候会有信号,同样信号也不知道进程现在处于什么情况;

信号是进程间异步通知的一种方式,属于软中断.

信号的处理方式可以分为三类:

  1. 默认动作,执行操作系统的默认动作;
  2. 自定义动作,通过signal()接口来自定义动作;
  3. 忽略。
  • signal可以对信号的动作进行执行,如果第二个参数是函数指针,就对自定义捕捉信号的动作;
  • 如果第二个参数是SGI_IGN表示忽略该信号;SGI_DFL表示执行默认动作。

信号的产生

信号的产生方式可以分为5种:

  1. 键盘组合键;
  2. kill命令;
  3. 系统接口/库函数;
  4. 异常;
  5. 软件条件。

下面一一介绍。

键盘组合键

常见的键盘组合键有:ctrl + c表示第二个信号SIGINI中断信号,ctrl + \第三个信号SIGQUIT退出进程,并生成核心转储文件,后面进行解释,ctrl + z第19信号SIGSTOP暂停进程。

关于键盘组合键的方式向进程发送信号,要求进程必须是前台进程。

kill命令

在Linux操作系统中可以通过kill + -信号编号 + 进程PID 的方式向进程发送信号,可以通过ps - axj和grep命令来获取进程的PID,只要知道进程的PID就可以向进程发送信号。

kill的使用很简单,此处不再过多赘述。

系统接口/库函数

在操作系统中提供了一些接口允许我们通过进程发送信号。

int kill(pid_t pid , int sig):

  1. 参数一:向指定进程ID发送信号;
  2. 参数二:向进程发送sig信号;
  3. 返回值,0表示发送成功,-1表示失败。

int raise(int sig):该接口于上一个对比,没有了进程的PID,只能向调用该接口的进程发送信号。

void abort(void):
abort() 会向当前进程发送 SIGABRT 信号(编号为 6)。进程收到该信号后的默认行为是:

  • 终止进程;
  • 生成核心转储文件(core dump) (记录进程终止时的内存状态,用于调试
    无法被忽略或阻止
    与 SIGINT 等信号不同,abort() 触发的终止行为很难被完全阻止:
  • 即使程序注册了 SIGABRT 的自定义处理函数,abort() 仍可能在处理函数返回后继续终止进程(具体行为可能因系统而异,但通常会强制退出);
  • 唯一例外是在 SIGABRT 处理函数中调用 _exit() 或 exit() 主动控制退出方式,但这会绕过核心转储。

异常

我们编写的进程崩溃大多数都是因为程序出现了异常。

我们在编码过程中一定有过除零和访问空指针导致程序崩溃的情况,其对应的信号分别是SIGFPE和SIGSEGV。

如果对异常信号进行捕捉会怎么样:

通过以下代码进行测试以下:

cpp 复制代码
void signal_handler(int sig)
{
    std::cout << "Divide by zero signal caught" << std::endl;
}
int main()
{
    // 对8号信号进行重定义
    signal(8 ,  signal_handler);
    while(1)
    {
        int a = 1;
        int b = 0;
        int c = a / b;
        sleep(1);
    }
    return 0;
}

现象:

根据上面的现象可以看出,确实信号被捕捉了,但是好像一直都在被调用,这是为什么呢?

下面将介绍一异常产生的过程。

我们的代码时CPU在执行,操作系统时如何知道代码中有这些非法操作???

此处先以除零为例:

在CPU中有一群状态寄存器 ,可以理解为一个位图,通过0,1表示不同的状态;

其中有一个标志位是状态表示为溢出标志位,当一个数很大溢出时,该位置就会被标记,由0变成1;

因此除零异常的流程如下:

  1. 代码中出现/0操作,导致得到的值很大出现溢出,CPU中的溢出标志位被标记,CPU出现硬件异常;
  2. 操作系统能够管理软硬件,所以自然也就知道CPU出现了异常,操作系统就会根据异常情况向进程发送信号,从而进程做出相应的反应。

CPU中的标志位被修改,会不会影响其他进程???

不会,CPU上的数据属于进程的上下文,当一个进程从CPU上拿下来的时候,也会带走该进程的上下文,来让下一次再放到CPU上执行时,知道从哪里继续,以及进程的状态情况。

野指针异常与除零错误不太一样:

  • 当CPU访问资源的时候,要拿着虚拟地址在页表上在物理地址的位置,如果发现虚拟地址没有对应的物理地址,就会发生缺页中断,CPU中同样也有一个寄存器专门存放没有对应的虚拟地址,此时操作系统会判断该虚拟地址对应的数据是还没有加载到内存中,还是非法访问。

当操作系统认为该指针正在进行非法访问,同样也会向进程发送对应的信号。

如何解释,当对异常信号进行捕捉后,没有崩溃,而是一直调用重定义的方法???

当CPU运行一个进程时,如果该进程出现异常,CPU就会出现硬件异常,在CPU中关于进程上下文的寄存器中就会标记该异常,操作系统识别异常后,向进程发送信号,进行捕捉后,并没有崩溃,而是继续在CPU上运行,此时CPU还处于硬件异常,操作系统继续向进程发送信号,循环往复。

捕获信号的目的是什么???

捕捉信号的目的并不是让我们在程序运行时对异常信号进行解决,而是让我们知道代码中有错误,要进行修正。

软件条件

异常不一定都是硬件CPU产生的,一些软件也有可能产生异常。

最常见的软件异常有:管道异常SIGPIPE,当一个管道的读端关闭后,写端进程就会收到该异常,导致写端进程被终止。

闹钟异常SIG_ALRM:

在操作系统中有一个接口:unsigned int alarm(unsigned int seconds)

  • 调⽤ alarm 函数可以设定⼀个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发SIGALRM 信号,该信号的默认处理动作是终⽌当前进程。
  • 这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。

打个比方,某人要小睡⼀觉,设定闹钟为30分钟之后响,20分钟后被⼈吵醒了,还想多睡⼀会儿,于是重新设定闹钟为15分钟之后响,"以前设定的闹钟时间还余下的时间"就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

通过闹钟异常,我们可以实现一个代码,简单验证以下IO的速度:

第一段代码是这样的:

cpp 复制代码
int main()
{
    long long count = 0;
    alarm(1);
    while(1)
    {
        std::cout << "Count: " << count << std::endl;
        count++;
    }
    return 0;
}

第二段代码是这样的:

cpp 复制代码
long long count = 0;
void signal_handler(int sig)
{
    std::cout << "Count: " << count << std::endl;
    exit(0);
}
int main()
{
    signal(SIGALRM , signal_handler);
    alarm(1);
    while(1)
        count++;
        
    return 0;
}

两个代码中闹钟都设置为1,第一组代码中IO的次数比第二组更多,看最后两个count相差多少,即进入循环的次数相差多少。

第一组:

第二组:

可以看到两者的循环次数相差很多,所以IO效率极低。

我们也可以自定义闹钟异常,如果在闹钟异常的处理方法中,再加上一个下一次的定时闹钟,就可以实现重复闹钟,让闹钟每个固定时间响一次。

在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产⽣机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据产⽣的SIGPIPE信号)等。当这些软件条件满⾜时,操作系统会向相关进程发送相应的信号,以通知进程进行相应的处理。

简而言之,软件条件是因操作系统内部或外部软件操作而触发的信号生成。

信号的默认行为种类

通过mnan 7 signal可以查看信号的不同默认行为:

  1. Term
  • 含义:Terminate(终止)。
  • 默认动作 :收到信号后,直接终止进程(无核心转储)。
  • SIGINT(Ctrl+C,终端中断)、SIGTERM(普通终止请求,可被捕获)。
  1. Ign
  • 含义:Ignore(忽略)。
  • 默认动作 :收到信号后,完全忽略,进程无任何反应。
  • SIGHUP(部分守护进程会忽略 "挂起" 信号); SIGCHLD(父进程可忽略,让系统自动回收子进程资源)。
  1. Core
  • 含义:Terminate + Core Dump(终止 + 核心转储)。
  • 默认动作 :
    1. 终止进程;
    2. 生成 核心转储文件(core dump)(记录进程终止时的内存、寄存器状态,用于调试)。
  • SIGSEGV(段错误,如空指针访问)、SIGFPE(算术错误,如除零)、SIGABRT(abort() 调用触发)。
  1. Stop
  • 含义:Stop(暂停)。
  • 默认动作 :让进程进入 "停止状态" (无法继续运行,可通过 ps 看到状态为 T)。
  • SIGSTOP(强制暂停,不可捕获 / 忽略 ,用于系统级控制); SIGTSTP(Ctrl+Z,交互型暂停,可捕获,常用于终端程序暂停)。
  1. Cont
  • 含义:Continue(继续)。
  • 默认动作 :如果进程当前处于 "停止状态" (如被 Stop 类信号暂停),则恢复进程运行。
  • SIGCONT(唯一作用是恢复暂停的进程)。

在上面有两个好像很类似:Core和Term都是终止进程,有什么区别呢?

Core在退出之前,操作系统将进程在用户空间类的数据dump(转储)到进程当前目录(磁盘)上形成core.pid文件。

在进程退出的信息中,也有core dump标志位,记录文件终止收到信号的类型,是不是Core类型。

为什么要进行core dump核心存储???

进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以⽤调试器检查core文件以查清错误原因,这叫做 Post-mortem Debug (事后调试)。

在gdb调试的时候,将核心转储文件调入进去就可以看到是那个位置出现异常,下面以除0为例:

如果你使用的是云服务器,默认core dump功能是关闭的,可以通过ulimit -a来查看,因为云服务器上运行的大多都是公司的服务端,如果服务端挂了,会自动重启,如果一个程序一直启动,一直挂,就会导致磁盘中存储大量的core.pid无效文件。

如果你希望在云服务器上打开这一功能,可以使用ulimit -c指令进行打开。

信号的发送

操作系统向进程发送信号实际上是向进程得task_struct结构体进行发送的。

在进程的结构体中,有一个位图,每个位置表示不同的信号,其中使用0,1表示信号是否产生。

因此,所谓的发信号,本质就是操作系统改变了进程task_struct中的信号位图对应的比特位。

  • 操作系统是进程的管理者,只有它有资格修改进程中的属性。

因为操作系统中位图只有0和1,因此当一个进程接收到一个普通信号后,将对应的比特位置为1后,再发送相同的普通信号也会不有反应,知道该信号被处理后,再发送信号才有反应。

实时信号的管理与普通信号不一样,实时信号被发送后会被立即处理,实时信号是通过队列来进行维护的,队列中存储信号属性的结构体,实时信号发送几次就必须被处理几次,

信号的保存

进程收到信号后,可以不立即处理,还需要结合进程自己的情况判断什么时候进行处理,因此在此期间信号必须能够被保存。

信号的几个概念:

  • 执行信号的处理动作叫信号递达Delivery;
  • 信号从产生到递达之间的状态,叫做信号未决Pending;
  • 进程可以阻塞某一种信号,当接收到被阻塞的信号后,该信号将一直处于信号未决状态,不会被处理,知道进程解开阻塞;
  • 注意:阻塞和忽略是不一样的行为,被阻塞的信号不会被递达,但是被忽略的信号会被递达,只不过对该信号的处理方法是忽略。

在一个进程的task_struct的结构体中,必须能够存储那些信号是阻塞的,那些信号是未决的,以及每个信号的处理方法:

cpp 复制代码
struct task_struct {
	struct sighand_struct *sighand;   // 每个信号对应的处理方法
	
 	sigset_t blocked;                 // 存储被阻塞的信号 
	struct sigpending pending;        // 存储处于未决的信号
}

首先先看一下struct sighand_struct结构体:

cpp 复制代码
struct sighand_struct {
	atomic_t		count;
	struct k_sigaction	action[_NSIG];
	spinlock_t		siglock;
};

struct k_sigaction {
	struct __new_sigaction 	sa;
	void __user		*ka_restorer;
};

typedef void (*__sighandler_t)(int);
struct __new_sigaction {
	__sighandler_t		sa_handler;      // 信号对应的处理方法
	unsigned long		sa_flags;
	__sigrestore_t 		sa_restorer;  /* not used by Linux/SPARC yet */
	__new_sigset_t		sa_mask;
};

sigset_t blocked的结构就比较简单,就是用一个数组来模拟位图:

cpp 复制代码
sigset_t blocked;    

typedef struct {
    unsigned long sig[_NSIG_WORDS];
} sigset_t;

上面的sigset_t被称为信号集,阻塞信号集也被称为信号屏蔽字。

struct sigpending pending使用一个链表将所有处于未决的信号连接起来:

cpp 复制代码
struct sigpending pending;

struct sigpending {
	struct list_head list;
	sigset_t signal;              // 存储没有被处于未决的信号
};

以下是上面对应的示意图:

用户如何对这些表进行操作,尤其是block表

信号集操作函数

操作系统中提供了信号集结构体sigset_t,我们对所有信号表的修改都需要通过该结构体:

  1. int sigemptyset(sigset_t *set),将信号集全部置为0;
  2. int sigfillset(sigset_t *set),将信号集全部置为1;
  3. int sigaddset(sigset_t *set , int signo),向信号集中添加一个信号,即将对应的信号置为1;
  4. int sigdelset(sigset_t *set , int signo),从信号集中删除一个信号,即将对应的信号置为0;
  5. int sigismember(sigset_t *set , int signo),在信号集中判断一个特定信号是否存在。

以上所有接口的返回值都是0表示成功,-1表示失败;

而sigset_t都是要进行操作信号集的地址,信号集要自己进行定义;
int signo表示信号编号。

最终我们将自己希望的信号集设置好后,还要将其设置进进程的task_struct结构体中:

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

  1. 返回值0成功,-1失败;
  2. 第一个参数,选项,选择如何进行操作:SIG_BLOCK新增屏蔽字,相当于mask |= set;SIG_UNBLOCK删除屏蔽字,相当于mask &= set;SIG_SETMASK设置当前信号屏蔽字为set,相当于mask = set;
  3. 第三个参数,是一个输出型参数,记录修改之前的block表,如果后续需要恢复就可以直接找到。

还有一个接口,可以让我们直接获取进程中的pending表:

int sigpending(sigset_t *set),参数是输出型参数,将pending从进程中带出来。

以上接口就这些,下面进行简单实验:将所有的信号设置为阻塞,重复打印进程中的pending表,观察pending表的变化。

代码如下:

cpp 复制代码
// 打印未处理的信号集
void PrintPending(sigset_t &set)
{
    for (int i = 1; i < 32; i++)
    {
        std::cout << (sigismember(&set, i) ? "1" : "0");
    }
    std::cout << std::endl;
}

int main()
{
    sigset_t set;
    sigfillset(&set); // 将信号集全部置为1

    // 此时我们在栈上定义了一个sigset_t,但是并没有将其传递给进程中,也就是说进程中并没有进行设置
    // 将信号集设置进进程中
    sigprocmask(SIG_SETMASK, &set, nullptr); // 阻塞信号集中信号
    
    sigset_t sig_pending;
    while (1)
    {
        sigpending(&sig_pending); // 获取当前进程中未处理的信号集
        PrintPending(sig_pending);
        sleep(1);
    }
    return 0;
}

下面看实验现象:

信号的捕捉

进程会在时机合适的时候,对信号进行处理,什么时候叫做时机合适???

答案:操作系统会在进程从内核态回到用户态的时候对信号进行检查,并对信号进行处理。

关于这一句话的含义及步骤,以下将进行详细讲解。

我们都是到,在进程地址空间中,只有0-3G属于用户,而3-4G是属于操作系统的,所以这也就意味着每个进程的地址空间中,都有操作系统的代码和数据地址,可以访问操作系统的代码和数据。

进程不能直接访问操作系统的代码和数据,操作系统不信任任何人,因此必须要有身份标识,才能进行访问,也就是进程必须处于内核态才行。

当进程调用系统调用时,就需要进程从用户态转变为内核态,才能指向内核中的方法实现。而这一转化由操作系统来完成,操作系统负责进行进程的"身份"切换。

不同的进程的代码和数据不一样,因此不同的进程用户空间时不一样的,但是所有进程都由一个操作系统进行管理,因此所有进程的内核空间中的代码和数据是一摸一样的。

  • 通过将操作系统的代码和数据映射到进程地址空间中,使得代码在执行的时候可以直接进行跳转来执行操作系统的代码,获取操作系统的数据。

操作系统是软硬件的管理者,操作系统可以指挥进程的调度,那么操作系统也是一个软件呀,操作系统的运行机制是什么,换句话说操作系统怎么知道要调度哪一个进程,要调用哪一个方法???

操作系统的内核运行在内核态(拥有最高权限),其执行依赖于硬件触发的 "事件",这些事件会主动 "唤醒" 内核进行处理:

  • 中断(Interrupt) :外部硬件(如键盘、鼠标、磁盘、网络卡)完成操作后,会向 CPU 发送中断信号,迫使 CPU 暂停当前任务,转而去执行内核中对应的 "中断处理程序"。像前面讲的键盘输入ctrl + c。
  • 异常(Exception):进程在运行中出现错误(如除以零、访问无效内存、系统调用)时,会触发 CPU 的异常机制,强制切换到内核态执行异常处理程序。
  • 时钟中断:CPU 内部的定时器会定期(如每 10ms)发送时钟中断,内核会利用这个事件触发 "进程调度"------ 暂停当前运行的进程,根据调度算法选择下一个要运行的进程,实现多任务切换。

操作系统本质就是一个死循环,操作系统会按预设逻辑进行工作:

  • 等待硬件事件(中断 / 异常),事件发生后执行相应的处理逻辑(如调度、IO 处理、资源分配),处理完后继续等待下一个事件;一直这样循环往复。

此处以时钟中断为例:

  1. 当CPU接收到时钟中断时,就会到中断向量表中执行相应的方法;
  2. 此时操作系统被唤醒,先将进程从CPU上拿下来,根据调度算法,决定将哪一个进程放到CPU上进行执行,实现进程"并发"运行。

上述的硬件中断信号和时钟中断都属于硬件中断 ,程序异常通常属于软件中断;

以上这些情况都会导致进程从用户态 进入内核态,CPU在内核中执行相应的中断处理方法。

CPU中有ecs寄存器,用来表示CPU上正在运行的进程处于用户态还是内核态。

现在我们就可以真实了解进程是如何对信号进行处理的:

先贴一张示意图:

以下是具体的步骤流程:

  1. 进程执行自己的代码;

  2. 遇到中断,异常,系统调用等,要进入内核,执行内核的相应方法;

  3. 进入内核,执行相应方法;

  4. 方法执行完毕,准备返回用户态之前,对信号的pending表,block表进行检查,看是否有信号需要进行处理;

  5. 有信号要处理,如果信号的处理方式是忽略就不做处理;如果是默认处理方法,就直接在内核中调用默认处理方法;如果用户对信号进行捕捉了,要执行用户自定义的方法,此时因为自定义的方法在用户空间中,所以要先从内核态回到用户态执行相应的方法,执行完后,再回到内核态,看是否还有信号没有处理,继续处理;

  6. 所有信号处理完毕,从内核态回到用户态中,继续从原来代码后面位置执行。

以上就是信号被处理的全流程。

信号被处理完后,需要将pending表对应位置从1置为0,那么是在信号处理完后置0,还是在要执行信号处理前置为0???

答案:在要执行捕捉方法之前,先置为0,再执行相应的方法。

如果我对信号进行自定义捕捉,如果在执行捕捉方法时,我又再次发送这一个信号,此时pending表的对应位置又置为了1,那么此时不就有可能导致在调用信号处理方法时,再次检查到该信号,再次调用该信号的捕捉方法,最终导致handler方法被循环调用???

是的,这种情况有可能发生,因此操作系统在调用一个信号的处理方法的时候,会将该信号的block表对应位置置为1,表示阻塞该信号,即使信号再次被发送也不会被执行,最等到信号的处理方法执行完后,才将block表对应位置置回0。这样就防止了同一个信号被多次递交。

在操作系统中提供了一个接口:允许我们设置在调用信号处理方法时,屏蔽那些信号。

int sigaction(int signo , const struct sigactoin* act , struct sigactoin* oact)

其中struct sigaction是操作系统提供的一个结构体:

cpp 复制代码
struct sigaction {
    __sighandler_t  sa_handler;
    unsigned long   sa_flags;
    sigset_t    sa_mask;    /* mask last for extensibility */
};
  1. sa_handler表示函数指针,表示捕捉信号后,对应的处理方法;
  2. sa_mask表示要额外阻塞的信号集.

补充

我们日常在进行开发的过程中,有些函数是不可以重复进入的,就不能同时有两个控制流在同一个函数中进行执行,如以下这个例子:

当我们在进行node1节点插入的时候,还没有对头指针进行改变之前,因为某些原因导致,进程要去执行信号的处理方法,进入到内核中调用信号对应的处理方法,而该方法中也对链表进行了修改,此时就会导致不可预料的结果。如上图所示,node2节点最后插入失败。

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

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

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

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

volatile关键字

volatile关键字用来保证内存可见性。

当一个数据在正常执行流中。不会被修改,只会被读取,此时编译器为了更快的访问到该数据,就会将数据放到寄存器中。

但是如果因为一些原因,导致数据被修改,而CPU依旧使用寄存器中原来的数据,而不是重新在内存中进行读取就会导致,数据读取错误。

cpp 复制代码
int flag = 1;
void signal_handler(int sig)
{
    flag = 0;
    std::cout << "flag changed to 0" << std::endl;
}
int main()
{
    signal(2, signal_handler); // 设置信号处理函数
    while(flag) ;
    std::cout << "flag is 0, exit" << std::endl;
    return 0;
}

可以通过执行以上代码,看如果发送二号信号进程是否退出了:

可以看到通过ctrl + c,flag的值确实改变了,但是编译器是从寄存器中拿的flag值,因此一直拿到的都是0;

如果我们希望每一次在使用这个变量的时候,都去内存中拿,可以通过在该变量前面加上volatile关键字;同样如果我们希望一个变量放到寄存器中让获取更快,可以使用register关键字。

编译器也有优化等级,不同的优化等级,编译器处理的优化的成不不同:O0 , O1 , O2 , O3... O0表示优化最高,后面优化逐渐下降。

SIGCHLD信号

子进程在退出前会向父进程发送SIGSCHLD信号,告诉父进程代码执行完了,要退出了。

所以在进行子进程回收的时候,可以对SIGCHLD进行捕捉,来进行进程等待,获取子进程的退出信息。

在前面我们谈到在进行SGICHLD捕捉的时候,会将该信号加入到阻塞中,如果此时有多个子进程都退出,都发送了SGICHLD信号,但是我们在pending表中只能设置一个,就会导致其他有些子进程没有被回收。

为了解决上面的问题,在进行信号等待的时候使用轮询式的等待方式,而不是只等待一个子进程,一次性等待多个子进程即可。

cpp 复制代码
void signal_handler(int signo)
{
	while(waitpid(-1 , nullptr , WNOHANG) > 0 )
	{
		;
	}
}

我们同样也可以通过signal将SIGCHLD信号设置为忽略,此时子进程退出时就会直接退出,父进程不用进行回收了,同样父进程也就获取不了子进程的退出信息。

相关推荐
律宏阔39 分钟前
WSL Docker 端口明明空闲,但就是绑不上端口
linux·windows
律宏阔39 分钟前
WSL 突然断网,无法 ping 内网或外网
linux·windows
穷人小水滴40 分钟前
用容器编译 VirtualBox 虚拟机软件 (ArchLinux, podman)
linux·容器·virtualbox
乱码三千1 小时前
如何优雅地直连无公网 IP 的远程 GPU 服务器
linux·人工智能·程序员
yunwei371 小时前
使用 eBPF 跟踪 Nginx 请求
linux·后端·性能优化
yunwei371 小时前
使用 eBPF 跟踪 MySQL 查询
linux·后端·性能优化
yunwei371 小时前
eBPF 示例教程:使用 XDP 捕获 TCP 信息
linux·后端·性能优化
judezh1 小时前
api 容器一直 unhealthy?我把一个 Agent 运行时的健康检查逐条拆了,查出四个问题
运维·docker
GeW1 小时前
制造业高端化,为什么必须重估Linux和数据库?
linux
GeW1 小时前
工业场景中的Linux与数据库选型:高端化转型的技术底座
linux