【Linux】二十二.《Linux 信号机制完全指南:表示、捕捉与处理》

上一回我们认识了进程信号的基本概念,进程信号的本质等讲解了,这一篇文章,将带领大家继续深入学习进程信号,保存进程信号,信号处理方等知识。


一.保存信号

当前阶段

1.1信号其他相关常⻅概念

  • 实际执⾏信号的处理动作称为信号递达(Delivery)
  • 信号从产⽣到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block )某个信号。
  • 被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作.
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动作。

1.2 在内核中的表⽰

信号在内核中的表示示意图

实际上在 Linux kernel 的 task_struct 中还包含了一些信号相关字段,如下面这个信号在内核中的示意图:这个图应该横着来看:

SIGHUP(1):没有收到 pending,也没有 block,默认处理是 SIG_DFL(默认动作)。

SIGINT(2):收到了 pending,但因为被 block 了,所以信号被阻塞,暂时不会递达给进程,也就不会执行 SIG_IGN 或其他处理动作。等 block 解除后,才会继续执行对应的处理动作。

SIGQUIT(3):没有收到 pending,但收到了 block。需要注意,block 位图是独立于 pending 存在的,即使没有收到信号,block 状态也可以提前设置好。所以阻塞更准确的理解是:它是一种状态,表示"我不希望收到这个信号",而不是"这个信号来了才阻塞它"。

信号的自定义捕捉方法是用户提供的,是在用户权限下对应的方法。下面学习信号的操作都是围绕着这三个表来展开。

  • 每个信号都有两个标志位分别表⽰阻塞(block)和未决(pending) ,还有⼀个函数指针表⽰处理动作信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。
  • **SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达。**虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT信号未产⽣过,⼀旦产⽣SIGQUIT信号将被阻塞,它的处理动作是⽤⼾⾃定义函数sighandler。

如果在进程解除对某信号的阻塞之前这种信号产⽣过多次,将如何处理?

POSIX.1允许系统递送该信号⼀次或多次**。Linux是这样实现的:常规信号在递达之前产⽣多次只计⼀次,⽽实时信号在递达之前产⽣多次可以依次放在⼀个队列⾥**。这篇不讨论实时信号。

cpp 复制代码
// 内核结构 2.6.18
struct task_struct {
...
/* signal handlers */
struct sighand_struct *sighand;
sigset_t blocked
struct sigpending pending;
...
}
struct sighand_struct {
	atomic_t
		count;
	struct k_sigaction action[_NSIG]; // #define _NSIG
	64
		spinlock_t
		siglock;
};
struct __new_sigaction {
	__sighandler_t sa_handler;
	unsigned long
		sa_flags;
	void
	(*sa_restorer)(void);
	/* Not used by Linux/SPARC */
	__new_sigset_t sa_mask;
};
struct k_sigaction {
	struct __new_sigaction sa;
	void
		__user* ka_restorer;
};
/* Type of a signal handler. */
typedef void (*__sighandler_t)(int);
struct sigpending {
	struct list_head list;
	sigset_t signal;
};

1.3 sigset_t

为了方便操作 PCB 里的三张表,OS 提供了 sigset_t 类型,本质上就是个位图

从上面的图可以看出,pending 和 block 都是位图,每个信号只占一个 bit,不记录信号来了多少次,只记录有没有来、有没有被阻塞。所以这两种状态可以用同一个数据类型 sigset_t 来表示。

在阻塞信号集里,bit 为 1 表示"这个信号被阻塞了";在未决信号集里,bit 为 1 表示"这个信号已经收到了,但还没处理"。

sigset_t 定义在用户空间(栈上),最终要设置到内核的 PCB 里。从用户态到内核态,需要系统调用接口来传递,不能直接操作

另外,OS 不推荐用户手动做位运算,因为不同系统的位图实现可能不一样。OS 提供了一套标准的操作函数,比如 sigemptysetsigfillsetsigaddsetsigdelset 等。用户态用这些函数操作 sigset_t,然后通过系统调用(如 sigprocmask)把数据交给内核。

简单总结就是:sigset_t 是用户空间描述信号集的类型,配合 OS 提供的操作接口,可以影响内核 PCB 中的信号状态。

类似权限哪⾥的 umask指令可以做到


1.4 信号集操作函数

光有 sigset_t 类型还不够,它本质上就是个位图,但不能直接操作它。因为不同的系统、不同的架构下,sigset_t 的底层实现可能不一样。比如 32 位和 64 位系统,这个位图的大小和组织方式可能不同。所以 OS 提供了专门操作 sigset_t 的接口,用户态通过这些接口来设置信号集,再传给系统调用。sigset_t类型对于每种信号⽤⼀个bit表⽰"有效"或"⽆效"状态, ⾄于这个类型内部如何存储这些bit则依赖于系统实现, 从使⽤者的⻆度是不必关⼼的, 使⽤者只能调⽤以下函数来操作sigset_ t变量,⽽不应该对它的内部数据做任何解释, ⽐如⽤printf直接打印sigset_t变量是没有意义的。sigset_t称为信号集信号屏蔽字(Signal Mask)

cpp 复制代码
#include <signal.h>

// 清空信号集(所有位设为 0)
int sigemptyset(sigset_t *set);

// 填满信号集(所有位设为 1)
int sigfillset(sigset_t *set);

// 向信号集中添加某个信号(对应位置 1)
int sigaddset(sigset_t *set, int signum);

// 从信号集中删除某个信号(对应位置 0)
int sigdelset(sigset_t *set, int signum);

// 检查某个信号是否在信号集中
int sigismember(const sigset_t *set, int signum);

这些函数先在用户层操作 sigset_t 位图,然后通过 sigprocmasksigpendingsigaction 等系统调用把处理好的信号集设置到内核的 PCB 中。

信号集操作函数说明

使用 sigset_t 类型的变量之前,必须先调用 sigemptysetsigfillset 做初始化,让信号集处于一个确定的状态,否则里面的值是随机的,没法用。

cpp 复制代码
#include <signal.h>
int sigemptyset(sigset_t *set);

sigemptysetset 指向的信号集全部清零,表示这个信号集里不包含任何有效信号。

cpp 复制代码
int sigfillset(sigset_t *set);

