文章目录
- [信号捕捉方法:`signal` 与 `sigaction`](#信号捕捉方法:
signal与sigaction) -
- [`sigaction` 的基本作用](#
sigaction的基本作用) -
- [struct sigaction 的关键字段](#struct sigaction 的关键字段)
- [sigset_t 与信号集](#sigset_t 与信号集)
- [sigaction 的基本使用流程](#sigaction 的基本使用流程)
- 实验:同类型信号在处理期间是否会重入
- [sa_mask 的作用](#sa_mask 的作用)
-
- [处理 `SIGINT` 时额外屏蔽 `SIGQUIT`](#处理
SIGINT时额外屏蔽SIGQUIT)
- [处理 `SIGINT` 时额外屏蔽 `SIGQUIT`](#处理
- 总结
- [`sigaction` 的基本作用](#
信号捕捉方法:signal 与 sigaction
在进程信号处理机制中,常见的信号捕捉方法主要有两种:
signalsigaction
前面已经介绍过signal。它的优点是接口简单、使用方便,因此在基础教学和简单场景中较容易上手。
进一步介绍sigaction。它与signal的基本用途一致,都是为某个特定信号注册处理动作;但sigaction提供了更丰富的控制能力,因此在工程实践中通常更推荐使用。
sigaction 的基本作用
sigaction 的核心功能,是为指定信号设置处理策略。当进程收到该信号时,内核会根据预先注册的规则执行相应动作,例如调用用户自定义的处理函数。
其典型原型如下:
c
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
参数说明如下:
signum
表示要处理的信号编号,例如SIGINT。act
输入参数,用于指定新的信号处理方式。oldact
输出参数,用于保存该信号之前的处理方式,便于后续恢复。
返回值规则如下:
- 成功返回
0 - 失败返回
-1,并设置errno
需要说明的是,sigaction之所以比signal更复杂,主要原因在于它需要配合struct sigaction结构体共同使用。也就是说,信号处理策略不是简单传入一个函数指针,而是通过一个结构体统一描述。
struct sigaction 的关键字段
struct sigaction 中包含多个成员,但在普通信号处理场景下,重点只需要关注以下几个字段:
sa_handler
用于指定信号处理函数,其典型形式为:
c
void handler(int signo);
其中 signo 表示当前到达的信号编号。
如果多个信号共用同一个处理函数,可以在函数内部通过 switch (signo) 区分不同信号并执行不同逻辑。
sa_mask
类型为 sigset_t,表示在当前信号处理函数执行期间,需要额外屏蔽的信号集合。它本质上是一个信号集。
sa_flags
用于设置附加行为选项。初学阶段通常可以设置为 0。
sigset_t 与信号集
sa_mask 的类型是 sigset_t,它表示一个信号集合。
在信号机制中,信号集通常用于描述如下几类对象:
- 未决信号集(pending set)
- 阻塞信号集或信号屏蔽字(blocked set / signal mask)
从内核角度看,理解信号处理时,通常可以关注以下几类核心逻辑对象: - 未决信号集合
- 阻塞信号集合
- 信号处理动作表
因此,当用户态程序希望对某些信号进行屏蔽、解除屏蔽或组合控制时,就需要借助sigset_t以及相关接口,例如: sigemptysetsigaddset
sigaction 的基本使用流程
如果要对某个信号进行自定义捕捉,通常步骤如下:
- 定义信号处理函数;如果处理逻辑较长,也可以像示例中一样再封装辅助函数,例如
Count。 - 定义
struct sigaction结构体对象,例如act和oact,其中act用于设置新的处理方式,oact用于保存旧的处理方式。 - 设置
act.sa_handler = handler,指定信号到达时要执行的回调函数。 - 设置
act.sa_flags = 0,表示当前不启用额外标志位选项。 - 调用
sigaction(SIGINT, &act, &oact),为SIGINT注册新的处理动作,并保存该信号原来的处理方式。 - 通过
while(true) sleep(1);让进程持续运行,以便在程序运行期间接收并处理信号。
cpp
#include <iostream>
#include <cstdio>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signo: " << signo << "正在处理中..." << endl;
Count(20);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
//sigemptyset(&act.sa_mask); // 当我们正在处理某一种信号的时候,我们也想顺便屏蔽其他信号,就可以添加到这个sa_mask中 %%
sigaddset(&act.sa_mask, 3);
sigaction(SIGINT, &act, &oact);
while(true) sleep(1);
return 0;
}
在这段示例代码中,程序为 SIGINT 注册了自定义处理函数 handler。当用户按下 Ctrl + C 时,进程不会立即终止,而是进入 handler,输出当前捕捉到的信号编号,并调用 Count(20) 模拟一个持续 20 秒的处理过程。
实验:同类型信号在处理期间是否会重入
为了观察 sigaction 的行为,可以设计如下实验:
让信号处理函数故意执行较长时间,例如在处理函数内部加入 20 秒倒计时。这样就可以观察,在当前信号尚未处理完成时,如果再次发送同类型信号,系统会如何表现。
示例代码如下:
c
#include <iostream>
#include <cstdio>
#include <signal.h>
#include <unistd.h>
using namespace std;
void Count(int cnt)
{
while(cnt)
{
printf("cnt: %2d\r", cnt);
fflush(stdout);
cnt--;
sleep(1);
}
printf("\n");
}
void handler(int signo)
{
cout << "get a signo: " << signo << "正在处理中..." << endl;
Count(20);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigaction(SIGINT, &act, &oact);
while(true) sleep(1);
return 0;
}
运行程序后,先发送一次 SIGINT,再在处理函数执行期间多次发送 SIGINT。
实验现象与结论
实验中可以观察到两个典型现象:
- 当进程正在处理
SIGINT时,后续到达的同类型SIGINT不会立即再次进入处理函数,也不会发生递归式重入。 - 即使在处理期间连续发送多次
SIGINT,当前处理结束后,通常也只会再额外处理一次,而不会按发送次数逐次补发。
基于这两个现象,可以得到第一层结论:
- 当某个信号的处理函数正在执行时,内核会自动将"当前正在处理的该信号"加入进程的信号屏蔽字。
- 因此,在默认情况下,同类型标准信号在处理函数尚未返回前不能再次立即递送。
- 同类型标准信号的处理方式是串行的,而不是递归重入的。
当进程正在捕捉某一个信号期间,同类型信号无法抵达,
当前信号正在被捕捉,系统自动加入把当前信号加入进程信号屏蔽字,block,当信号完成捕捉动作,系统有会接触对该信号的屏蔽
为什么只会"再处理一次"
这个现象的根本原因在于:标准信号的未决状态通常按位图记录,而不是按次数排队。
其处理过程可以概括为:
- 某个信号到达后,先在未决信号集中置位。
- 内核开始递送该信号时,会清除对应的未决位,并进入用户注册的处理函数。
- 在处理函数执行期间,该信号被自动屏蔽。
- 如果此时外部又多次发送同一个标准信号,那么该信号只能再次把对应未决位置为
1;后续重复发送不会额外累积,因为修改的是同一个位。 - 当当前处理函数返回、信号解除屏蔽后,内核会再次检查未决信号集;如果发现该信号仍为未决状态,则会再次递送一次。
因此,即使在处理期间发送了很多次同一种标准信号,通常也只会表现为"当前处理结束后再补处理一次",而不会根据发送次数逐次执行。
如果在处理期间只发送过第一次信号、没有新的同类信号再次产生,那么处理函数返回后自然也不会再递送第二次,因为未决信号集中已经没有该信号了。
这段程序用 sigaction 为 SIGINT 注册了处理函数 handler,并且在处理 SIGINT 的过程中,又通过
cpp
sigaddset(&act.sa_mask, 3);
额外把 3 号信号,也就是 SIGQUIT,加入了附加屏蔽集合。也就是说:
- 收到
SIGINT时,会进入handler - 在
handler执行期间,当前信号SIGINT会被系统自动屏蔽
例如,程序运行后先按一次Ctrl + C,此时会触发SIGINT,程序进入handler,输出:
cpp
get a signo: 2 正在处理中...
然后执行 Count(20),开始 20 秒倒计时。
在这 20 秒内,如果你再次连续按很多次 Ctrl + C,表面上看程序没有立刻再次进入 handler。原因是:当前 SIGINT 正在处理,内核已经自动将 SIGINT 加入信号屏蔽字,所以后续同类型信号不能立即再次递送。
但是,这并不意味着这些信号完全"没了"。对于标准信号来说,内核只在未决信号集中用一个比特位记录"这个信号是否还在等待处理",而不记录它到底来了多少次。因此,你在这 20 秒内无论再发送 1 次、5 次还是 50 次 SIGINT,最终效果通常都是一样的:SIGINT 的未决位被重新置为 1。
等当前这次 handler 执行结束后,内核会解除对 SIGINT 的自动屏蔽,并检查 SIGINT 是否仍然处于未决状态。如果你刚才在处理期间又发过 SIGINT,那么它的未决位就是 1,于是程序会再次进入一次 handler。所以实验现象往往是:
- 第一次
Ctrl + C,立即进入处理函数 - 处理中再按很多次
Ctrl + C,不会递归重入 - 当前处理结束后,通常只会再补执行一次处理函数,而不是按按键次数执行很多次
再看你代码里的这句:
cpp
sigaddset(&act.sa_mask, 3);
它表示在处理 SIGINT 期间,顺便把 SIGQUIT 也屏蔽掉。
正常情况下,如果程序没有设置这个 sa_mask,你在处理 SIGINT 倒计时时按 Ctrl + \,SIGQUIT 会立即递送,程序可能直接退出。
但现在由于 SIGQUIT 被加入了 sa_mask,所以在 handler 执行期间,即使你按了 Ctrl + \,它也不会立刻终止程序,而是会等当前 SIGINT 处理完成后,再看是否递送。
所以结合这段代码,可以把整个过程总结为:
- 第一次收到
SIGINT后,程序进入handler。 - 在
handler执行期间,SIGINT自动被屏蔽,SIGQUIT也因sa_mask被额外屏蔽。 - 这期间再次发送
SIGINT,不会立即重入,只会把SIGINT的未决位重新置为1。 - 当前处理结束后,解除屏蔽;如果
SIGINT仍未决,则再递送一次。 - 因此,同类型标准信号默认是串行处理的,而不是递归处理的。
一般一个信号被解除屏蔽后会自动递达该信号,如果该信号已经被pending话,没有就不做任何动作
可以这样理解这句话:
一个信号在"被屏蔽期间"如果到来了,它不会立刻递达,而是先进入未决状态;等这个信号以后被解除屏蔽时,内核会立即检查它是否处于 pending 状态,如果是,就马上递达一次;如果不是,就什么也不做。
你的程序正在处理 SIGINT 时,SIGINT 会被系统自动屏蔽;
假设现在程序已经进入 handler(SIGINT),正在执行 Count(20):
-
这时
SIGINT是屏蔽状态。 -
如果你在这 20 秒里再次按
Ctrl + C,新的SIGINT不会立刻执行。 -
但是这个信号也不会丢掉,而是把
SIGINT在未决信号集中的那一位设为1,也就是进入pending状态。 -
等当前
handler执行完,内核会解除对SIGINT的屏蔽。 -
一旦解除屏蔽,内核马上检查:
SIGINT现在是不是 pending? -
如果是,就再递达一次
SIGINT,于是你会看到处理函数又执行一次。 -
如果不是,就直接继续往下运行,不会再发生任何事。
因此:
- 解除屏蔽时,内核会自动检查
- 如果这个信号之前在屏蔽期间来过,已经 pending 了,就补递达一次
- 如果没有 pending,就不补递达
程序里,最典型的现象就是: - 第一次按
Ctrl + C,立即进入handler - 倒计时期间再按很多次
Ctrl + C - 倒计时过程中不会重入
- 倒计时结束后,如果期间至少又来过一次
SIGINT,程序会再进一次handler - 如果期间一次都没再发,就不会再进第二次
同类型标准信号的处理原则
由上述实验和分析,可以归纳出标准信号处理的基本原则:
- 同类型标准信号默认按串行方式处理
- 不允许在处理函数执行期间发生同类型信号的递归式重入
- 大量重复到达的同类型标准信号,不会被逐次排队保存,而是通过未决位图进行合并记录
- 当前处理结束、解除屏蔽后,若该信号仍处于未决状态,则会再次递送一次
这段话的核心意思是:
同一种标准信号可以反复产生,但内核默认不会让它在处理函数内部一层套一层地递归执行,而是采用串行处理的方式。
结合代码来理解。
你的程序给SIGINT注册了handler。当你第一次按Ctrl + C时,进程收到一个SIGINT,于是开始执行:
cpp
handler(SIGINT);
在 handler 里又会执行 Count(20),所以这次处理要持续 20 秒。
问题来了:如果这 20 秒里你又连续按很多次 Ctrl + C,会不会程序一边处理第一次 SIGINT,一边又立刻跳进去处理第二次、第三次、第四次 SIGINT?
答案是:不会。
原因是:当内核开始执行某个信号的处理函数时,会自动把"当前这个正在处理的信号"加入信号屏蔽字。也就是说,当前正在处理 SIGINT 时,SIGINT 会暂时被屏蔽。于是后续再来的同类型信号,就不能立刻递达,也就不可能在 handler 里面再次递归进入 handler。
这就是"不能递归式处理"。
但是,后面发来的这些 SIGINT 也不是完全消失了。对于标准信号来说,它们会以"未决"的方式先记下来。只是这个记录不是按次数排队,而是用一个位表示"这个信号现在还有没有待处理"。所以你在处理期间发 1 次 SIGINT 和发 100 次 SIGINT,最终效果通常都差不多:最多只是在未决信号集中把 SIGINT 对应那一位重新设成 1。
等当前这次 handler 处理完,内核会解除对 SIGINT 的屏蔽。解除屏蔽之后,内核马上检查:SIGINT 现在是不是还处于未决状态?
- 如果是,说明在处理期间至少又发过一次
SIGINT,那就再递达一次,于是程序会再执行一次handler - 如果不是,说明处理期间没有新的
SIGINT到来,那程序就继续往下运行,不会再补处理
所以"串行处理"的意思就是: - 一次只处理一个同类型信号
- 当前这个没处理完,后面的同类型信号不能立刻插进来
- 必须等当前处理结束后,才有机会再处理下一次
对于同类型标准信号,内核默认通过"处理期间自动屏蔽当前信号"的方式,防止信号处理函数递归重入,从而保证同类型信号按串行方式处理;若该信号在屏蔽期间再次产生,则仅记录为未决状态,待当前处理结束并解除屏蔽后,再决定是否补递达一次。
sa_mask 的作用
前面讨论的是"当前正在处理的信号会被自动屏蔽"。
而 sa_mask 的作用,是在此基础上进一步指定:当处理某个信号时,还希望额外屏蔽哪些其他信号。
也就是说:
- 当前信号的自动屏蔽,是内核默认行为
- 其他信号的附加屏蔽,则由
sa_mask控制
例如,若当前为SIGINT注册处理函数,但希望在处理SIGINT期间,SIGQUIT也暂时不要递送,那么就可以将SIGQUIT添加到sa_mask中。
示例写法如下:
c
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, SIGQUIT);
这表示:当 SIGINT 的处理函数执行时,除了 SIGINT 本身会被自动屏蔽外,SIGQUIT 也会在这一阶段被一并屏蔽。
处理 SIGINT 时额外屏蔽 SIGQUIT
可以继续基于前面的程序做实验:
为 SIGINT 注册处理函数,并将 SIGQUIT 添加到 sa_mask 中。
示例代码如下:
c
#include <iostream>
#include <cstdio>
#include <signal.h>
#include <unistd.h>
using namespace std;
void Count(int cnt)
{
while(cnt)
{
printf("cnt: %2d\r", cnt);
fflush(stdout);
cnt--;
sleep(1);
}
printf("\n");
}
void handler(int signo)
{
cout << "get a signo: " << signo << "正在处理中..." << endl;
Count(20);
}
int main()
{
struct sigaction act, oact;
act.sa_handler = handler;
act.sa_flags = 0;
sigaddset(&act.sa_mask, 3);
sigaction(SIGINT, &act, &oact);
while(true) sleep(1);
return 0;
}
此时可以观察到:
- 当程序正在处理
SIGINT时,SIGINT本身会自动被屏蔽。 - 由于
SIGQUIT已被加入sa_mask,因此在处理SIGINT期间,SIGQUIT也不会立即递送。 - 如果在此期间既发送了多个
SIGINT,又发送了SIGQUIT,那么SIGQUIT会在解除屏蔽后再决定是否递送。
为什么程序最后可能退出
这一现象在实验中尤其值得注意。
SIGQUIT 不是被你"永久屏蔽"了,它只是在执行 SIGINT 处理函数期间被临时加入屏蔽字。
cpp
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3); // 即 SIGQUIT
sigaction(SIGINT, &act, &oact);
含义是:
- 当
SIGINT到达并进入handler时 - 内核会临时修改当前进程的信号屏蔽字
- 自动屏蔽当前正在处理的
SIGINT - 同时再把
act.sa_mask里的SIGQUIT也一起屏蔽
但这个屏蔽只在这个 handler 执行期间有效 。
当handler返回时,内核会把进入处理函数之前保存的旧信号屏蔽字恢复回来。这个恢复过程可以理解为:临时加上的屏蔽全部撤销 。所以如果原来SIGQUIT并没有被进程主动长期屏蔽,那么 handler 返回后,SIGQUIT就自然"解除屏蔽"了。
流程就是:
SIGINT到达,进入handler- 内核临时屏蔽
SIGINT,并按sa_mask临时屏蔽SIGQUIT - 这期间你发来的
SIGQUIT不会立刻递达,只会变成 pending handler返回后,内核恢复原来的屏蔽字- 当前
SIGINT处理结束后,若SIGINT仍未决,则会再处理一次SIGINT - 第二次
SIGINT处理完成后,解除屏蔽 - 此时若
SIGQUIT仍未决,则会立即递送,SIGQUIT不再被屏蔽 - 内核发现它处于 pending,于是立即递达
- 因为
SIGQUIT没有自定义处理,执行默认动作:终止进程
所以程序最后退出,不是死循环失效,而是:
SIGQUIT在处理SIGINT期间被临时阻塞,处理结束后自动恢复原屏蔽字,于是 pending 的SIGQUIT被递达并按默认动作终止了进程。
由于SIGQUIT默认动作为终止进程,因此程序最终可能表现为:前面一直在执行SIGINT的处理函数,而在全部SIGINT处理完成后,进程突然退出。
这并不是死循环失效,而是因为先前积压的SIGQUIT在解除屏蔽后被递送,并按照默认动作终止了进程。
总结
本节围绕 sigaction 主要讲清了以下几点:
signal和sigaction都可以用于信号捕捉。sigaction提供了比signal更完整、更细粒度的控制能力。- 当某个信号的处理函数被调用时,内核会自动将当前信号加入进程的信号屏蔽字,并在处理函数返回后恢复原有屏蔽状态。
- 因此,同类型标准信号默认采用串行处理方式,不会在处理函数执行期间递归式重入。
- 标准信号的未决状态通常采用位图记录,因此同一种信号在屏蔽期间即使多次到达,也不会逐次排队保存,而只会在解除屏蔽后至多再次递送一次。
- 如果希望在处理当前信号时,额外屏蔽其他信号,可以通过
sa_mask将对应信号加入附加屏蔽集合。
为了进一步加深理解,可以做两个实验: - 将多个信号加入信号集,测试哪些信号允许被屏蔽、哪些不允许被屏蔽。
- 在信号处理函数执行期间,结合未决信号集相关接口观察:当某个信号被屏蔽后再次产生时,它是否会在未决信号集中保留下来。