当前位置: 首页 > news >正文

Linux进程信号(上)

目录

预备

信号的产生

产生信号的方式:键盘产生信号

前后台移动

jobs: 查看后台进程信息

fg: 将后台进程放置到前台

bg : 让后台进程恢复运行

产生信号的方式:函数调用

kill调用

raise调用

abort 函数

产生信号的方式:kill命令级

产生信号的方式:硬件异常产生

信号产生的方式:软件条件

简单快速理解系统闹钟


预备

我们上一篇进程间通信(补充)https://blog.csdn.net/Small_entreprene/article/details/146120541?fromshare=blogdetail&sharetype=blogdetail&sharerId=146120541&sharerefer=PC&sharesource=Small_entreprene&sharefrom=from_link学习了信号量。那么我们本篇的信号和信号量有什么关系?

其实信号VS信号量=老婆:老婆饼,说人话就是没有任何关系!


那么信号到底是什么呢?

信号是一种特殊的事件通知机制,用于在不同实体之间传递信息或中断当前正在进行的操作。在生活中,信号无处不在。例如,闹钟的铃声和红绿灯的灯光都是信号。红绿灯是信号源,不同颜色的灯光代表着不同的信号,提醒人们做出相应的反应。信号的一个重要特性是它能够中断人们当前正在进行的活动。比如,当你正在睡觉时,闹钟响起的信号会打断你的睡眠,让你不得不从休息状态切换到起床的动作。

在计算机系统中,信号的作用与生活中的信号类似。进程可以理解为正在运行的程序实例,就像人正在执行某项任务一样。假设一个进程正在执行“睡觉”的代码,即处于等待或空闲状态。此时,如果一个信号(比如闹钟信号)到达,它会中断进程当前的执行状态,强制进程停止当前的“睡觉”操作,转而处理信号所代表的事件。这种机制使得进程能够及时响应外部事件,而无需一直等待事件的发生,从而提高了系统的灵活性和响应能力。

所以,信号就是一种给进程发送的,用来进行事件异步通知的机制!

这里需要澄清一个重要的概念:信号的产生是异步的,但信号的处理是同步的。这听起来有些矛盾,但其实反映了信号机制的精妙之处。我们可以通过生活中的例子和计算机中的例子来进一步解释。


假设你正在家里看书(这是你的主要任务)。突然,门铃响了(信号)。门铃的响起是异步的,因为它在你专注于看书的时候突然发生,你无法预知它什么时候会响。然而,当你听到门铃声后,你必须停下手中的书,去开门(处理信号)。这个过程是同步的,因为你必须中断当前的任务(看书),去处理门铃这个事件。

在这个例子中:

  1. 异步产生:门铃的响起是异步的,因为它在你执行主要任务(看书)的过程中突然发生,你无法预知它的到来。

  2. 同步处理:当你听到门铃声后,你必须停下来去开门,这个过程是同步的,因为你在处理信号时无法同时继续看书。(不过可以拖拉一会儿再去开门)


在计算机系统中,信号的产生和处理机制也类似。假设一个进程正在执行一个复杂的任务(比如运行一个长时间的计算程序)。突然,操作系统检测到一个硬件错误(比如内存不足),于是向该进程发送一个信号(比如 SIGSEGVSIGINT)。

异步产生

  • 信号的产生是异步的,因为它可以在进程执行任何代码时突然发生。操作系统或其他进程可以在任何时刻发送信号,而进程无法预知信号的到来。

  • 例如,硬件错误或用户按下 Ctrl+C(发送 SIGINT)都是不可预测的事件。

同步处理

  • 当信号到达时,操作系统会中断进程的当前执行状态,强制进程切换到信号处理程序(signal handler)。

  • 在信号处理程序运行期间,进程无法继续执行被中断的任务。只有当信号处理程序执行完毕后,进程才会恢复到被中断的状态,继续执行之前的任务。

  • 这个过程是同步的,因为进程在处理信号时必须暂停当前任务。


为什么信号的处理是同步的?

信号的处理是同步的,有时候信号代表了紧急或重要的事件,需要立即处理。例如:

  • 如果进程收到一个内存不足的信号(SIGSEGV),它必须立即处理,否则可能会导致程序崩溃。

  • 如果用户按下 Ctrl+C(发送 SIGINT),进程需要立即响应用户的请求,停止当前任务。

如果信号的处理不是同步的,进程可能会忽略这些紧急事件,导致程序行为异常或系统资源无法正确管理。

其实,对于信号处理,进程在信号没有产生的时候,早就知道信号该如何处理了!(人能识别信号,是因为从小的所见所闻,是被“教育”过的,进程也是如此,OS程序员设计的进程,进程中其实早就内置了对于信号的识别和处理方式!!!)

现实生活中,信号源很多,一样的,给进程产生信号的信号源也非常多,通过后面的学习,我们会更好的认识与理解!

但是同步是同步,处理是处理,两者有本质的区别,信号的处理可以是不立即处理的,而是可以等一会儿再处理,在合适的时候,进行对信号的处理,但是要满足同步的机制。


信号是一种异步通知机制,它的产生是异步的,但它的处理是同步的。这种机制的优点在于:

  1. 异步产生:信号可以在任何时刻被触发,不会干扰进程的正常执行流程。

  2. 同步处理:信号的处理是同步的,确保进程能够及时响应紧急事件,避免程序崩溃或资源管理问题。

通过生活中的例子(如门铃响起)和计算机中的例子(如进程处理信号),我们可以更好地理解信号机制的异步性和同步性。