sigfillsetset 指向的信号集全部置 1,表示这个信号集包含系统支持的所有信号。信号集初始化好之后,就可以用 sigaddset 往里面添加信号,或者用 sigdelset 从里面删除信号。

cpp 复制代码
int sigaddset(sigset_t *set, int signum);
int sigdelset(sigset_t *set, int signum);

sigismember 用来检查某个信号是否在信号集中,是个布尔函数。

cpp 复制代码
int sigismember(const sigset_t *set, int signum);

上面的四个函数(sigemptysetsigfillsetsigaddsetsigdelset)成功返回 0,出错返回 -1。sigismember 返回值稍微不同:包含返回 1,不包含返回 0,出错返回 -1。


(1)sigprocmask 函数

1. 接口说明

sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。说白了就是把用户空间定义的信号集设置到内核的 block 位图里。这个阻塞信号集也叫信号屏蔽字(Signal Mask),注意屏蔽的意思是阻塞,不是忽略。

cpp 复制代码
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

参数:

  • set:输入型参数,用户定义好的信号集,要设置到内核

  • oldset:输出型参数,返回旧的信号屏蔽字,方便以后恢复,不想保存就传 NULL

  • how:指定怎么改,有三种方式

oldsetset 都是指针,可以是 NULL,也可以指向一个 sigset_t 变量。

  • 如果 oldset 非空,就把当前进程的信号屏蔽字(旧的 block 位图)拷贝到 oldset 里,供用户查看或后续恢复用。

  • 如果 set 非空,说明要修改屏蔽字,具体怎么改由 how 决定。

  • 如果 setoldset 都非空,那就先把旧的备份到 oset,再根据 sethow 去修改。

how 参数告诉内核怎么改,假设当前信号屏蔽字是 mask

how 的三种方式:

方式 含义
SIG_BLOCK 把 set 中的信号加到当前屏蔽字里,相当于 mask = mask | set
SIG_UNBLOCK 从屏蔽字中移除 set 里的信号,相当于 mask = mask & ~set
SIG_SETMASK 直接用 set 替换当前屏蔽字,相当于 mask = set

返回值: 成功返回 0,失败返回 -1。

注意:如果 sigprocmask 解除了对某些未决信号的阻塞,在函数返回前,至少会把其中一个信号递达给进程。

2. 查看手册

cpp 复制代码
man 2 sigprocmask

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


3. 代码如下

cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;

void handler(int sig)
{
    cout << "捕获到信号: " << sig << endl;
}

int main()
{
    signal(2, handler);  // 注册 SIGINT 处理函数

    sigset_t set, oldset;
    sigemptyset(&set);
    sigaddset(&set, SIGINT);  // set 里只放 2 号信号

    // 阻塞 SIGINT
    sigprocmask(SIG_BLOCK, &set, &oldset);
    cout << "2号信号已被阻塞,PID: " << getpid() << endl;
    cout << "5秒内按 Ctrl+C 试试" << endl;
    sleep(5);

    // 解除阻塞
    cout << "解除阻塞" << endl;
    sigprocmask(SIG_SETMASK, &oldset, NULL);
    sleep(1);

    return 0;
}

4. 运行效果

按 Ctrl+C 时,程序不会立刻响应,等解除阻塞后才处理信号。


(2)sigpending 函数

读取进程当前的未决信号集(pending 位图)。

cpp 复制代码
#include <signal.h>
int sigpending(sigset_t *set);

set 是输出型参数,内核把当前未决信号集拷贝给用户。

返回值: 成功返回 0,失败返回 -1。

cpp 复制代码
man 2 sigpending

那问题来了,如果对所有的信号都进行自定义捕捉,那是不是就相当于写了一个不会被异常或者用户杀死的进程?

不是的,OS 的设计者也想到了这一点,所以,设定了 9 号信号属于管理员信号。

9号信号不可被捕获

假如把所有信号都自定义捕捉了,是不是就无敌了?进程不会被杀死,也不会被异常终止?理论上是的,但 OS 设计者早就防着这一手了,所以规定了 9号信号(SIGKILL)19号信号(SIGSTOP) 不能被捕获、不能被阻塞、不能被忽略。

cpp 复制代码
signal(9, handler);   // 无效,注册不成功
signal(19, handler);  // 无效,注册不成功

这两个信号是"管理员信号",专门留给系统或 root 用户用的。kill -9 PID 就是最后的杀招,不管进程在干什么,直接干掉,进程没有任何反抗的余地

运行结果:

根据结果你可以看见最后输入kii -9 ID号进程直接显示被kiled了。


验证 pending 位图变化一

如果我们把 2 号信号 block 掉,然后不断打印当前进程的 pending 信号集,这时候发一个 2 号信号,就能看到 pending 的第 2 位从 0 变成 1。

pending 位图用户不能自己改,所有的信号发送(kill、raise、abort)都是由内核去改 pending 位图。但我们能用 sigpending 读取当前的 pending 信号集。

  1. 屏蔽(阻塞)2 号信号。
  2. 不断的获取 pending 信号集,并输出。
  3. 发送 2 号信号给进程。

代码如下:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <cassert>
using namespace std;

// 打印 pending 信号集
static void showPending(sigset_t &pending)
{
    for(int sig = 1; sig <= 31; sig++)
    {
        if(sigismember(&pending, sig))
            cout << "1";
        else
            cout << "0";
    }
    cout << endl;
}

int main()
{
    // 1. 定义信号集对象
    sigset_t bset, obset;
    sigset_t pending;

    // 2. 初始化信号集
    sigemptyset(&bset);
    sigemptyset(&obset);
    sigemptyset(&pending);

    // 3. 添加要阻塞的信号(2号信号)
    sigaddset(&bset, SIGINT);

    // 4. 设置到内核,阻塞 2 号信号
    int n = sigprocmask(SIG_BLOCK, &bset, &obset);
    assert(n == 0);

    cout << "block 2 号信号成功..." << endl;

    // 5. 循环打印 pending 信号集
    while(true)
    {
        sigpending(&pending);
        showPending(pending);
        sleep(1);
    }

    return 0;
}

验证 pending 位图变化二

  1. 屏蔽(阻塞)2 号信号。
  2. 不断的获取 pending 信号集,并输出。
  3. 发送 2 号信号给进程。
  4. 20 秒后取消屏蔽 2 号信号

打印这个指令后

