
这般说来也算熟悉 C++后端开发岗位与 Linux 大厂面试的考生会发现,Signal 信号机制一直都是高频出现的核心考察点,同时也是不少从业者的知识盲区。和我们日常写代码的常规逻辑不一样,信号其实是 Linux 内核和用户进程之间开展交互的核心异步通信手段,常被用于进程终止、异常处理、超时监听、程序优雅停机这类关键业务场景中。绝大多数开发者只会简单调用信号注册函数,却没能吃透信号在内核侧的流转全过程,一旦碰到面试里提到的信号丢失、系统调用中断、信号重入、安全函数这类深层问题时,常常没法给出准确完整的回答。
这篇短文我将结合大厂面试真题透彻拆解 Linux Signal 信号机制的核心逻辑。从信号的生成、注册、派发、屏蔽以及处理的完整流程着手,讲清用户态和内核态的交互规则,同时梳理信号编程中常见的雷区,囊括不可重入风险、EINTR 中断问题、信号丢失情形等。帮大家打通理论和实战环节,完全吃透信号机制,从容应对各类面试考察与线上项目排障。
一、Linux 信号机制是什么?
面试题写作模版在那 Linux 系统当中,信号机制那可是进程之间进行通信以及控制的很重要的一种方式。它主要就是用来异步地去通知进程,让进程知道发生了特定的事件。你可以把信号看成是一种特殊的消息。内核或者别的进程能够把这个消息发送给目标进程,让目标进程去做相应的处理。信号的产生和处理那都是异步的。
也就是说进程在运行的时候,随时都有可能接收到信号,而且接收到信号的时候,不一定正在执行和信号相关的代码。比如说你在终端去执行命令,运行一个前台进程,当你按下 Ctrl+C 组合键的时候,系统就给这个前台进程发送一个 SIGINT 信号,这个信号通知进程得终止运行。但是进程接收到这个信号的时候,或许正在做文件读取、数据计算等等操作,这并不会影响信号的接收和处理。信号的作用有很多,主要体现在以下几个方面:
在 Linux 系统之中,信号存在好几种类型。总共一共有 62 种信号,编号是从 1 到 64。可是 0 号、32 号、33 号信号是不存在的。这些信号被划分成普通信号和实时信号。普通信号的编号是 1 到 31,实时信号编号是 34 到 64。普通信号是从早期的 Unix 系统继承而来的,在处理的时候有一定的局限性。比如说不支持排队,要是一个普通信号在还没有处理之前又产生,它不会被多次记录,只会被处理一次,这样就有可能让信号丢失。
实时信号是对普通信号的扩展,支持排队。要是一个实时信号多次产生,系统会把这些信号依次放到一个队列里面,进程会依照信号产生的顺序依次去处理它们,不会出现信号丢失的情况。而且实时信号在处理的时候能够携带更多的信息。比如说 SIGINT(2 号信号)是普通信号,是用户按下 Ctrl + C 组合键的时候产生的,用来中断进程的运行。SIGALRM(14 号信号)也是普通信号,常常被用来实现定时器的功能。SIGRTMIN(34 号信号)是实时信号,在一些对信号处理要求比较高的场景,像在实时控制系统里边就会用到。
二、信号的产生方式
面试题写作模版在终端当中,用户可以运用一些按键组合来给正在运行着的前台进程发送信号。举例而言按下 Ctrl + C 组合键,便会产生 SIGINT 信号。这个 SIGINT 信号的功用是中断当前正在运行着的程序,常常被用来终止处于死循环状态的程序。当你运行那一个一直在进行打印输出的程序的时候,按下 Ctrl + C,程序就接收到 SIGINT 信号然后就终止运行了。按下 Ctrl + \组合键,会产生 SIGQUIT 信号。它不只是终止进程,还极有可能生成 Core dump 文件。
这个 Core dump 文件包含着进程接收到信号的时候的内存状态等方面的信息,便于开发者进行调试。程序出现异常的时候,通过 Ctrl + \生成 Core dump 文件,开发者能够解析这个文件来找出出错的缘由。另外按下 Ctrl + Z 组合键,会产生 SIGTSTP 信号。它的作用是将前台进程挂起(也就是暂停),放到后台去。你可以运用 fg 命令把它恢复到前台接着进行运行。
下面是一个可测试用户按键触发信号的简单代码,运行后可分别测试 Ctrl+C、Ctrl+Z、Ctrl+\ 效果:
#include <stdio.h>#include <unistd.h>int main() { printf("程序运行中,可测试:\n"); printf("1. Ctrl+C 触发 SIGINT\n"); printf("2. Ctrl+Z 触发 SIGTSTP\n"); printf("3. Ctrl+\\ 触发 SIGQUIT\n"); while (1) { // 死循环保持进程前台运行 sleep(1); } return 0;}在程序之中,可以通过调用某些个函数来产生信号。kill 函数能够向指定的进程去发送指定的信号。它的函数原型是 int kill(pid_t pid, int signo)。倘若 pid 大于 0,信号就会发送给这个 pid 所对应的进程。要是 pid 等于 0,信号就会发送给和调用进程同属一个进程组的全部进程。要是 pid 小于 0,信号就会发送给组 ID 为 - pid 的进程组里边的全部进程。要是 pid 等于 - 1,信号就会发送给除开系统进程之外的全部进程。一个父进程创建子进程之后,父进程可以使用 kill 函数向子进程发送信号,比如说 kill(child_pid, SIGTERM),这就会向着 child_pid 所对应的子进程发送 SIGTERM 信号,让子进程终止运行。
raise 函数乃是用于向调用进程自身发送指定信号的。其函数原型为 int raise(int signo)。在某一个函数当中,要是察觉到比如用户输入错误达到一定次数这类特定情形,便可以调用 raise(SIGUSR1)来向自身发送 SIGUSR1 信号,随后在信号处理函数之中开展相应的处理,比如说提示用户重新进行输入。
alarm 函数是比较常用的。它可以用来设置一个定时器。当指定的秒数到达之后,内核就会向当前的进程发送 SIGALRM 信号。它的函数原型是 unsigned int alarm(unsigned int seconds)。你可以用 alarm(5)来设置在 5 秒之后向进程发送 SIGALRM 信号。这常常被用来做简单的超时控制。在执行可能会长时间运行的操作之前,设置一个 alarm 定时器。要是在操作完成之前定时器到时间,那就能够在 SIGALRM 信号处理函数里面做超时的处理,比如说终止操作并且提示用户的。
abort 函数会向被它所调用的进程自身去发送 SIGABRT 信号。这一般是用来异常地终止进程并且生成 Core 文件。它的函数原型乃是 void abort(void)。它不存在返回值,由于它总是能够成功地发送信号。当程序检测到严重的错误而没有办法继续正常地运行的时候,就可以去调用 abort 函数。比如说在内存分配失败并且没有办法恢复的时候,调用 abort 来终止进程。以下是 kill、raise、alarm、abort 四个函数的综合测试代码:
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <signal.h>#include <sys/types.h>// 自定义 SIGUSR1 信号处理函数void usr1_handler(int sig) { printf("收到 SIGUSR1 自定义信号\n");}// 自定义闹钟信号处理函数void alarm_handler(int sig) { printf("闹钟超时,收到 SIGALRM 信号\n");}int main() { // 注册信号处理函数 signal(SIGUSR1, usr1_handler); signal(SIGALRM, alarm_handler); // 1. raise:给自己发送 SIGUSR1 信号 printf("1. 执行 raise 发送自定义信号\n"); raise(SIGUSR1); // 2. alarm:设置 3 秒定时器 printf("2. 设置 3 秒闹钟定时器\n"); alarm(3); sleep(4); // 等待定时器触发 // 3. abort:主动触发异常终止信号 printf("3. 执行 abort 触发异常终止\n"); abort(); return 0;}在系统之中所发生的某些个事件会引发信号产生。当子进程退出的时候,会向父进程发送 SIGCHLD 信号。父进程能够通过对这个信号进行处理来回收子进程的资源,以此来避免子进程变成僵死进程。父进程创建了好几个子进程,要是其中某一个子进程完成任务退出了,父进程就会接收到 SIGCHLD 信号,随后在信号处理函数里面调用 wait 或者 waitpid 函数来获取子进程的退出状态并且回收资源。
定时器到了规定的时间就会产生相应的信号。除了前面所讲到的由 alarm 函数设置的定时器到时间会产生 SIGALRM 信号之外,还有更为精确的定时器函数,像是 setitimer。setitimer 可以设置更为复杂的定时器,其中包含有间隔的时间等等情况。当定时器到了规定的时间的时候,会依照所设置的情况来发送不同的信号,比如说 SIGALRM(是用于自然时间定时的)、SIGVTALRM(是用于进程用户态时间定时的)、SIGPROF(是用于进程用户态和内核态时间总和定时的)。在一个需要精准地控制时间的程序之中,比如说在网络通信里设置超时重传的时间,就可以运用 setitimer 函数来设置精准的定时器。
当管道出现破裂的情况时,如同一个进程向一个已经不存在读端的管道中写入数据的时候,便会产生 SIGPIPE 信号。要是进程没有对这个信号进行处理的话,默认就会致使进程终止。在一个简易的管道通信程序当中,写端进程向管道里写入数据,这时候读端进程提前退出了,这时候写端进程继续向管道写入数据就会引发 SIGPIPE 信号 。
以下是 SIGCHLD、SIGPIPE 系统事件信号测试代码:
#include <stdio.h>#include <unistd.h>#include <signal.h>#include <sys/wait.h>#include <sys/types.h>// 处理子进程退出 SIGCHLD 信号void chld_handler(int sig) { printf("收到 SIGCHLD:子进程已退出,开始回收资源\n"); wait(NULL); // 回收子进程,避免僵尸进程}int main() { signal(SIGCHLD, chld_handler); pid_t pid = fork(); if (pid == 0) { // 子进程直接退出,触发父进程 SIGCHLD printf("子进程运行完毕,即将退出\n"); return 0; } else { // 父进程持续运行,等待信号 while (1) { sleep(1); } } return 0;}硬件在检测到异常状况的时候,也会去告知内核从而让内核产生相应的信号。当进程去访问非法的内存地址,就好比使用空指针或者野指针的时候,硬件的内存管理单元(MMU)就会检测到错误,这时候内核就会向进程发送 SIGSEGV 信号,而这样通常就会使得进程终止并且提示段错误(Segmentation Fault)。比如说在程序里面定义指针 int *p = NULL;,然后去尝试*p = 10;,就会触发 SIGSEGV 信号。
当出现浮点方面出现异常的状况的时候,例如除以零的情况(不管是浮点数还是整数进行除以零的运算),便会产生 SIGFPE 信号。在一个计算的程序当中,要是去执行类似 int a = 10; int b = 0; int c = a / b;这样的代码的话,那么就会触发 SIGFPE 信号。
当一个进程要去执行一条处理器所无法识别的指令的时候,这便就是非法指令,在这种时候就会产生 SIGILL 信号。这种情形一般是由于程序代码出现了损坏的情况,或者是在不兼容的硬件平台上面进行运行所导致造成的。
当出现比如内存地址未对齐这类总线错误的情况时,在进行内存访问的时候出现了硬件层面上的错误,那么就会产生 SIGBUS 信号。在一些对于内存访问要求比较高的程序当中,如果没有正确地去处理内存对齐的问题,那么就极有可能会触发 SIGBUS 信号。以下是段错误、除零异常硬件信号触发代码:
#include <stdio.h>#include <signal.h>// 捕获段错误void segv_handler(int sig) { printf("捕获 SIGSEGV:非法内存访问(段错误)\n"); exit(1);}// 捕获浮点异常void fpe_handler(int sig) { printf("捕获 SIGFPE:算术运算异常(除零)\n"); exit(1);}int main() { signal(SIGSEGV, segv_handler); signal(SIGFPE, fpe_handler); // 触发段错误 int *p = NULL; *p = 10; // 触发除零异常(上述代码已终止,仅作示例) // int a = 10, b = 0; // int c = a / b; return 0;}三、Linux信号的分类
面试题写作模版标准信号是传统的 UNIX 信号,在 Linux 系统中编号从 1 到 31 。这些信号按照功能可以进一步细分为以下几类:
进程控制类的信号之中,SIGTERM(15 号)乃是用于较为优雅地终止进程的。进程在接收到它之后,可以去做关闭已经打开的文件、释放所占用的资源这类清理的工作然后再正常地退出。有不少服务器程序在接收到 SIGTERM 之后会停止去接收新的连接,把现有的请求处理完毕之后再退出。SIGKILL(9 号)是属于强制杀死进程的信号,会立刻就让进程终止掉,而且这个信号是不能够被捕获、不能够被阻塞或者是不能够被忽略的。当碰到像死锁等没有办法正常终止的故障进程的时候,可以通过 kill -9 来发送这个信号从而去强制结束进程。
SIGSTOP(19 号)是用来暂停进程执行的,同样也不能够被捕获、不能够被阻塞或者是不能够被忽略,大多是用在调试或者临时挂起进程,以便于查看进程的状态。SIGCONT(18 号)是用来让被暂停的进程接着运行的。进程被 SIGSTOP 挂起之后,发送 SIGCONT 就能够让程序接着去执行,常常是用在调试完之后接着让程序运行。
在错误处理类信号当中,SIGSEGV(也就是 11 号)代表着段错误。当进程去访问那非法的内存地址的时候就会被触发,比如说代码 int *p = NULL; *p = 10;这样的情况就会触发这个信号,一般而言就会使得进程出现异常终止的情况。SIGFPE(8 号)意味着浮点异常。当出现了比如除以零、数值溢出等这类致命的算术运算错误的时候就会产生该信号,例如执行 int a = 10; int b = 0; int c = a / b;这样的代码就会触发此信号。SIGILL(4 号)是非法指令信号。
当进程尝试去执行处理器没办法识别的指令的时候就会被触发,大多是由于程序代码出现了损坏或者是在不兼容的硬件平台上运行所引起的。SIGBUS(7 号)是总线错误信号。多数是由硬件访问错误、内存地址没有对齐所导致的。对于很多对内存访问要求比较高的程序来说,如果没有处理好内存对齐的问题,就有可能触发该信号。
在用户交互类信号当中,SIGINT(也就是 2 号信号)是用户在终端按下 Ctrl+C 所产生的。此信号的作用是中断当前正在运行着的程序。举个例子当运行一个一直进行打印操作的程序时,按下这个组合键,程序接收到 SIGINT 信号就会终止掉。SIGQUIT(3 号信号)对应的是终端上的 Ctrl+\。这个信号除了能够终止进程之外,还能够生成 Core dump 文件以便于进行调试。
当程序出现异常时按下这个组合键就能够产生转储文件,供开发者去查找程序故障的原因。SIGTSTP(20 号信号)是由 Ctrl+Z 触发的,它的作用是将前台进程挂起然后放到后台。可以使用 fg 命令把进程切换回前台接着进行执行。比较适合在执行耗时命令的时候临时暂停下来,去做别的操作。SIGHUP(1 号信号)是在终端连接结束的时候发出的。当用户退出 Linux 登录时,前台进程组以及很多能够向终端输出的后台进程都会收到这个信号。默认的行为是终止进程,但是脱离终端的守护进程一般会利用 SIGHUP 信号来重新读取配置文件。
系统通知类的信号之中,SIGCHLD(17 号)会在子进程的状态发生改变的时候,像子进程退出、子进程暂停这类的情况,就会发送给父进程。父进程可以捕获这个信号,在信号处理的函数里面调用 wait 或者 waitpid 来获取子进程退出的状态、回收资源,这样就能够防止子进程变成僵死的进程。SIGALRM(14 号)是定时器到期的信号,调用 alarm 设置的定时时间到了就会触发它。
它大多用于实现简易的超时控制,在执行耗时的操作之前设置定时器,如果操作还没有完成而定时器却到期了,就可以在信号处理的函数里面完成超时的逻辑,比如说终止操作并且给出提示。SIGPIPE(13 号)代表管道破裂,当进程向读端已经关闭的管道里面写数据的时候就会产生。要是程序没有处理这个信号,进程就会被默认终止,典型的场景是管道通信中读端进程提前退出,写端还继续写数据就会触发这个信号。SIGUSR1(10 号)和 SIGUSR2(12 号)属于用户自定义的信号,默认的动作是终止进程,能够被程序捕获并且自定义处理,常常用来做进程间的通信以及自定义事件的通知,比如说分布式系统里面进程间约定 SIGUSR1 代表某类特定的业务事件。
实时信号是 POSIX.1b 标准的扩展,在 Linux 系统中编号从 34 到 64(SIGRTMIN 到 SIGRTMAX,通常 SIGRTMIN 为 34,SIGRTMAX 为 64 )。实时信号具有以下特性:
实时信号与标准信号存在着不同之处。标准信号主要用于处理比如基本进程控制以及错误处理等方面的情况,其功能相对较为基础。实时信号则更多地侧重于满足对信号可靠性以及数据传递要求较高的场景,例如实时控制系统、高性能数据传输这类情形。标准信号的默认动作是事先确定好的。实时信号的默认动作通常是终止进程,但是用户能够更加自由地去设置实时信号的处理方式。
实时信号排队、传参特性代码示例(验证不丢失、带数据传递):
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <signal.h>#include <sys/types.h>// 实时信号处理函数(支持传参)void rt_signal_handler(int sig, siginfo_t *info, void *ctx) { printf("收到实时信号%d,携带数据:%d\n", sig, info->si_value.sival_int);}int main() { struct sigaction sa; // 初始化信号处理配置 sa.sa_sigaction = rt_signal_handler; sa.sa_flags = SA_SIGINFO; // 开启传参模式 sigemptyset(&sa.sa_mask); // 注册 SIGRTMIN 实时信号 sigaction(SIGRTMIN, &sa, NULL); pid_t pid = getpid(); union sigval val; // 连续发送 3 次相同实时信号(标准信号会丢失,实时信号会排队) for (int i = 1; i <= 3; i++) { val.sival_int = i; sigqueue(pid, SIGRTMIN, val); } sleep(2); // 等待信号处理完成 return 0;}四、Linux 信号的处理方式
面试题写作模版默认状况之下,操作系统针对于每一个信号都已经定义好了默认的动作。当进程接收到某一个信号,但是却并没有为这个信号去设置其他的处理方式的时候,那么就会去执行默认的动作。不同的信号所具备的默认动作是不相同的,常见的情况有终止进程、产生核心转储文件之后再终止进程、对信号进行忽略、暂停进程、让进程继续运行等等。
当进程接收到 SIGTERM 信号而且不存在自己所定义的处理函数的时候,SIGTERM 信号默认的动作便就是使进程终止运行。当进程去访问非法的内存地址从而触发 SIGSEGV 信号的时候,如果系统启动了核心转储功能,那么 SIGSEGV 信号默认的动作是生成包含进程运行时状态信息的核心转储文件(要是系统设置是允许的话)并且让进程终止,这核心转储文件能够方便调试。
忽略信号意味着进程接收到该信号后,不做任何处理,直接跳过。在 C 语言中,可以使用 signal 函数或 sigaction 函数来设置忽略信号 。使用 signal 函数忽略 SIGPIPE 信号,示例代码如下:
#include <signal.h>#include <stdio.h>#include <unistd.h>int main() { // 设置忽略 SIGPIPE 信号 signal(SIGPIPE, SIG_IGN); // 这里可以进行其他操作,比如向管道写数据等 while (1) { sleep(1); } return 0;}在该示例当中,运用 signal(SIGPIPE, SIG_IGN) 来将 SIGPIPE 信号的处理方式设定成为忽略掉。那么当进程遭遇到管道破裂这种情况(也就是产生 SIGPIPE 信号的时候),不会由于默认的终止进程的动作就致使程序出现崩溃的状况,而是继续去执行后面的代码 。
使用 sigaction 函数忽略信号的示例代码如下:
#include <signal.h>#include <stdio.h>#include <unistd.h>int main() { struct sigaction sa; sa.sa_handler = SIG_IGN; // 设置为忽略信号 sigemptyset(&sa.sa_mask); sa.sa_flags = 0; // 设置忽略 SIGPIPE 信号 if (sigaction(SIGPIPE, &sa, NULL) == -1) { perror("sigaction"); return 1; } // 这里可以进行其他操作,比如向管道写数据等 while (1) { sleep(1); } return 0;}在某些个场景之中会把信号给忽略掉。当一个进程对于某个信号不予以关注,或者那个信号对于进程正常的运行没有影响的时候,那么就可以去选择把它给忽略掉。在简单的网络通信程序当中,要是不想要因为管道破裂(产生 SIGPIPE 信号)而让进程出现异常的退出情况,那么就可以把 SIGPIPE 信号给忽略掉。不过得要留意一下,忽略某些个信号是会存在潜在的问题的,比如说忽略 SIGTERM 信号就会让进程没有办法正常地去响应终止的请求,忽略硬件异常相关的信号(比如说 SIGSEGV)就会让程序不能够正确地去处理内存错误,这样一来问题就很难去排查以及解决了 。
自定义的处理情况指的就是进程针对信号去注册一个自定义的用于处理的函数。当信号到来的时候,系统就会去调用这个自定义的函数来对信号进行处理。在 C 语言当中,可以运用 signal 函数以及 sigaction 函数来注册自定义的信号处理的函数。
signal 函数的原型是 void (*signal(int signum, void (*handler)(int)))(int),其中 signum 是信号编号,handler 是指向信号处理函数的指针 。自定义处理 SIGINT 信号(用户按下 Ctrl + C 产生的信号)的示例代码如下:
#include <signal.h>#include <stdio.h>#include <unistd.h>#include <stdlib.h>// 自定义信号处理函数void sigint_handler(int signum) { printf("Received SIGINT signal. Cleaning up...\n"); // 这里可以进行一些清理工作,比如关闭文件、释放资源等 sleep(1); // 清理工作 printf("Cleaning up done. Exiting...\n"); exit(0);}int main() { // 注册 SIGINT 信号的处理函数 signal(SIGINT, sigint_handler); printf("Press Ctrl + C to terminate the program.\n"); while (1) { sleep(1); } return 0;}在该示例当中,定义了一个名为 sigint_handler 的函数用于去处理 SIGINT 信号。当进程接收到 SIGINT 信号的时候,便会去调用此函数。在这个函数里面,首先打印出提示性的信息,随后做一些模拟性质的清理方面的工作(使用 sleep(1)来进行模拟),最后让程序结束退出 。
sigaction 函数比 signal 函数更灵活和强大,它可以设置更多的信号处理选项 。sigaction 函数的原型是 int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact),其中 signum 是信号编号,act 是指向 struct sigaction 结构体的指针,用于设置新的信号处理方式,oldact 是指向 struct sigaction 结构体的指针,用于保存旧的信号处理方式 。struct sigaction 结构体的定义如下:
struct sigaction { void (*sa_handler)(int); // 信号处理函数指针 void (*sa_sigaction)(int, siginfo_t *, void *); // 带参数的信号处理函数指针,用于实时信号 sigset_t sa_mask; // 信号屏蔽集,在信号处理函数执行期间屏蔽的信号 int sa_flags; // 标志位,用于设置信号处理的一些选项 void (*sa_restorer)(void); // 已废弃,不再使用};使用 sigaction 函数自定义处理 SIGTERM 信号的示例代码如下:
#include <signal.h>#include <stdio.h>#include <unistd.h>#include <stdlib.h>// 自定义信号处理函数void sigterm_handler(int signum) { printf("Received SIGTERM signal. Performing custom actions...\n"); // 这里可以进行一些自定义的操作,比如保存数据、关闭连接等 sleep(1); printf("Custom actions done. Exiting...\n"); exit(0);}int main() { struct sigaction sa; sa.sa_handler = sigterm_handler; // 设置信号处理函数 sigemptyset(&sa.sa_mask); // 初始化信号屏蔽集为空 sa.sa_flags = 0; // 没有特殊标志 // 设置 SIGTERM 信号的处理方式 if (sigaction(SIGTERM, &sa, NULL) == -1) { perror("sigaction"); return 1; } printf("Process is running. Send SIGTERM to terminate.\n"); while (1) { sleep(1); } return 0;}在该示例当中,运用 sigaction 函数去设置 SIGTERM 信号的自定义处理函数 sigterm_handler。当进程接收到 SIGTERM 信号的时候,就会去调用这个函数来进行相应的处理。在 sa_mask 当中可以添加一些信号,这些信号在 sigterm_handler 函数执行的时候会被屏蔽掉,得等到 sigterm_handler 函数执行完毕之后才会去进行处理。
sa_flags 能够设置一些标志位,比如说 SA_RESTART 表示被信号中断的系统调用会自动地重新启动,SA_SIGINFO 表示把 sa_sigaction 成员当作信号处理函数(用于实时信号处理,能够携带更多的信息)。
五、Linux 信号相关函数详解
面试题写作模版signal 函数乃是用于对信号的处理方式进行设置的函数。它的原型是在<signal.h>头文件当中予以定义的。其原型为 sighandler_t signal(int signum, sighandler_t handler)。在这里 signum 是那一个需要去设置处理方式的信号的编号。handler 则是信号处理函数的指针,也可以是 SIG_IGN(对信号予以忽略)或者 SIG_DFL(采用默认的处理方式)。sighandler_t 是一种函数指针类型,它的定义是 typedef void (*sighandler_t)(int)。信号处理函数得拥有一个 int 类型的参数,这个参数是用来接收信号编号的。
在使用 signal 函数时,先定义一个信号处理函数,然后将其作为参数传递给 signal 函数来注册信号处理方式 。下面是一个使用 signal 函数处理 SIGINT 信号(用户按下 Ctrl + C 产生的信号)的示例:
#include <signal.h>#include <stdio.h>#include <unistd.h>#include <stdlib.h>// 自定义信号处理函数void sigint_handler(int signum) { printf("Received SIGINT signal. Exiting...\n"); exit(0);}int main() { // 注册 SIGINT 信号的处理函数 signal(SIGINT, sigint_handler); printf("Press Ctrl + C to terminate the program.\n"); while (1) { sleep(1); } return 0;}在该示例当中,定义了一个名为 sigint_handler 的函数来作为 SIGINT 信号的处理函数。之后运用 signal(SIGINT, sigint_handler)将其注册成为处理 SIGINT 信号的途径。程序运行之后,要是按下 Ctrl + C,就会激发 SIGINT 信号,随后就会调用 sigint_handler 函数,输出提示性质的信息而后退出程序 。
在不同的 UNIX 系统之上,signal 函数的运作行为或许会存在不一样的情况。特别是在对于信号处理过程中对信号处理函数的重置这一方面。在某些系统当中,当信号处理函数被调用之后,signal 函数有可能会把信号的处理方式重置成为默认的情况。这就需要在信号处理函数里面重新去注册信号的处理方式。所以在实际的开发过程中,尤其是很多需要进行跨平台的代码之中,更加建议使用 sigaction 函数来对信号进行处理 。
sigaction 函数乃是 POSIX 标准所定义的信号处理接口。它相较于 signal 函数更为灵活且更为强大,具备着更多的控制选项。sigaction 函数的原型是 int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)。它是在<signal.h>头文件当中进行声明的。
signum 就是要进行操作的信号的编号。act 是指向 struct sigaction 结构体的指针,用来对新的信号处理方式进行设置,要是它为 NULL 的话,那就不会去改变当前信号的处理方式。oldact 同样是指向 struct sigaction 结构体的指针,用来把信号先前的处理方式给保存下来,要是它为 NULL 的话,那就不会去进行保存。函数在成功的时候返回 0,失败的时候返回 - 1,而且还会去设置 errno 来表示错误的原因。struct sigaction 结构体用于描述对信号的处理,定义如下:
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_handler 乃是一个函数指针,其指向的是信号处理函数。这与 signal 函数当中的信号处理函数的含义大致是相同的。sa_sigaction 则是另一个信号处理函数指针,它具备三个参数,能够获取到关于信号更为详细的信息。倘若 sa_flags 成员的值包含有 SA_SIGINFO 标志,那么系统便会将 sa_sigaction 函数当作信号处理函数来使用;要不然就会将 sa_handler 当作信号处理函数来使用。在有些系统之中,sa_handler 和 sa_sigaction 被放置在联合体当中,所以在使用的时候不要同时对它们进行设置。
sa_mask 成员乃是用于指明在信号处理函数执行的时候需要屏蔽的信号。当某一个信号遭到处理的时候,其自身会自动进入进程的信号掩码,所以在信号处理函数执行的过程当中这个信号便不会再度出现。能够运用 sigemptyset 函数将信号集予以清空,还可以运用 sigaddset 函数来添加需要阻塞的信号。
sa_flags 成员用于指定信号处理的行为,它可以是以下值的 “按位或” 组合:
sa_restorer 成员是一个已经废弃的数据域,不要使用 。
下面是一个使用 sigaction 函数处理 SIGTERM 信号的示例:
#include <signal.h>#include <stdio.h>#include <unistd.h>#include <stdlib.h>// 自定义信号处理函数void sigterm_handler(int signum) { printf("Received SIGTERM signal. Cleaning up...\n"); // 这里可以进行一些清理工作,比如关闭文件、释放资源等 sleep(1); // 清理工作 printf("Cleaning up done. Exiting...\n"); exit(0);}int main() { struct sigaction sa; sa.sa_handler = sigterm_handler; // 设置信号处理函数 sigemptyset(&sa.sa_mask); // 初始化信号屏蔽集为空 sa.sa_flags = 0; // 没有特殊标志 // 设置 SIGTERM 信号的处理方式 if (sigaction(SIGTERM, &sa, NULL) == -1) { perror("sigaction"); return 1; } printf("Process is running. Send SIGTERM to terminate.\n"); while (1) { sleep(1); } return 0;}在该示例当中,对 sigterm_handler 函数进行了定义,目的是用于处理 SIGTERM 信号。随后运用 sigaction 函数将其设定为处理 SIGTERM 信号的方式。在对 struct sigaction 结构体进行设置的时候,让 sa_handler 指向 sigterm_handler,把 sa_mask 给清空掉(也就是不另外去屏蔽别的信号),把 sa_flags 设置成为 0(采用默认的行为)。程序运行之后,发送 SIGTERM 信号,就会去调用 sigterm_handler 函数,进行清理相关工作然后就退出程序。
kill 函数可以向指定的进程或者进程组去发送信号。它的原型是在<sys/types.h>以及<signal.h>这两个头文件里面进行声明的。该函数的形式是 int kill(pid_t pid, int sig)。在这里 pid 是目标进程或者进程组的标识 ID,sig 是所要发送的信号的编号。
pid 的取值有以下几种情况:
sig 乃是要发送的信号。它既可以是像 SIGTERM、SIGKILL 这类的标准信号,也能够是像 SIGUSR1、SIGUSR2 这类用户定义的信号。要是 sig 为 0 的话,kill 函数并不会去发送信号,但是会去检查目标进程存不存在,并且还会去检查调用进程有没有权限去给目标进程发送信号。
kill 函数成功时返回 0,失败时返回 - 1,并设置 errno 指示错误类型 。常见的错误包括:
kill 函数代码示例(父子进程信号通信):
#include <stdio.h>#include <unistd.h>#include <signal.h>#include <sys/types.h>// 子进程信号处理函数void child_handler(int sig) { printf("子进程收到父进程发送的 SIGUSR1 信号,执行任务\n");}int main() { pid_t pid = fork(); if (pid == 0) { // 子进程注册信号处理函数 signal(SIGUSR1, child_handler); while (1) { sleep(1); } } else { // 父进程延时 2 秒后向子进程发送信号 sleep(2); printf("父进程向子进程发送 SIGUSR1 信号\n"); kill(pid, SIGUSR1); sleep(3); // 发送 SIGKILL 强制终止子进程 kill(pid, SIGKILL); printf("父进程强制终止子进程\n"); } return 0;}raise 函数可以使得当前进程向自身发送信号,是一种最为简单的发送信号的函数。无需对进程的 PID 进行操作,仅仅是为自身的信号触发而使用。其函数原型为 int raise(int sig)。参数 sig 是所要发送的信号的编号,当调用成功的时候就返回 0,要是调用失败则返回 -1。
raise 函数的实质是对 kill 函数进行封装。它等同于 kill(getpid(), sig)。raise 函数是专门为进程自身的信号触发来提供服务的。它常常被运用在程序内部主动去触发异常、超时以及自定义事件等情况当中。在程序之中检测到存在非法状态,这时候程序将会主动触发信号,然后要么就终止程序,要么就去施行自定义的恢复逻辑。
#include <stdio.h>#include <signal.h>#include <stdlib.h>void err_handler(int sig) { printf("程序检测到异常,主动触发信号退出\n"); exit(1);}int main() { signal(SIGABRT, err_handler); int num = -1; // 参数非法,主动触发信号 if (num < 0) { raise(SIGABRT); } printf("程序正常执行\n"); return 0;}alarm 函数乃是用于设置进程定时器的。当倒计时结束的时候,内核就会自动地向当前进程发送 SIGALRM 信号。它的函数原型是这样的:unsigned int alarm(unsigned int seconds)。那参数 seconds 就是倒计时的秒数,返回值是上一次还没走完的剩余秒数。
在同一个进程当中,在同一个时间点只能够存在一个闹钟。要是重复地去调用 alarm 函数的话,那么上一次所设置的定时器就会被覆盖掉。而且上一次还没有超时的剩余时间会作为返回值被返回出来。要是传入的参数是 0 的话,就可以把当前已经设置好的定时器给取消掉。
常用的场景是:当接口出现超时情况的时候来进行熔断,当任务到达了限定的时间去进行执行,还有去进行心跳检测这一情况。
#include <stdio.h>#include <unistd.h>#include <signal.h>void timeout_handler(int sig) { printf("任务执行超时,触发 SIGALRM 信号\n");}int main() { signal(SIGALRM, timeout_handler); // 设置 3 秒超时定时器 unsigned int ret = alarm(3); printf("上一次剩余定时时间:%u\n", ret); // 耗时任务,超过 3 秒触发超时 sleep(5); printf("任务执行完成\n"); return 0;}六、信号屏蔽与未决信号
面试题写作模版信号的屏蔽指的是进程主动地将指定的信号给阻拦住。被屏蔽的信号并不会立刻就得到处理,系统会先把这个信号搁置在一旁,直到进程把屏蔽的状态解除掉之后,才会一起去对其进行处理。需保证信号处理函数执行的原子性,绝对不能让信号出现嵌套触发的情况,也不能去打断正在执行的核心业务逻辑。就好比在处理 SIGCHLD 来回收子进程的时候,得把同类信号给屏蔽掉,防止因为重复回收的而让程序出现问题。
信号核心操作存在五类常用函数。sigemptyset 这个函数的作用是对信号集进行清空操作。sigfillset 能够将所有的信号填充到信号集当中。sigaddset 可以把特定的信号添加到信号屏蔽集里面。
sigdelset 能够从信号屏蔽集中把指定的信号给删除掉。而 sigprocmask 是正式对进程信号屏蔽规则进行设置和修改的核心函数,它的函数原型是 int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);参数 how 有三种取值情况,分别是 SIG_BLOCK,这个是用来新增屏蔽信号的;还有 SIG_UNBLOCK,是用来解除已经存在的信号屏蔽的;以及 SIG_SETMASK,是用来直接去覆盖替换进程整体的屏蔽集的。
#include <stdio.h>#include <unistd.h>#include <signal.h>void sigint_handler(int sig) { printf("执行 SIGINT 信号处理逻辑\n"); sleep(3); // 耗时处理 printf("SIGINT 信号处理完成\n");}int main() { signal(SIGINT, sigint_handler); sigset_t mask; // 初始化信号集,屏蔽 SIGINT sigemptyset(&mask); sigaddset(&mask, SIGINT); printf("开始屏蔽 SIGINT 信号,5 秒内 Ctrl+C 无效\n"); sigprocmask(SIG_BLOCK, &mask, NULL); sleep(5); // 解除屏蔽,搁置的信号会立即触发 printf("解除 SIGINT 信号屏蔽\n"); sigprocmask(SIG_UNBLOCK, &mask, NULL); while (1) { sleep(1); } return 0;}信号已经出现,可是,由于被进程给予以屏蔽掉,当下没有办法去对它进行处理,它就处在等待的状态。所有还没有得到处理的信号,全都将会被内核记录到进程的未决信号集合当中。
信号具备多项核心特性,标准信号不支持排队,在信号屏蔽期间如果多次触发同一标准信号,系统只会保留一次信号记录,其余重复信号会直接丢失;而实时信号支持排队机制,多次触发后会被全部记录,待进程解除信号屏蔽后会依次响应执行;开发者可通过 sigpending 函数查询并获取进程当前的未决信号集合,以此查看屏蔽期间堆积的信号状态。未决信号查询代码示例:
#include <stdio.h>#include <unistd.h>#include <signal.h>int main() { sigset_t mask, pending; // 屏蔽 SIGINT sigemptyset(&mask); sigaddset(&mask, SIGINT); sigprocmask(SIG_BLOCK, &mask, NULL); printf("请按下 Ctrl+C,触发未决信号\n"); sleep(5); // 查询未决信号 sigpending(&pending); if (sigismember(&pending, SIGINT)) { printf("检测到 SIGINT 处于未决状态\n"); } // 解除屏蔽,处理未决信号 sigprocmask(SIG_UNBLOCK, &mask, NULL); printf("信号处理完毕\n"); return 0;}信号乃是 Linux 系统编程的根基所在。进程的调度、异常的处理、服务的启停以及轻量通信的底层,无一不是依赖于信号机制来运作的。必须要熟练地去掌握信号的屏蔽、捕获、排队以及处理等方面的逻辑。这可是后端开发、服务架构以及 Linux 运维调试所必须具备的能力。
end
如果这篇文章对你有所启发,欢迎点赞、在看,转发三连。星标⭐账号,还可以第一时间收到推送,感谢你的收看,我们下期再见~
往期干货推荐