信号是发送给进程的,所以我们为什么本篇的标题是---《Linux进程信号》

带着上面的预备知识,我们开始学习信号的相关行为,我们先从信号的产生开始。

信号的产生

信号产生的方式其实非常多:

产生信号的方式:键盘产生信号

上面提到的,当一个进程运行,正在被调度的时候,如果用户用户按下 Ctrl+C,这种操作本质是给目标进程发送信号的。其实相当一部分的处理动作,就是让自己进程终止!!!

我们基于上面这一个场景,来逐步引出相关知识:

  • 我们现在还没有真正见过信号呢,那么信号都有哪些?

我们可以使用命令:

kill -l

来查看信号列表:

其实在计算机当中,信号就是一个整数数字,我们未来要编程使用信号的时候,可以使用数字,当然,数字的可读性比较差,也可以使用信号名,这个信号名其实就是根据整数数字所代表的宏!

Linux当中,并不是就是64个信号,我们可以细心观察,有31,没32,33,所以有62=64-2个信号!从34~64的信号称为实时信号。1~31为普通信号。

普通信号(1~31):用于常见的进程控制和通信,如终止进程(SIGTERM)、中断进程(SIGINT)等,共31个。
实时信号(34~64或更高):从34开始,数量因系统而异,通常用于实时应用,支持更高优先级和自定义信号处理。

和我们上面说的相关:信号的处理不是立即处理的,在合适的时候再处理,这种可以不被立即处理的信号(当然了,也可以被立即处理),这是普通信号,而实时信号往往一旦产生就需要立即处理! 

其实收到信号,后处理信号有哪些动作?马上终止进程?还有什么?我们可以分成三大类:

  1. 默认处理动作:就像生活中遇到突发情况时的本能反应。比如,突然听到警报声,大多数人会本能地停下手中的事情,寻找安全的地方躲避。类似地,进程接收到信号后,如果没有特别的处理方式,就会按照系统默认的行为执行,比如终止运行。

  2. 自定义信号处理动作:就好比你在听到警报声后,根据之前制定的应急计划,迅速找到重要物品并按照预定路线撤离。这是一种经过提前规划和准备的应对方式。在进程里,开发者可以根据需求定义信号的处理逻辑,让进程在接收到信号时执行特定的操作,而不是简单地终止。

  3. 忽略处理:这就好比你对某个烦人的闹钟声已经习惯了,每次它响的时候你都选择不去理会,继续做自己的事情。在信号处理中,进程也可以选择忽略某些信号,就像对闹钟声充耳不闻一样,让信号对进程没有任何影响。

所以,进程收到信号之后,在合适的时候处理信号,在三种处理动作中选择一种,其中相当一部分进程处理信号时,都是让自己终止,因为相当一部分信号处理的动作就是默认处理动作,默认处理动作相当多的是让进程终止。

信号处理动作都称为捕捉!但是自定义信号处理动作是自定义捕捉!


  • Ctrl+C这种键盘组合方式究竟是如何给进程发送信号的?(这一点涉及到从硬件到系统,从系统到进程的整个过程,这个我们下一篇谈)虽然现在不知道,但是总要知道最后发的是什么信号吧?

其实Ctrl+c就是给进程发送2号信号。那怎么证明该进程收到的信号就是2号信号呢?

这种Ctrl+c造成

对于信号的处理动作,我们有上面提及的三种,如果默认的信号处理动作不能满足了,我们需要自定义信号处理动作:

就像生活中遇到突发情况时,我们会有本能的反应(比如听到警报声就本能地躲避),进程在接收到信号时也有默认的行为——比如接收到 Ctrl+C(SIGINT)信号时,默认会终止运行。但如果希望进程在遇到信号时做出不同的反应,比如优雅地保存数据后再退出,或者完全忽略这个信号,就需要我们为进程设置自定义的信号处理动作。

具体来说,我们可以通过编程让进程“学会”如何处理信号。这就好比给一个人制定一套应对突发事件的预案,让他在遇到情况时按照既定计划行动,而不是盲目地做出本能反应。在 Linux 中,实现这一功能的关键是调用 signal()sigaction() 系统调用,通过它们为进程指定一个自定义的信号处理函数,从而让进程在接收到信号时执行我们定义的逻辑,而不是默认的终止行为。

signal() 是一个简单的信号处理接口,用于设置进程对特定信号的处理函数。

#include <signal.h>
typedef void (*sighandler_t)(int);// 函数指针类型
sighandler_t signal(int signum, sighandler_t handler);

signum:要处理的信号编号,例如 SIGINT(2)表示 Ctrl+C。

handler:信号处理函数的指针(函数是int为参数,void为返回值),也可以是以下特殊值:

  • SIG_IGN:忽略信号。

  • SIG_DFL:恢复为默认行为。

返回值:成功时返回之前的信号处理函数指针。失败时返回 SIG_ERR

不在执行默认的对应的signum的信号处理动作,而是接收到signum的行为是使用函数指针指向的方法!

示例:

#include <iostream>
#include <unistd.h>
#include <signal.h>

void handlerSig(int sig)
{
    std::cout << "获得了一个信号: " << sig << std::endl;
}
int main()
{
    signal(SIGINT, handlerSig);
    int cnt = 0;
    while (true)
    {
        std::cout << "hello world, " << cnt++ << std::endl;
        sleep(1);
    }
    return 0;
}

 我们编译运行,使用Ctrl+c,观察:

进程不终止了,因为将之前的终止方法改成了打印语句!!侧面证明了Ctrl+c为进程发送的是2号信号。 那怎么那该进程退出呢?其中,我们可以使用 Ctrl+\ 来终止进程:(这是为进程发送3号信号)

man 7 signal

 可以用来查看信号详细的默认处理动作:

信号是Unix和类Unix操作系统中用于通知进程发生了某个事件的一种机制。其中许多信号的默认行为是终止进程,比如SIGKILL和SIGTERM。

关于“Action”字段,它描述了当进程接收到特定信号时默认会采取的动作:

  • Term:终止进程。

  • Ign:忽略该信号。

  • Core:终止进程并生成核心转储文件,用于调试。

  • Stop:暂停进程执行。

  • Cont:继续执行被暂停的进程。

Term和Core都有终止进程的行为,之间的区别,我们后面详谈。所以说相当一部分的信号默认处理动作都是终止操作的!


我们上面说到,Ctrl+c是给目标进程发送信号的,那目标进程指的是什么进程?

现在我们粗力度的来理解一下进程的类别,我们今天先来谈前台和后台进程,等我们学习到网络的时候,还有一个精灵进程或守护进程。

使用(./testsig)

对于我们上面的代码,我们在程序运行期间输入ll,pwd等命令,程序并没有输出ll,pwd对应的效果,现在,我们使用:(./testsig &)

这里,我们简单理解:

  1. ./xxx ---前台进程
  2. ./xxx & ---后台进程

我们在没有执行任何程序的时候,我们的系统里一定有一个进程在不断为我们提供服务:

命令行Shell进程,命令行Shell进程就是前台进程。

从上面的演示我们可以观察:如果我们将一个进程放在后台,Ctrl+c没有做处理,没有捕捉信号,没有打印捕捉到的信号值。所以我们就可以知道:键盘产生的信号,只能发送给前台进程!

那我们现在该怎么终止该后台进程呢?


打开另一个XShell,进入命令行当中,先确定能否查到该进程:

ps ajx | head -1 && ps ajx | grep testsig | grep -v grep 
lfz@HUAWEI:~/lesson$ clear
lfz@HUAWEI:~/lesson$ ps ajx | head -1 && ps ajx | grep testsig | grep -v grep 
   PPID     PID    PGID     SID TTY        TPGID STAT   UID   TIME COMMAND
 983796  990700  990700  983678 pts/7     983796 S     1000   0:00 ./testsig

然后使用kill -9命令:(9是一个杀掉进程的很强势的信号)

kill -9 [对应进程pid]

之后就完成了对该后台进程的终止。这里kill -9就是给对应pid进程发信号!


我们来谈谈前后台的问题:

我们一旦启动登入我们的Linux系统,Linux系统通常会为我们启动一个Shell进程。在大多数Linux发行版,包括Ubuntu和CentOS中,默认的Shell往往是bash(Bourne Again SHell)。就会为用户输出命令行,scanf处等待键盘的输入。

后台进程是无法从标准输入(键盘)中获取内容的!前台进程就和后台进程相反,前台进程可以从标准输入(键盘)中获取内容!但是两种进程都可以向标准输出(显示器)上打印!这是为什么呢?

其实是因为键盘只有一个,输入数据一定是给一个确定的进程的,这也就造成了前台进程必须只有一个!!!而后台进程可以有多个!!!

所以前台进程的本质就是要从键盘上获取数据的!!!(谁想获取数据就放前台,不想获取数据就放后台,键盘组合键也是键盘输入的数据)

我们现在拿着这个结论,来解释上面的演示的现象:

我们 ./testsig 运行程序的时候,输入" ll " " pwd "等等命令,不做任何反应,是因为当如此(./testsig)运行的时候,该进程就变成了前台进程,因为前台进程只能有一个,那么bash进程就自动被系统换成后台进程了,所以输入" ll "等命令,bash进程接收不到这些从键盘上输入的命令了,输入的"ll"的字符其实是输给 testsig 进程的,只不过 testsig 没有调用 scanf/cin 等函数调用,导致没有办法读到数据。

我们 ./testsig & 运行程序的时候,输入" ll " " pwd "等等命令,会做相应反应,是因为当如此(./testsig & )运行的时候,该进程就变成了后台进程,因为前台进程只能有一个,这个前台进程依旧是bash,所以输入的" ll "等命令是被bash进程scanf接受了!!!


父进程fork创建子进程后,子进程先退出并没有什么问题,但是父进程先退出了,子进程就变为孤儿进程了,这时候如果孤儿进程还在循环打印消息的时候,我们Ctrl+c是终止不了进程的,这是因为什么?

当一个父进程创建了一个子进程后,子进程会继承父进程的前台或后台状态。如果父进程是前台进程,那么子进程也会是前台进程;如果父进程是后台进程,那么子进程也会是后台进程。

然而,当父进程退出时,子进程的状态不会自动改变。子进程仍然是它原来的状态(前台或后台)。如果父进程是前台进程,并且它退出了,那么它的子进程(如果存在)会变成孤儿进程,但它们仍然是前台进程,但是当父进程退出时,其子进程(即孤儿进程)会被 init 进程收养。这些孤儿进程会被自动移动到后台进程当中,因此,即使孤儿进程是前台进程,如果它们不属于当前终端的前台进程组,Ctrl+C 也可能无法终止它们!


接下来,我们补充一部分命令,来证明进程是可以进行前后台移动的:

前后台移动

jobs: 查看后台进程信息

jobs 命令显示当前shell的所有后台作业及其状态:

jobs