程序运行时,每秒钟打印一次各信号的未决状态(pending 位图)。因为我们用 sigprocmask 阻塞了 SIGINT(2号)信号,所以按下 Ctrl+C 后,SIGINT 信号不会被递达,而是处于未决状态,pending 位图的第 2 位会从 0 变成 1。

但 SIGQUIT(3号)信号没有被阻塞,所以按下 Ctrl+\ 时,进程会直接终止,不会进入未决状态,也不会被捕获。

问题补充:为什么 Ctrl+C 杀不掉?

因为 Ctrl+C 发送的是 2 号信号,被我们提前阻塞了,信号一直挂起(pending),进程不处理它,所以程序继续跑。

Ctrl+\ 发送的是 3 号信号,没被阻塞,所以进程收到后执行默认动作(终止 + core dump),程序就退出了。


把所有信号都阻塞,然后看看 pending 位图长什么样。

程序每秒钟打印一次 pending 位图,刚开始全是 0。另一个终端挨个发信号(1号、2号、3号...),每发一个,pending 位图里对应的 bit 就从 0 变成 1,并且一直保持为 1,因为信号被阻塞了,没人处理。

Pending 位图记录的是"已经收到、但还没被处理的信号",发一个信号,对应的 bit 就置 1,处理完才清 0。阻塞信号就是让信号一直 pending,不处理。


9 号信号不能被屏蔽或阻塞。

运行./signal

终端一:

终端二:


二.处理信号(捕捉信号)

当前阶段

1. signal 接口的本质

前面讲过的 signal(2, handler),本质上是把 2 号信号的处理动作从默认(SIG_DFL)改成用户自定义的 handler 函数。OS 在 PCB(task_struct)中维护了一个信号处理函数指针数组,以信号编号为索引,signal 调用就是把数组中对应位置的内容替换成用户传入的 handler 地址。

cpp 复制代码
// 注册信号处理函数
signal(SIGINT, handler);  // 把 2 号信号的处理函数改成 handler

2. 信号什么时候被处理?

信号不是收到就立即处理的,而是在合适的时候才处理。所谓合适的时候,就是进程从内核态返回用户态时

为什么是这个时间点?

因为进程运行过程中,大部分时间都在用户态执行代码。当发生系统调用、中断或异常时,进程会陷入内核态,由内核完成相应工作后再返回用户态继续执行。这个从内核态返回用户态的切换点,就是检查和处理信号的最佳时机:

  • 此时 CPU 权限从内核态切换回用户态,是系统最后一次介入的机会

  • 进程的状态信息完整,可以安全地调用用户空间的信号处理函数

  • 不会打断进程正在执行的关键操作


1.用户态和内核态

什么是用户态和内核态?

进程跑的时候,如果执行的是用户空间的代码,比如你自己写的 main 函数里的各种逻辑、运算、函数调用,这时候进程处于用户态。如果执行的是内核空间的代码,比如系统调用内部的逻辑,这时候进程处于内核态

我们平时写代码,大部分时间都在用户态。但很多操作我们自己干不了,比如读写文件、创建进程、收发网络数据,这些必须通过系统调用让内核帮忙做调用系统接口的时候,CPU 会自动完成身份切换:从用户态切换到内核态,让内核去干活。干完之后切回用户态,程序继续执行后面的代码。


OS 怎么知道当前是用户态还是内核态?

CPU 内部有一个状态寄存器,专门记录当前 CPU 工作在什么权限级别。用户态和内核态就是通过这个寄存器区分的。除了寄存器,还要看用的是哪套页表------每个进程有自己的用户级页表,OS 有一份内核级页表,所有进程共享。内核态时访问的是内核级页表,用户态时访问的是进程自己的页表。


什么情况会触发从用户态切换到内核态?

触发方式挺多的,大概分几类:

  1. 系统调用:这是最直接的。比如调用 readwriteopenfork,这些函数内部会触发软中断,让 CPU 陷入内核。

  2. 硬件中断:敲键盘、移动鼠标、网卡收到数据包,这些硬件事件会触发中断,CPU 停下当前的工作去处理中断。比如你写的程序里用 cin 等着用户输入,看起来程序卡住了,其实是键盘输入触发中断被 OS 先拿走放缓冲区,再通过系统调用把数据交给你的程序。早期硬件中断管理靠的是 8259 这类中断控制器芯片,现在的 APIC 系统也是类似原理。

  3. 异常:比如除零、野指针访问,CPU 会触发异常,自动切换到内核态由内核处理。

还有一个经典例子,汇编里有个指令叫 int 0x80,这是 x86 下系统调用的老路子------通过软件中断陷入内核。虽然现在 64 位系统换成了 syscall 指令,但本质上都是让 CPU 主动陷入内核去干活。


从内核态返回用户态

内核干完活就返回用户态继续跑。调完系统调用返回,中断处理结束返回,异常处理完也返回。每次返回用户态之前,OS 都会检查有没有信号需要处理,这也解释了为什么信号是在内核态返回用户态的时候被递达的。


用户态和内核态权限级别不同,能访问的资源也不一样。内核态权限高,但不能直接执行用户空间代码。OS不信任任何用户代码,万一有恶意操作,以内核态跑就可能搞崩系统。所以原则就是:用户代码在用户态执行,内核代码在内核态执行,跨权限执行必须经过状态切换。信号捕捉的时机是进程从内核态返回用户态的时候。递达分三种**:忽略、默认、自定义**。忽略和默认好处理,内核自己就搞定了。麻烦的是自定义捕捉------handler 是用户写的,在用户空间,可当前进程在内核态,怎么执行用户代码?


信号处理完整流程

进程在用户态执行代码时,因为系统调用、中断或异常触发而陷入内核态。内核完成相应工作后准备返回用户态,此时就是信号检测的时机。内核检查 pending 位图,根据信号的处理方式执行不同动作。

第一步:陷入内核

用户态进程执行代码,调用系统接口(如 read、write)后触发软中断,CPU 自动完成权限切换,从用户态陷入内核态,开始执行内核空间中的系统调用代码。

第二步:内核返回前检测信号

系统调用执行完毕,准备返回用户态。在返回之前,内核检查当前进程的 pending 位图,看看有没有信号需要处理。

第三步:根据处理方式分支

  • 没有信号待处理:直接返回用户态,继续执行被中断的代码

  • 有信号待处理:查 handler 表,根据注册的处理方式分三种情况:

    • SIG_DFL(默认):内核直接处理,终止进程或暂停进程或生成 core 文件,进程不返回用户态

    • SIG_IGN(忽略):内核把 pending 位图中对应位清 0,然后返回用户态继续执行

    • 自定义 handler:这是最复杂的情况,需要执行用户空间的自定义函数

第四步:执行自定义 handler

内核发现信号的处理方式是用户自定义的 handler 函数(在用户空间),于是从内核态切回用户态,执行 handler 函数。

第五步:sigreturn 返回

handler 执行完毕后,不能直接返回被中断的代码位置,因为内核还需要清理现场、恢复上下文。所以 handler 必须调用 sigreturn() 系统调用,再次陷入内核,让内核完成收尾工作。

第六步:恢复用户态

内核清理完现场后,再次返回用户态,回到程序被信号中断的位置继续执行。


为什么自定义捕捉这么麻烦?

内核态不能直接执行用户代码,这是出于安全考虑。 所以流程就绕了:用户态调系统调用陷入内核,内核忙完准备返回时发现有自定义信号要处理,于是切回用户态执行 handler。handler 跑完不能直接回原代码,得通过 sigreturn 再进一次内核,让内核帮忙恢复现场,再返回用户态继续执行。

整个过程在用户态和内核态之间来回切换,核心原因就是OS 不信任用户代码,必须保证任何用户代码都在用户态执行。

三种处理方式对比

处理方式 执行者 处理过程
忽略 内核 pending 位图清 0,返回用户态
默认 内核 终止进程 / 暂停进程 / 生成 core 文件
自定义 用户态 切回用户态执行 handler --> sigreturn 回内核 -->返回用户

2.内核如何实现信号的捕捉

示意图是如下:

如果信号的处理动作是⽤⼾⾃定义函数,在信号递达时就调⽤这个函数,这称为捕捉信号。

由于信号处理函数的代码是在⽤⼾空间的,处理过程⽐较复杂,举例如下:

  • ⽤⼾程序注册了 SIGQUIT 信号的处理函数 sighandler 。
  • 当前正在执⾏ main 函数,这时发⽣中断或异常切换到内核态。
  • 在中断处理完毕后要返回⽤⼾态的 main 函数之前检查到有信号 SIGQUIT 递达。
  • 内核决定返回⽤⼾态后不是恢复 main 函数的上下⽂继续执⾏,⽽是执⾏ sighandler 函数, sighandler 和 main 函数使⽤不同的堆栈空间,它们之间不存在调⽤和被调⽤的关系,是两个独⽴的控制流程。
  • sighandler 函数返回后⾃动执⾏特殊的系统调⽤ sigreturn 再次进⼊内核态。
  • 如果没有新的信号要递达,这次再返回⽤⼾态就是恢复 main 函数的上下⽂继续执⾏了

上面的文字有些复杂,我用这个简单的图带大家理解一下,信号捕捉:

宏观理解来看,说白了,信号捕捉就是用户态和内核态之间来回切换的过程。下面简化一下整个流程。

流程如下:

  1. 程序在用户态跑着,调用系统调用(比如 read),陷入内核态

  2. 内核把活干完,准备返回用户态

  3. 返回之前先检查一下:有没有信号要处理?

    • 没有 --> 直接返回用户态,该干嘛干嘛

    • 有 - ->处理方式

  4. 如果是用户自定义的 handler(在用户空间),就从内核态切回用户态去执行

  5. handler 执行完,调用 sigreturn 再次陷入内核

  6. 内核清理现场,然后再次返回用户态,回到程序原来被打断的地方继续跑


举个具体例子,方便大家理解

假设程序注册了 3 号信号(SIGQUIT)的自定义处理函数 sighandler,正在执行 main 函数。这时候用户按了 Ctrl+\,触发了信号。

流程是这样的:

  1. main 函数正在跑,因为系统调用或者中断,程序陷入了内核态

  2. 内核处理完自己的事,准备返回用户态继续跑 main

  3. 返回之前检查发现有 3 号信号要处理,而且处理方式是自定义的 sighandler

  4. 内核不返回 main 了,而是切回用户态去执行 sighandler

  5. sighandler 和 main 是两个独立的控制流,用不同的栈空间,不存在谁调用谁的关系

  6. sighandler 执行完,调用 sigreturn 再次陷入内核

  7. 内核清理现场,发现没有其他信号要处理,就返回用户态继续跑 main


3.sigaction

前面聊 signal 的时候,我们知道了怎么修改信号处理表,但那套接口其实比较简陋。现实中更常用的是 sigaction,它功能更强,可配置的选项也更多。虽然它设计上还兼顾了实时信号,但我们平时用的时候,只要掌握基本用法就够用了。

(1) 接口原型

cpp 复制代码
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);

参数含义如下:

  • signum :要捕获或设置的信号编号,比如 SIGINTSIGTERM 等。

  • act :传入参数,指向一个 sigaction 结构体,里面定义了新的信号处理方式。

  • oldact :传出参数,如果不为 NULL,内核会把旧的处理方式填进去,方便你之后恢复;不需要就传 NULL

结构体说明

cpp 复制代码
struct sigaction {
    void     (*sa_handler)(int);          // 旧式处理函数,类似 signal 的用法
    void     (*sa_sigaction)(int, siginfo_t *, void *); // 带额外信息的处理函数(实时信号相关)
    sigset_t   sa_mask;                   // 信号处理函数执行期间要屏蔽的信号集
    int        sa_flags;                  // 行为标志位,比如 SA_SIGINFO 等
    void     (*sa_restorer)(void);        // 已废弃,不用管
};

其中:

  • sa_handlersa_sigaction 是互斥的,具体用哪个由 sa_flags 中的 SA_SIGINFO 标志决定。如果你不关心实时信号的附加信息,直接用 sa_handler 就行,写法跟 signal 差不多。

  • sa_mask 可以用来指定在信号处理函数运行期间,额外屏蔽哪些信号,避免嵌套触发。

  • sa_flags 控制一些行为细节,比如是否自动重启被中断的系统调用(SA_RESTART)、是否使用 sa_sigaction 等。

  • sa_restorer 这个字段现在基本不用,了解即可,不用纠结。