你可以查看哪些作业在后台运行,以及它们的作业号。

图中的【1】+pid,这个 1 其实是任务号/作业号,等我们谈到守护进程的时候再来详谈,我们知道这个任务是在后台的,所以我们使用jobs就可以查看到后台有一个进程任务,正在Running(打印时混在一起是因为显示器在多进程当中是被当作共享资源)

接下来:

fg: 将后台进程放置到前台

fg 命令用于将最后一个被置于后台的作业(进程)移至前台:

fg

如果有多个后台作业,你可以指定作业号来选择哪一个作业移至前台:

fg %1

这里的 %1 是作业号。

对于Ctrl+z进行暂停操作,因为前台进程只有一个,所以前台进程不能被暂停,不然暂停之后,怎么按键盘就没有反应了。

其实一个进程一旦被暂停,那么这个进程会自动放置到后台:

那我们怎么让这个后台任务运行起来呢?

bg : 让后台进程恢复运行

如果一个进程被意外地置于后台(例如,通过按Ctrl+Z暂停),可以使用 bg 命令将其重新置于后台:

bg %1

这里的 %1 是作业号,表示上一个作业。

说到这,我们一直要解决的什么是目标进程,现在就好理解了,该目标进程就是前台进程!


我们知道,信号的本质其实就是一个数字,那什么叫做给进程发信号呢?

信号的产生并不是立即处理的,所以要求进程必须把收到的信号记录下来!记录的目的是为了在合适的时机处理!那信号记录在哪?如何记录的?(我们重点考虑普通信号【1~31】)

因为信号是发送给进程的,信号是记录在进程控制块PCB(task_struct结构体)当中的类似:

struct task_struct
{
    unsigned int sigs;
}

一个进程可以接收到多个信号,所以sigs其实是一个位图结构!!!比特位的位置就是信号编号,比特位的内容,代表的是是否收到。所以说发送信号的本质是向目标进程写信号,就是修改位图!

修改位图需要的是进程pid和信号编号,这也是为什么kill -9 pid了!!!

但是task_struct是属于系统内的数据结构对象!修改位图本质就是修改内核的数据,但是作为普通用户是没有办法直接修改内核的数据的,只有操作系统自己能够直接修改。所以不管信号怎么产生,发送信号,在底层必须让给OS发送!!!所以OS要为我们提供发送信号的系统调用!

操作系统为用户提供的·1系统调用有很多,其中kill就是一个:

kill 系统调用的主要功能是向一个或多个进程发送指定的信号。信号是操作系统与进程之间通信的一种方式,用于通知进程发生了某些事件,例如中断、错误、终止等。

#include <sys/types.h>
#include <signal.h>

int kill(pid_t pid, int sig);
  • pid:目标进程的进程 ID(PID)。如果 pid 为正数,则信号发送给该进程;如果 pid 为负数,则信号发送给进程组;如果 pid 为 0,则信号发送给调用进程所在的进程组。

  • sig:要发送的信号。信号是一个整数值,例如 SIGTERM(终止信号)、SIGKILL(强制终止信号)、SIGINT(中断信号)等。

我们用户不用担心信号处理的先后,这是OS的事情。


那么信号和通信IPC有什么关系呢?

狭义上讲:IPC和信号不一样,IPC是进程和进程之间的数据交互,信号本质是操作系统和进程之间的数据交互。

广义上讲:我们也可以将信号理解为广义上的通信范畴,因为信号也是某种事件的通知机制,虽然不以传递数据为目的,但是他也是双方的交互。


产生信号的方式:函数调用

kill调用

产生信号的方式---调用系统命令向进程发信号---kill

知道了kill系统调用,我们可以自己封装一个函数来实现命令级终止进程:

//mykill.cc
#include <iostream>
#include <sys/types.h>
#include <signal.h>

int main(int argc, char *argv[])
{
    if (argc != 3)
    {
        std::cout << "./mykill signumber pid" << std::endl;
        return 1;
    }
    int signum = std::stoi(argv[1]);
    pid_t target = std::stoi(argv[2]);

    int n = kill(target, signum);
    if (n == 0)
    {
        std::cout << "send " << signum << " to " << target << " success." << std::endl;
    }
    return 0;
}

所以信号的产生方式处理从键盘上产生,还有系统调用!

当我们对信号做自定义捕捉的时候,我们假设进程捕捉的信号都是被自定义为打印信息的行为,那是不是变得刀枪不入了呢?

    for (int i = 1; i <= 31; i++)
    {
        signal(i, handlerSig);
    }

这样的话这个进程不就不会被终止了吧?实则不然,其实9号信号是强制性的:为了防止某些恶意进程屏蔽或自定义捕捉掉所有信号,那么系统就不会允许所有的信号能被自定义捕捉,打本份是可以的,但是系统要求9号信号:SIGKILL,所以9号信号是不能被自定义捕捉的!!!(还有19号信号:SIGSTOP不能被自定义捕捉)

除了kill系统调用可以产生信号,还有:

raise调用

raise 是一个标准库函数,用于向调用它的进程自身发送信号。它的原型如下:

#include <signal.h>

int raise(int sig);
  • sig 参数:指定要发送的信号编号,如 SIGINTSIGABRT 等。

  • 功能raise 函数的作用是向调用它的进程自身发送指定的信号。它是一个便捷的函数,用于在程序内部触发信号处理机制。

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>