实际开发中,我们大多数时候只关心 sa_handlersa_masksa_flags 这三个成员,实时信号那套(sa_sigaction)只有在做高级信号处理时才会涉及。

sigaction 的作用,说白了就是读取和修改指定信号对应的处理动作 。你可以把它当成 signal 的"增强版",只不过参数写得稍微啰嗦一点。

调用成功时返回 0,出错返回 -1,错误信息可以通过 errno 进一步查看。

sa_handler

sa_handlersigaction 结构体里最常用的字段,它的取值有三种情况:

  • 赋值为 SIG_IGN告诉内核,这个信号来了我不理它,直接忽略。

  • 赋值为 SIG_DFL:恢复成系统默认行为,比如该终止的终止,该停的停。

  • 赋值为一个函数指针:表示你要用自定义函数去处理这个信号。

    这个函数返回值必须是 void,可以带一个 int 参数------那个参数就是信号的编号。这样一来,你就可以写一个通用的处理函数,根据传入的编号区分是哪个信号触发了它,省得每个信号写一个单独的函数。

另外要提一嘴的是,这个函数本质上是一个回调函数------它不是被你写的 main 函数直接调用的,而是由内核在信号到达的时候主动去调用的。这一点跟 signal 是一样的,理解成"注册一个处理函数,等系统来调用"就对了。


(2)sa_mask & sa_flags

先聊一个场景:

假设进程正在执行 2 号信号(比如 SIGINT)的处理函数,这时候操作系统又收到一个 2 号信号发往该进程。那这个新信号能不能立马被处理?

答案是不行。

原因很简单:如果允许嵌套处理同一个信号,逻辑会乱掉,处理函数重入也容易出问题。所以系统的默认行为是------在处理某个信号的过程中,同类型的信号会被短暂阻塞(block),直到当前处理函数结束,再解除阻塞,让后来的信号有机会被递达。

假如,当 1 号信号被捕捉并进入处理函数时,内核会把该信号的 pending 标志清零(表示这个信号已经被受理了),同时把对应的 block 位置为 1。这样如果处理期间又来了一个同样的 1 号信号,因为它被阻塞了,所以暂时不会递达,只能停留在 pending 状态等到当前处理函数执行完毕,通过类似 sys_sigreturn 的机制返回用户态时,内核再把 block 位清 0,这时候如果还有挂起的信号,就可以继续递达处理了。

这种机制还有个额外的好处:防止短时间内大量相同信号不断触发处理函数,导致进程频繁上下文切换,也算是一种系统层面的"限流"保护。


sa_masksa_flags 分别负责什么?

  • 自动屏蔽当前信号

    这个是内核默认的行为,不需要你额外配置。也就是说,不管你用不用 sa_mask,当前正在处理的信号在处理期间都会被自动屏蔽。

  • sa_mask:额外要屏蔽的信号

    如果你觉得除了当前信号以外,在处理函数期间还想顺便屏蔽掉其他某些信号(比如处理 SIGINT 的时候顺便把 SIGQUIT 也挡住),就可以在 sa_mask 里指定这些信号。处理函数返回后,这些信号会自动解除屏蔽,恢复原状。

  • sa_flags:控制一些可选行为

    自动屏蔽当前信号这个行为,并不是靠 sa_flags 开启的,它本身就是默认行为。

    sa_flags 是用来控制其他扩展选项的,比如:

    • SA_RESTART:让被信号中断的系统调用自动重启;

    • SA_SIGINFO:表示使用 sa_sigaction 而不是 sa_handler,可以获取更多信号来源信息,等等。


实际应用中分配

如果你只是简单地想注册一个信号处理函数,不想折腾太多花样,那通常情况下:

  • sa_mask 设为空集(sigemptyset(&sa.sa_mask));

  • sa_flags 直接设为 0

  • 只填 sa_handlersignum

这样就已经够用了,剩下的交给内核默认行为去处理就行。


这段代码的核心目的是演示 sigaction 的注册过程,以及信号处理期间 pending 位图的变化

解析如下:

1. 注册信号处理函数

cpp 复制代码
struct sigaction act, oact;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);   // 不额外屏蔽任何信号
act.sa_handler = handler;    // 自定义处理函数

sigaction(2, &act, &oact);   // 给 SIGINT(2号信号)注册 handler

这里注册了 2 号信号(SIGINT,即 Ctrl+C)的处理函数 handler,并且:

  • sa_flags = 0:全部用默认行为

  • sa_mask 为空:除了当前信号(2号)会被自动屏蔽外,不额外屏蔽其他信号


2. 打印旧的处理动作

cpp 复制代码
cout << "default action: " << (int)(oact.sa_handler) << endl;

sigaction 的第三个参数 &oact 会把该信号原来的处理动作填进去。

打印出来你会看到类似 01 之类的数字:

  • 0 表示 SIG_DFL(默认行为,即终止进程)

  • 1 表示 SIG_IGN(忽略)

  • 其他数值表示之前注册过的函数地址


3. handler 里做了什么

cpp 复制代码
void handler(int signum)
{
    cout << "获取了一个信号:" << signum << endl;  // 重复打印了好几次
    sigset_t pending;
    int c = 10;
    while(true)
    {
        sigpending(&pending);   // 获取当前进程的 pending 位图
        showPending(&pending);  // 把 1~31 号信号的 pending 状态打印出来
        c--;
        if(!c) break;
        sleep(1);
    }
}

当 2 号信号触发时,处理函数会:

  1. 打印几行提示(告诉你收到了哪个信号)

  2. 进入一个循环,连续 10 秒,每秒打印一次当前进程的 pending 位图

  3. 循环结束后退出


4. showPending 打印 pending 位图

cpp 复制代码
for(int sig = 1; sig <= 31; sig++)
{
    if(sigismember(pending, sig)) cout << "1";
    else cout << "0";
}

这个函数把 1~31 号普通信号的 pending 状态依次打印出来,1 表示该信号正在被阻塞(等待处理),0 表示没有挂起。


5. main 里最后进入死循环

cpp 复制代码
while(true) sleep(1);

主进程一直在休眠,等着你按 Ctrl+C 触发信号。

这里有个点需要注意------如果 2 号信号正在被处理(被阻塞),第二次按 Ctrl+C 时,pending 位图中 2 号信号对应的位置应该是 1,但我们在循环里看到的可能一直是全 0