void handlerSig(int sig)
{
    std::cout << "获得了一个信号: " << sig << std::endl;
    // exit(12);
}
int main()
{
    for (int i = 1; i <= 31; i++)
    {
        signal(i, handlerSig);
    }
    for (int i = 1; i <= 31; i++)
    {
        if (i == 9 || i == 19)
        {
            continue;
        }
        sleep(1);
        raise(i);
    }
    int cnt = 0;
    while (true)
    {
        std::cout << "hello world, " << cnt++ << " ,pid: " << getpid() << std::endl;
        sleep(1);
    }
    return 0;
}

abort 函数
#include <stdlib.h>

void abort(void);

abort 是一个标准库函数,用于终止程序的执行,并向调用它的进程发送 SIGABRT 信号。它通常用于程序运行时检测到不可恢复的错误或异常情况时,主动终止程序并触发异常处理机制。

发送信号abort 会向调用它的进程发送 SIGABRT 信号(6号)。

信号处理

  • 如果进程为 SIGABRT 信号设置了自定义的信号处理器,则会执行该处理器。
  • 如果没有设置信号处理器,或者信号处理器的行为是默认的,则进程会终止,并生成一个核心转储文件(core dump),便于调试。

程序终止无论信号处理器是否被调用,abort 最终都会导致程序终止(会自动将自定义捕捉动作改为默认动作,这也是为什么我们可以自定义捕捉6号信号,使用abort系统调用还可以进行程序的终止操作),返回状态码为 1 或其他非零值。

产生信号的方式:kill命令级

我们上面知道了产生信号的方式---调用系统命令向进程发信号---kill命令!

产生信号的方式:硬件异常产生

 我们在写程序的时候可能会崩掉:

char *msg = "hello";
*msg='H';

我们知道,在语法层面,字符串常量是常量,常量是无法修改的,后面我们学习了虚拟地址空间,字符串常量其实是经过页表映射到对应的内存当中的,常量对应的页表当中有相关权限,证明当前的"hello"是只读的,没有写的权限,但是操作系统不让写并不是进程终止退出的理由。

最经典的就是除0和野指针:

void handlerSig(int sig)
{
    std::cout << "获得了一个信号: " << sig << std::endl;
    exit(13);
}

8号是SIGFPE信号,除0越界了,是浮点错误。我们观察到我们除0运行之后,程序在Windows或Linux下就直接挂掉了,因为观察到进程被发送接收到了8号信号。

下面,我们看看野指针错误是发送的哪一个信号:

11号是SIGSEGV信号,是段错误!

信号全部都是操作系统发送给对应进程的,上面的问题是因为程序犯错了,然后OS识别带对应进程犯错了,识别错误类型,然后发送信号,那么操作系统是怎么知道进程犯错了?

1.操作系统怎么知道程序除0了?

除0本质就是运算,是在CUP上运行的,而CPU是有对应的各种寄存器,其中有一个寄存器叫做状态寄存器(EFLAGS),其中有一个比特位代表CUP当前计算时,是否出现溢出。CPU是属于硬件的,操作系统是软硬件资源的管理者,所以程序一旦出错了,操作系统能识别硬件上出错的问题,发现计算溢出,而CPU寄存器保存的是当前进程的上下文,所以就向该目标进程发送8号信号

 2.操作系统怎么知道程序有野指针行为?

操作系统本身并不直接检测野指针行为,因为野指针本质上是程序逻辑错误,而非硬件或操作系统层面的异常。操作系统通过硬件保护机制(如 MMU 的页面错误)间接发现非法内存访问行为。当程序访问未分配或非法的内存时,MMU 触发页面错误,操作系统捕获后会终止程序运行。此外,现代工具如 AddressSanitizer 和 Valgrind 可以在运行时检测野指针行为,编译器的静态检查功能也能在编译阶段发现潜在问题。这些机制共同帮助开发者发现和修复野指针问题,从而提高程序的稳定性和安全性。

自此,我们又可以知道一种产生信号的方式:硬件异常! 

信号产生的方式:软件条件

我们在学习管道的时候,我们可以基于管道实现进程间通信,一个进程作为写端,一个进程作为读端,如果把读端关闭了,那么写进程就会被终止,被操作系统终止,因为操作系统不仅仅能够识别异常所对应的硬件错误,操作系统也同样可以识别软件错误,管道是文件,文件缓冲区,文件描述符...本质都是软件概念,所以当软件条件不具备时,操作系统就会通过发送信号的形式给该进程发送SIGPIPE信号。

SIGPIPE 是⼀种由软件条件产⽣的信号,在“管道”中已经介绍过了。下面主要介绍 alarm 函数
SIGALRM 信号。

alarm函数用于设置一个定时器,在指定的秒数(seconds参数)后向当前进程发送SIGALRM信号。若未捕获或处理该信号,默认动作是终止进程。

#include <unistd.h>

unsigned int alarm(unsigned int seconds);
  • seconds:表示定时器的超时时间,单位是秒。如果设置为 0,则会取消之前设置的定时器。

返回值特性:

  • 返回剩余时间若之前已设置闹钟,返回剩余秒数;否则返回0。
  • 覆盖性调用:重复调用alarm会覆盖之前的定时器,新时间取代旧时间。
  • 取消定时器alarm(0)取消所有已设置的定时器,并返回剩余时间。

示例
若先设置alarm(30),20秒后调用alarm(15),则返回值为10(原剩余时间),新定时器15秒后触发。


SIGALRM信号的默认处理与捕捉

SIGALRM信号的默认处理动作是终止进程。因此,未捕获该信号的程序会在定时器到期时直接退出

IO操作对程序性能的影响