为什么? 因为 sigpending 读到的 pending 位图,只记录当前被阻塞且未递达的信号。而 2 号信号在第一次进入 handler 时,pending 已经被清零(表示已受理),然后 block 置 1 用于阻塞后续同类型信号。第二次按 Ctrl+C 时,新信号确实会被挂起(pending 置 1),但它在 block 为 1 的情况下不会递达,所以 sigpending 应该能读到这个 1

如果你实际运行时发现没看到这个 1,原因很可能是:第二次按 Ctrl+C 时,第一次 handler 已经退出了,所以信号直接递达并再次进入 handler,而不是被阻塞。

补充:

cpp 复制代码
cout << "default action: " << (int)(oact.sa_handler) << endl;

oact.sa_handler 是函数指针类型(8 字节),你把它强转成 int(4 字节)。

在 64 位系统上:把 8 字节转成 4 字节,会丢失精度,编译器觉得这是个大问题,应该报错。

加上 -fpermissive:编译器就懒得报错了,只给你一个警告,让你能编译通过。

-fpermissive 是干嘛的

简单说:让编译器对一些不合规范的代码"睁一只眼闭一只眼",把错误降级成警告。


这段代码展示了 sa_mask 的用法------在处理 2 号信号的同时,额外屏蔽 3~7 号信号。

代码如下:

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

using namespace std;

void showPending(sigset_t *pending)
{
    for(int sig = 1; sig <= 31; sig++)
    {
        if(sigismember(pending, sig)) cout << "1";
        else cout << "0";
    }
    cout << endl;
}

void handler(int signum)
{
    cout << "获取了一个信号:" << signum << endl;
    cout << "获取了一个信号:" << signum << endl;
    cout << "获取了一个信号:" << signum << endl;
    cout << "获取了一个信号:" << signum << endl;
    cout << "获取了一个信号:" << signum << endl;
    cout << "获取了一个信号:" << signum << endl;
    
    sigset_t pending;
    int c = 20;
    while(true)
    {
        sigpending(&pending);
        showPending(&pending);
        c--;
        if(!c) break;
        sleep(1);
    }
}

int main()
{
    cout << "getpid: " << getpid() << endl;
    
    struct sigaction act, oact;
    act.sa_flags = 0;
    sigemptyset(&act.sa_mask);
    act.sa_handler = handler;

    sigaddset(&act.sa_mask, 3);
    sigaddset(&act.sa_mask, 4);
    sigaddset(&act.sa_mask, 5);
    sigaddset(&act.sa_mask, 6);
    sigaddset(&act.sa_mask, 7);

    sigaction(2, &act, &oact);

    cout << "default action: " << (long)(oact.sa_handler) << endl;

    while(true) sleep(1);
    return 0;
}

解析如下:

1. main 函数开头

cpp 复制代码
cout << "getpid: " << getpid() << endl;

打印当前进程的 PID,方便等会儿在另一个终端用 kill 命令给它发信号。


2. 配置 sigaction 结构体

cpp 复制代码
struct sigaction act, oact;
act.sa_flags = 0;              // 啥额外功能都不要,用默认的
sigemptyset(&act.sa_mask);     // 先清空屏蔽集
act.sa_handler = handler;      // 告诉内核:2号信号来了就调用 handler 函数

sa_mask 的意思是:在处理 2 号信号期间,额外把哪些信号也给挡住

cpp 复制代码
sigaddset(&act.sa_mask, 3);    // 挡住 Ctrl+\ (SIGQUIT)
sigaddset(&act.sa_mask, 4);    // 挡住 SIGILL(非法指令)
sigaddset(&act.sa_mask, 5);    // 挡住 SIGTRAP(调试断点)
sigaddset(&act.sa_mask, 6);    // 挡住 SIGABRT(abort 函数触发)
sigaddset(&act.sa_mask, 7);    // 挡住 SIGBUS(总线错误)

问题来了,为啥要挡这些?

比如你在处理 Ctrl+C 的时候,不希望被 Ctrl+\ 打断,那就把 3 号也屏蔽掉。


3. 注册

cpp 复制代码
sigaction(2, &act, &oact);     // 给 2 号信号设置新的处理方式

oact 会把原来的处理方式存下来,然后打印出来看看:

cpp 复制代码
cout << "default action: " << (long)(oact.sa_handler) << endl;

默认情况下,2号信号的处理方式是 SIG_DFL,也就是终止进程,所以这里通常会打印 0


4. handler 函数

cpp 复制代码
void handler(int signum)
{
    cout << "获取了一个信号:" << signum << endl;  // 打印6遍
    // ...(重复打印)
    
    sigset_t pending;
    int c = 20;
    while(true)
    {
        sigpending(&pending);      // 问内核:现在有哪些信号被挡住了?
        showPending(&pending);     // 把结果打印出来
        c--;
        if(!c) break;
        sleep(1);                  // 等1秒再继续
    }
}

收到 2 号信号后:

先打印几行提示,然后进入一个 20 秒的循环,每秒打印一次当前被阻塞的信号,20 秒后退出


5. showPending Function

cpp 复制代码
void showPending(sigset_t *pending)
{
    for(int sig = 1; sig <= 31; sig++)
    {
        if(sigismember(pending, sig)) cout << "1";
        else cout << "0";
    }
    cout << endl;
}

把 1~31 号信号的 pending 状态打印成一行 0 和 1:

第 1 位对应 1 号信号,第 2 位对应 2 号信号,以此类推

1 表示这个信号被阻塞了,正在排队等待;0 表示没有被阻塞。

运行结果如下:

补充:

信号捕捉不创建新进程或新线程。它只是在当前进程的执行路径上,由内核在合适的时机(从内核态返回用户态前)插入执行一段自定义函数,执行完再回到原来的代码继续跑。可以理解为"借道执行",不是并发,是串行插入。


4.可重⼊函数

main函数调⽤insert函数向⼀个链表head中插⼊节点node1,插⼊操作分为两步,刚做完第⼀步的时候,因为硬件中断使进程切换到内核,再次回⽤⼾态之前检查到有信号待处理,于是切换 到

sighandler函数,sighandler也调⽤insert函数向同⼀个链表head中插⼊节点node2,插⼊操作的