1. 高IO场景的性能瓶颈

在用户提供的高IO示例中,程序频繁调用std::cout输出计数器的值,导致性能低下:

alarm(1);
while(true) {
    std::cout << "count: " << count << std::endl;
    count++;
}

输出结果:计数器仅达到约10万次。
原因

  • std::cout涉及系统调用和缓冲区同步,占用大量CPU时间。
  • 频繁的IO操作导致进程频繁切换至内核态,实际执行计算的CPU时间减少。

2. 低IO场景的性能优化

低IO示例中,仅在信号处理函数中输出一次计数器值:

void handler(int signo) {
    std::cout << "获得了一个信号: " << sig << count << std::endl;
    exit(13);
}
alarm(1);
int count = 0;
while(true) { count++; }

输出结果:计数器可达数亿次。
原因

  • 消除了频繁IO操作,CPU时间几乎全用于递增计数器。
  • 缓冲区优化减少了系统调用开销。

实现周期性信号的技巧

1. 信号处理函数中重置定时器

在信号处理函数内再次调用alarm,可周期性触发SIGALRM

void handler(int signo) {
    // 执行周期性任务
    std::cout << "获得了一个信号: " << sig << std::endl;
    alarm(1); // 重置定时器
}
signal(SIGALRM, handler);
alarm(1);

此方法使每1秒触发一次信号,形成循环。

  • 信号安全性:信号处理函数应避免使用非异步安全函数(如printf)。
  • 竞争条件:定时器重置需在信号处理函数内完成,确保时序正确。

pause函数的作用与使用场景

pause使进程挂起,直到收到任何信号。若信号被捕获,处理函数执行后pause返回-1(errno=EINTR)。

典型应用

  • 阻塞等待信号:避免忙等待(Busy Waiting),节省CPU资源。
  • 信号驱动逻辑:与alarm结合实现超时机制,或等待异步事件
signal(SIGALRM, handler);
alarm(5);
pause(); // 等待SIGALRM或其它信号

现在,我们通过alarm和pause来实现一个代码:

代码实现了一个简单的周期性任务调度器,模拟操作系统后台定时执行任务的行为。其主要目的是每秒触发一次定时信号(SIGALRM),在信号处理函数中依次执行注册的进程调度、内存管理和数据刷新任务(对应Sched/MemManger/Fflush函数),形成类似于操作系统周期性维护任务(如CPU调度、内存回收、磁盘同步)的机制,通过alarm重复设置实现持续定时触发,最终达到定期自动化执行多个后台任务的效果。

#include <iostream>
#include <functional>
#include <vector>
#include <unistd.h>
#include <signal.h>

// 定义任务函数 //
void Sched() {
    std::cout << "我是进程调度" << std::endl;
}

void MemManger() {
    std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl;
}

void Fflush() {
    std::cout << "我是刷新程序,我在定期刷新内存数据,到磁盘" << std::endl;
}
/

// 使用 std::function 定义任务函数的类型
using func_t = std::function<void()>;

// 任务函数列表
std::vector<func_t> funcs;

// 时间戳,用于记录信号触发的次数
int timestamp = 0;

// 信号处理函数
void handlerSig(int sig) {
    timestamp++; // 每次信号触发时,时间戳加 1
    std::cout << "##############################" << std::endl;

    // 遍历任务列表,依次调用每个任务函数
    for (auto f : funcs) {
        f();
    }

    std::cout << "##############################" << std::endl;

    // 重新设置 alarm,确保 1 秒后再次触发信号
    alarm(1);
}

int main() {
    // 将任务函数添加到任务列表中
    funcs.push_back(Sched);
    funcs.push_back(MemManger);
    funcs.push_back(Fflush);

    // 注册信号处理函数,将 SIGALRM 信号绑定到 handlerSig
    signal(SIGALRM, handlerSig);

    // 设置第一次信号触发时间为 1 秒后
    alarm(1);

    // 主循环:程序通过 pause() 阻塞,等待信号到来
    // 每次信号触发时,handlerSig 会被调用,然后程序继续阻塞
    while (true) {
        pause();
    }

    return 0;
}

通过pause,我们可以让一个进程处于暂停状态,然后由外部的信号来驱动,每隔一秒钟,完成一些对应的任务。这就是操作系统,操作系统的本质就如同上面写的进程,操作系统在卡机后就一直处于死循环:

    while (true) {
        pause();
    }