两步都做完之后从sighandler返回内核态,再次回到⽤⼾态就从main函数调⽤的insert函数中继续往下执⾏,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main函数和sighandler先后 向链表中插⼊两个节点,⽽最后只有⼀个节点真正插⼊链表中了。

概念:

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

**先说什么叫重入。重入不是个功能,是个现象-----**一个函数还没跑完,又被叫进来跑了一次。比如你给2号信号绑了个handler,handler里调了show函数,main函数里也调了show函数。程序跑的时候,main正执行show呢,突然来了个信号,内核切到handler,handler里又调show。这时候show这个函数就同时有两份执行在跑,只不过一份被暂停了等着另一份先跑完。这就叫重入。在信号这里,main是一个执行流,信号捕捉是另一个执行流。两个执行流跑同一个函数,就叫重入。那有啥问题?用链表插入举个例子。

insert函数大概长这样:

cpp 复制代码
void insert(Node* node)
{
    node->next = head;   // 第一步
    head = node;         // 第二步
}

head是全局变量,指向链表的头。正常跑没问题。但假设main正调用insert,刚跑完第一步node->next = head,还没来得及跑第二步head = node,这时候来了个信号。**内核切到信号处理函数,信号处理函数里也调了insert。这个insert从头到尾跑完了,把head改成了新节点。然后返回main,main接着跑刚才没跑完的第二步head = node。**结果呢就是head被覆盖了,原来链表里的节点丢了,找不回来了。内存泄漏。**所以结论就是:如果一个函数被多个执行流同时调用,访问了全局变量或者静态数据,那它就不安全,叫不可重入函数。反过来,如果它只用自己的局部变量,不碰共享数据,那就是安全的,叫可重入函数。**信号处理函数里尽量只调可重入的函数,像printf、malloc、cout这些都不要用。我测试代码里用cout只是为了看效果,实际写代码别这么干。

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

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

5.volatile

volatile 是 C 语言里的一个关键字,也叫"易变关键字"。说白了就是告诉编译器:这个变量随时可能会变,你别自作聪明给我优化掉。
这段代码演示了信号处理函数如何通过修改全局变量,影响主程序的控制流;顺便引出了 volatile 关键字在信号编程中的必要性。

编译器优化对信号处理的影响

看一下这段代码:

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

using namespace std;

int flag = 0;

void changeFlag(int signum)
{
    (void)signum;
    cout << "change flag: " << flag;
    flag = 1;
    cout << "->" << flag << endl;
}

int main()
{
    signal(2, changeFlag);
    
    cout << "进程启动,PID: " << getpid() << endl;
    cout << "不加 volatile,开优化后可能退不出来" << endl;
    
    while(!flag);
    
    cout << "进程正常退出,flag = " << flag << endl;
    return 0;
}

我们期望的行为是:程序启动后卡在死循环,按 Ctrl+C 触发信号处理函数,把 flag 从 0 改成 1,然后循环退出,程序结束。实际跑起来呢?看下面两组测试。

测试结果

不开优化:

cpp 复制代码
g++ -o signal signal.cc -std=c++11
./signal

按 Ctrl+C 后正常退出。

开优化(-O):

cpp 复制代码
g++ -O -o signal signal.cc -std=c++11
./signal

按 Ctrl+C 后程序卡住,退不出来。连续按多次 Ctrl+C,发现信号处理函数虽然执行了(有打印输出),但 flag 始终是 1,循环就是不退出。

第一次按 Ctrl+C 时,flag 从 0 变成 1。再按 Ctrl+C 时,内存里的 flag 已经是 1 了,所以打印的是 1->1。但循环判断用的是寄存器,寄存器里还是 0,所以循环继续跑。这就解释为什么信号处理函数执行了,但循环退不出来。

为什么会这样?

问题出在编译器优化。

编译器为了提升程序性能,在开优化(如 -O-O2)时会做各种优化。

对于 while(!flag) 这个循环,编译器一眼看上去:flag 在循环体里从来没被改过啊。那每次循环都去内存读 flag 多慢,干脆把它放到 CPU 寄存器里,循环的时候直接从寄存器读就行了。这样程序跑得快。

但这里有个坑: flag 确实没在循环体里被改,但它会被信号处理函数改啊!编译器不知道信号这回事,它只看到代码表面。

于是结果就是:

信号处理函数改了内存里的 flag(内存里的值变成 1)

但循环判断用的是寄存器里的值(还是 0)

解决办法:volatile 关键字

volatile 是 C/C++ 里的关键字,意思是"易变的"。

用它修饰变量,就是告诉编译器:这个变量随时可能被外部改变,你不要自作聪明去优化它,每次用的时候都老老实实从内存读。

cpp 复制代码
volatile int flag = 0;

加上 volatile 后,不管开不开优化,循环每次判断都从内存读 flag,信号改了内存里的值,循环就能读到新值,正常退出。

测试结论

编译方式 不加 volatile 加 volatile
不开优化(默认) 能退出 能退出
开优化(-O / -O2) 退不出来 能退出

加 volatile 之前得先说清楚编译器优化干了啥。代码里 flag 定义时确实在内存中开了空间,但主执行流中的 while(!flag) 循环体里没有任何修改 flag 的操作,所以开了优化(比如 -O1 或 -O3)以后,编译器为了效率就把 flag 从内存搬到寄存器里了,之后循环判断只读寄存器不再读内存。可信号处理函数里 flag = 1 改的是内存里的那份,寄存器里的值还是 0,于是内存和寄存器数据就不一致了,表现出来的现象就是信号处理函数明明执行了,打印也打了,但主循环死活退不出来。加上 volatile 就是告诉编译器别干这事儿,每次判断 flag 都老老实实去内存读,这就保证了内存的可见性------一个执行流改了,另一个执行流马上能看到。

补充说明

volatile 只保证"每次都从内存读",不保证原子性,也不解决多线程安全问题。在信号处理的场景里,用它修饰全局标志位就够了。实际开发中,信号处理函数里尽量少做事,一般就是改个标志位,然后让主循环去处理具体逻辑。而那个标志位,记得加上 volatile

信号处理函数里要改的全局变量,记得加 volatile,否则编译器优化可能让你的程序"假装没收到信号"。


三.SIGCHLD 信号(了解)

进程⼀章讲过⽤wait和waitpid函数清理僵⼫进程,⽗进程可以阻塞等待⼦进程结束,也可以⾮阻 塞地查询是否有⼦进程结束等待清理(也就是轮询的⽅式)。采⽤第⼀种⽅式,⽗进程阻塞了就不 能处理⾃⼰的⼯作了;采⽤第⼆种⽅式,⽗进程在处理⾃⼰的⼯作的同时还要记得时不时地轮询⼀ 下,程序实现复杂。其实,⼦进程在终⽌时会给⽗进程发SIGCHLD信号,该信号的默认处理动作是忽略,⽗进程可以⾃ 定义SIGCHLD信号的处理函数,这样⽗进程只需专⼼处理⾃⼰的⼯作,不必关⼼⼦进程了,⼦进程 终⽌时会通知⽗进程,⽗进程在信号处理函数中调⽤wait清理⼦进程即可。

SIGCHLD 是第 17 号信号,子进程退出时内核会自动给父进程发送这个信号,父进程可以自定义 SIGCHLD 的处理函数,收到信号后调用 wait/waitpid 回收子进程资源,这样就避免了父进程被阻塞等待或反复轮询子进程状态,只需要专心干自己的活就行。不过要注意,多个子进程同时退出时 SIGCHLD 信号可能会合并,所以信号处理函数里要用 waitpid 配合 WNOHANG 循环回收,确保所有僵尸子进程都被清理干净。


过程如下:

这段代码就是验证子进程退出时会不会给父进程发 SIGCHLD 信号

在子进程退出后,向父进程发送了第 17 号信号:

.

SIGCHLD 信号处理 + waitpid 回收,子进程被正常清理,没有变僵尸。


父进程注册了 SIGCHLD 的处理函数 handler,然后创建子进程,自己在死循环里"忙自己的事"。子进程 sleep(100) 模拟干活,100 秒后退出,内核自动给父进程发 SIGCHLD 信号。父进程收到后,执行 handler 打印信息。

流程如下:

父进程注册了 SIGCHLD 信号处理函数;

子进程创建后 sleep(100);

100 秒后子进程退出,内核给父进程发 17 号信号;

父进程收到信号,执行 handler;

handler 里调用 waitpid(-1, NULL, WNOHANG) 回收子进程;

子进程彻底消失,不会变僵尸。

总结:

**子进程退出时内核会自动给父进程发 SIGCHLD,父进程收到后回收子进程,不用主动去等,也不用轮询。**这就是 SIGCHLD 信号存在的意义。


除了父进程自定义 SIGCHLD 处理函数来回收子进程之外,还有另一种方式:直接把 SIGCHLD 的信号处理动作设为 SIG_IGN 显式忽略,这样子进程终止时内核会自动清理资源,不会产生僵尸进程,父进程也不需要 wait。不过这种方式仅在 Linux 下有效(比如 CentOS 7.6),其他类 Unix 系统不一定支持,跨平台代码还是老老实实用 waitpid 回收。

父进程(PID: 4098568)在死循环里每隔 1 秒打印一行

子进程(PID: 4098569)打印 PID,等 5 秒,打印 "child exit" 后退出

子进程退出时,父进程没有收到信号通知(因为被 SIG_IGN 忽略了)

父进程继续打印,完全不关心子进程

子进程被 OS 自动回收,没有变僵尸

输出只有 grep 命令本身,没有看到子进程 4098569,说明子进程已经被 OS 自动回收了,没有变成僵尸。

总结:

自定义 SIGCHLD handler 是兼顾"父进程干自己的事"和"回收子进程"的优雅方案;如果连回收都不想管,直接 signal(SIGCHLD, SIG_IGN) 让 OS 自动清理。
由于 UNIX 历史遗留问题,想要避免僵尸进程其实还有一条路:父进程通过 sigaction 把 SIGCHLD 设成 SIG_IGN,这样子进程退出时系统会直接回收资源,不会变成僵尸,也不会给父进程发信号。一般来说,系统默认的忽略和用户手动设置的忽略没什么两样,但 SIGCHLD 是个例外。这个办法在 Linux 下好使,换个 UNIX 系统就不一定了。


僵尸进程与内存泄漏:等还是不等?

前面我们聊了僵尸进程的问题,你可能会有个疑问:一会说要 wait,一会又说可以不 wait,那到底该怎么决定?其实核心就一句话:看父进程关不关心子进程的退出状态。


如果父进程关心子进程

比如需要知道子进程是正常退出的还是被信号干掉的,或者需要拿到子进程的退出码,那就必须调用 wait/waitpid 来获取。这种情况 wait 不是为了"防僵尸",而是为了"拿结果"。

如果父进程不关心子进程

比如子进程就是个干活的,干完拉倒,父进程压根不想知道它怎么退的。那就可以用两种方式避免僵尸:

  • 自定义 SIGCHLD handler,在信号处理函数里调用 waitpid 回收

  • 或者直接把 SIGCHLD 设成 SIG_IGN(Linux 下可用),让操作系统自动清理

两种方式都能避免僵尸进程,区别在于前者父进程会被通知,后者连通知都省了。


所以结论是:站在防止内存泄漏的角度,等不等都行,都有办法解决僵尸问题。但 wait 还有一个更重要的目的------拿到子进程的退出状态。 如果需要这个信息,就必须等;如果不需要,就可以不等,用 SIG_IGN 或信号回收都行。

相关推荐
观山岳五楼5 小时前
Ubuntu 24 怎么使用Ubuntu 20 的镜像源
linux·运维·ubuntu
m0_715646766 小时前
20260720
linux·arm
寒晓星6 小时前
[linux]线程及多线程
linux·运维
辰痕~6 小时前
物理机装Linux操作系统(Ubuntu为例)
linux·ubuntu
X7x58 小时前
跨厂商路由引入(重发布)实战指南:华为、华三、思科的逻辑差异与配置避坑
网络协议·信息与通信·信号处理·实施路由引入
国服第二切图仔8 小时前
13其他工具 - Skill/LSP/Sleep等
linux·运维·里氏替换原则
就不掉头发9 小时前
计算机的虚拟内存
linux·服务器·windows
暴力求解9 小时前
Linux ---线程控制(二)
linux·运维·服务器·操作系统
smartvxworks9 小时前
Linux 实时内核(Linux Real-Time Kernel)详解:原理、实践与优化
java·linux·服务器