操作系统在运行的时候,并不是操作系统自己主动愿意运行的,操作系统也是牛马!(在被人的催促之下,然后每隔一段时间,别人给操作系统发送类似如上的信号机制,其实是叫时钟中断,然后叫操作系统执行自己注册所对应的方法,然后每隔一段时间执行一下,我们设定的时间是一秒钟,如果时间设置为0.000000000000001s,那么操作系统就会超高频的执行对应的工作!

依此,我们就可以自己定义一个struct task_syruct,按照先描述再组织,模拟完成进程的调度:

基于上述代码的思路,我们可以进一步扩展功能,定义一个 struct task_struct 来模拟进程的结构,并实现一个更复杂的进程调度模拟。这种方式可以更好地展示进程调度的原理,包括进程的状态、优先级、调度策略等。

以下是实现步骤和代码示例:

1. 定义 task_struct 结构

task_struct 是模拟进程的结构体,包含以下信息:

  • 任务名称:用于标识任务。

  • 任务函数指针:指向任务的具体执行函数。

  • 优先级:用于模拟优先级调度。

  • 状态:表示任务的状态(如就绪、运行、阻塞)。

  • 时间片:用于模拟时间片轮转调度。(时间片轮转调度通过将处理器时间分割成小的时间片,使得多个任务可以“同时”运行(实际上是交替运行)。)

2. 组织任务列表

将所有任务存储在一个列表中,并根据调度策略(如先来先服务、优先级调度、时间片轮转)选择任务执行。

3. 实现调度逻辑

在信号处理函数中,根据调度策略选择任务执行,并模拟任务的调度过程。


定义任务结构体

#include <iostream>
#include <vector>
#include <functional>
#include <unistd.h>
#include <signal.h>
#include <algorithm>

// 定义任务结构体
struct task_struct {
    std::string name;          // 任务名称
    std::function<void()> func; // 任务函数指针
    int priority;              // 优先级(数值越小优先级越高)
    int state;                 // 任务状态:0 - 就绪,1 - 运行,2 - 阻塞
    int time_slice;            // 时间片

    task_struct(std::string n, std::function<void()> f, int p, int s, int ts)
        : name(n), func(f), priority(p), state(s), time_slice(ts) {}
};

定义任务调度器

// 任务调度器类
class TaskScheduler {
public:
    TaskScheduler() {}

    // 添加任务
    void add_task(std::function<void()> func, std::string name, int priority = 0, int time_slice = 1) {
        tasks.emplace_back(name, func, priority, 0, time_slice); // 默认状态为就绪
    }

    // 执行任务调度
    void schedule() {
        // 按优先级排序(优先级低的任务优先执行)
        std::sort(tasks.begin(), tasks.end(), [](const task_struct& a, const task_struct& b) {
            return a.priority < b.priority;
        });

        // 遍历任务列表,执行每个任务
        for (auto& task : tasks) {
            if (task.state == 0) { // 只执行就绪状态的任务
                task.state = 1; // 设置为运行状态
                std::cout << "正在执行任务: " << task.name << std::endl;
                task.func(); // 执行任务函数
                task.state = 0; // 设置为就绪状态
            }
        }
    }

private:
    std::vector<task_struct> tasks; // 任务列表
};

主程序和信号处理函数

// 信号处理函数
void handlerSig(int sig) {
    static TaskScheduler scheduler; // 创建任务调度器实例
    scheduler.schedule(); // 执行任务调度

    // 重新设置 alarm,确保 1 秒后再次触发信号
    alarm(1);
}

int main() {
    // 创建任务调度器
    TaskScheduler scheduler;

    // 定义任务函数
    auto task1 = []() { std::cout << "任务1正在运行..." << std::endl; };
    auto task2 = []() { std::cout << "任务2正在运行..." << std::endl; };
    auto task3 = []() { std::cout << "任务3正在运行..." << std::endl; };

    // 添加任务到调度器
    scheduler.add_task(task1, "Task1", priority = 1, time_slice = 2);
    scheduler.add_task(task2, "Task2", priority = 2, time_slice = 1);
    scheduler.add_task(task3, "Task3", priority = 0, time_slice = 1);

    // 注册信号处理函数
    signal(SIGALRM, handlerSig);

    // 设置第一次信号触发时间为 1 秒后
    alarm(1);

    // 主循环:程序通过 pause() 阻塞,等待信号到来
    while (true) {
        pause();
    }

    return 0;
}

任务结构体

  • task_struct 包含任务的基本信息,如名称、函数指针、优先级、状态和时间片。

  • 这些信息用于模拟真实操作系统中进程的属性。

任务调度器

  • TaskScheduler 类负责管理任务列表和调度逻辑。

  • schedule 方法中,任务按优先级排序,并依次执行就绪状态的任务。

信号处理函数

  • 每次接收到 SIGALRM 信号时,调用 schedule 方法执行任务调度。

  • 任务执行完成后,重新设置 alarm,确保 1 秒后再次触发信号。

主程序

  • 定义任务函数并添加到调度器中。

  • 注册信号处理函数,并设置第一次信号触发时间为 1 秒后。

  • 主程序通过 pause() 阻塞,等待信号到来。

进程调度的基本原理:通过任务结构体和调度器,模拟了进程的创建、管理和调度。支持基于优先级的调度策略。

信号机制与任务调度的结合:使用 alarmSIGALRM 实现周期性任务调度,展示了信号机制在操作系统中的应用。

任务状态的管理:模拟了任务的就绪、运行和阻塞状态,展示了任务状态的转换。

可扩展性:可以进一步扩展调度策略,如时间片轮转、多级反馈队列等。通过这种方式,我们可以更直观地理解操作系统中进程调度的原理和实现方法。


 总结

  1. alarm的核心作用是设置单次定时器,需通过重复调用或信号处理实现周期性。
  2. IO操作是性能瓶颈:减少不必要的IO可显著提升计算效率。
  3. 信号处理与pause的配合:用于实现高效的异步事件驱动模型。
  4. 默认终止行为:未处理的SIGALRM会终止进程,需显式捕获以自定义逻辑。

通过合理利用信号和定时器,可以优化程序性能并实现复杂的时序控制逻辑。

简单快速理解系统闹钟

其实上面提到了alarm,上面所写的代码都是应用层的,不是系统级别的代码,我们自己其实是可以调用alarm,就可以向系统注册闹钟了,那么我们该如何理解这个“闹钟”呢?

我们的代码程序是经过操作系统调度之下,执行各种语句的,但是操作系统也是软件,那谁让操作系统主动运行起来呢?其实上面讲过,就是通过外部刺激(这个刺激是时钟中断),让操作系统不断去运行的!

不仅仅是一个进程可以设定闹钟,其他进程也可以,每个进程都会设置自己的闹钟,有的超时了,有的还没响,有的处理了还没销毁...所以,操作系统内可以存在很多闹钟,所以操作系统就要对闹钟进行先描述再组织的管理,所以所谓的闹钟,就是上在内核当中是属于一种数据结构的,设定闹钟就是创建闹钟结构体对象.........

现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。内核中的定时器数据结构是:

// Linux内核中的定时器数据结构
struct timer_list {
    struct list_head entry;       // 链表节点,用于将定时器组织到链表中
    unsigned long expires;        // 定时器的超时时间(到期时间点)
    void (*function)(unsigned long);  // 定时器到期时调用的处理函数
    unsigned long data;           // 传递给处理函数的参数
    struct tvec_t_base_s *base;   // 定时器的基结构,用于管理定时器的底层信息
};

关于定时器的管理方式:

  1. Linux内核使用时间轮(time wheel)来高效管理定时器。
  2. 时间轮是一种环形队列结构,能够快速处理大量定时器的到期事件。
  3. 为了简化理解,可以将定时器的组织方式类比为堆结构(heap)。
  4. 堆结构是一种优先级队列,可以高效地处理定时器的插入和到期事件。

定时器管理与进程调度过程

操作系统持续检查堆顶定时器并处理到期事件,是为了确保所有定时任务能够按照预定时间准确执行。这一过程不仅保证了系统时间管理的准确性,还能及时响应用户和系统对定时操作的需求,如周期性任务、延时操作等。同时,通过高效处理到期定时器,系统能够合理分配资源,避免因延迟处理而导致的资源浪费或任务堆积,从而提升整体运行效率,维持系统的稳定性和可靠性。

在现代操作系统中,定时器通常通过最小堆(Min-Heap)管理,以高效处理到期事件。以下是紧凑的流程描述:

定时器插入
当进程创建定时器(如通过alarm()setitimer())时,定时器被插入到最小堆中。插入时,定时器按到期时间戳expires排序,堆顶始终是最先到期的定时器。插入操作通过上浮(Heapify-Up)调整堆,确保堆性质(堆顶元素最小)保持不变。

定时器到期检查
操作系统通过定时中断(如tick定期检查堆顶定时器是否到期。如果当前时间大于或等于堆顶定时器的expires,则表示该定时器到期。

到期定时器处理

  • 弹出堆顶定时器:从堆中移除到期定时器,并通过下沉(Heapify-Down)调整堆,恢复堆性质。

  • 执行定时器函数:调用定时器的处理函数function,并将data作为参数传递。

  • 发送信号向目标进程发送SIGALRM信号。目标进程在接收到信号后,执行信号处理函数(如果已设置)或执行默认行为。

重复检查
操作系统继续检查堆顶定时器,重复上述过程,直到所有到期的定时器都被处理完毕。


通过最小堆管理定时器,操作系统能够高效地处理大量定时器的到期事件,确保到期任务及时执行,并通过信号机制通知目标进程。

所以对于软件条件:闹钟就是软件,闹钟超时就是条件,这就是属于软件条件的一种。

所以,无论是之前的管道,还是现在的闹钟,最终都是由操作系统内帮我们维护的,软件条件一旦触发,操作系统就会向目标进程发信号,这就是产生信号的第五种方式!!!

总结:

我们已经知道了信号产生的五种方式:

  1. 键盘
  2. 系统调用
  3. 系统命令
  4. 硬件异常
  5. 软件异常

不过现在我们也应该知道了:上面的5种发生最终都要转化成由操作系统来进行信号发送!所谓的信号发送就是向目标进程的PCB中对用的数据位图进行比特位的修改。

相关文章:

  • Python第五章03:函数返回值和None类型
  • 网络编程知识预备阶段
  • 东隆科技携手PRIMES成立中国校准实验室,开启激光诊断高精度新时代
  • 【免费】2004-2017年各地级市实际利用外资数据
  • Grokking System Design 系统设计面试问题
  • 从零开始实现一个HTML5飞机大战游戏
  • java 中散列表(Hash Table)和散列集(Hash Set)是基于哈希算法实现的两种不同的数据结构
  • 【渗透测试】webpack对于渗透测试的意义
  • Linux 如何上传本地文件以及下载文件到本地命令总结
  • WSL2配置Humanoidbench问题mujoco.FatalError: OpenGL version 1.5 or higher required
  • Bash中关于制表符\t站位情况说明
  • 滑动窗口算法详解:从入门到精通
  • 44运营干货:提高用户留存和粘性方式汇总
  • 传输层协议 ——— TCP协议
  • SVG利用+xssgame第8关注入详解
  • 裂缝识别系统 Matlab GUI设计
  • C# Unity 唐老狮 No.10 模拟面试题
  • 路由器与防火墙配置命令
  • QT5.15.2加载pdf为QGraphicsScene的背景
  • Matlab 汽车传动系统的振动特性分析
  • 罗马尼亚总理乔拉库宣布辞职
  • 争抢入境消费红利,哪些城市有潜力?
  • 南京明孝陵石兽遭涂鸦“到此一游”,景区:已恢复原貌,警方在排查
  • 无畏契约新王诞生:属于电竞世界的凯泽斯劳滕奇迹
  • 抚州一原副县长拉拢公职人员组建“吃喝圈”,长期接受打牌掼蛋等“保姆式”服务
  • 苏丹外交部:苏丹西部一城市约300名平民遭杀害