Linux系统–信号(3–信号的保存/阻塞)
第一节:Linux系统–信号(1–准备)-CSDN博客
第二节:Linux系统–信号(2–信号的产生)-CSDN博客
前言:
本节内容为:
- 补充了信号基础内容,将信号中的关键概念做了学习(递达,未决,阻塞,忽略)
- 简单补充了信号的生命周期
- 讲了未决信号集–>pending位图,以及用于阻塞信号的的blocked位图
- 包含了与信号阻塞相关的系统调用
- 验证了 在信号是标准信号的前提下:递达的时候是:先清除 pending 位图,再执行递达动作。
信号基础内容的补充:
实际执行信号的处理动作称为信号递达(Delivery)
信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block )某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。(这个区别我在讲信号的第一节就比较详细的讲过了)
我们来好好聊聊信号处理的这几个核心概念:递达(Delivery)、未决(Pending)、阻塞(Block)和忽略(Ignore)。
核心概念深度解析
- 信号递达 (Signal Delivery):
- 本质: 这是信号处理流程的终点。它指的是操作系统内核实际执行该信号对应的预设动作(Action)的那个时刻。
- 动作类型:
- 默认动作 (Default Action): 操作系统为每个信号预定义的标准行为。例如:
SIGTERM:终止进程。SIGINT(Ctrl+C):终止进程。SIGSTOP:停止进程(进入 TASK_STOPPED 状态)。SIGCONT:继续被停止的进程。SIGCHLD:忽略(父进程通常捕获它来处理子进程状态变化)。SIGKILL/SIGSTOP:强制终止/停止(不可捕获、阻塞或忽略)。
- 忽略动作 (Ignore Action): 进程明确告知内核,当此信号递达时,不做任何处理,直接丢弃该信号。这是通过将信号的处理程序设置为
SIG_IGN来实现的。 - 捕获动作 (Catch Action): 进程为信号注册了一个自定义的函数(信号处理程序)。当信号递达时,内核会临时中断进程的正常执行流,转而执行这个用户定义的函数。执行完毕后(除非处理程序导致进程终止),进程通常会从中断点继续执行。这是通过
signal()或sigaction()设置处理函数实现的。
- 默认动作 (Default Action): 操作系统为每个信号预定义的标准行为。例如:
- 关键点: 递达是信号最终被处理的时刻。在此之前,信号处于“未决”状态。
- 信号未决 (Signal Pending):
- 本质: 这是一个中间状态。它描述了信号从产生(例如,用户按下 Ctrl+C,另一个进程发送 kill 信号,内核检测到硬件异常等)到最终被递达处理之间的这段“等待期”。
- 产生原因:
- 信号刚刚产生,内核尚未调度到目标进程进行处理。
- 信号被目标进程阻塞了。
- 内核实现: 在 Linux 内核中,每个进程的进程控制块(
task_struct)里都有一个数据结构(通常是位图sigset_t)来记录该进程当前有哪些信号处于未决状态。内核在收到一个发给该进程的信号时,会首先检查该信号是否被阻塞。如果没有被阻塞,内核会尝试尽快递达它(通常是在进程从内核态返回用户态之前)。如果被阻塞了,内核就将该信号对应的位,在这个未决位图中置位,表示“有一个信号在等待处理”。 - 关键点: 未决状态是信号生命周期中的一个缓冲阶段。信号可以在此状态停留一段时间(直到进程解除阻塞或内核调度到它)。
- 信号阻塞 (Signal Blocking / Masking):
- 本质: 这是进程主动设置的一种过滤器或屏障。进程可以告诉内核:“我现在不想处理某些特定的信号,请把它们暂时拦住”。
- 目的:
- 保护临界区: 当进程正在执行一段不能被打断的关键代码(临界区)时,可以阻塞某些信号,防止信号处理程序意外修改共享数据或破坏程序状态。
- 控制信号处理时机: 进程可以选择在更合适的时机(例如,完成关键任务后)再处理某些信号。
- 内核实现: 每个进程同样在
task_struct中维护一个信号屏蔽字(Signal Mask)(也是sigset_t位图)。这个位图记录了当前进程阻塞哪些信号。当一个信号产生时:- 如果该信号在进程的阻塞位图中被标记(即被阻塞),那么无论其默认动作或自定义处理程序是什么,内核都不会立即递达它,而是将其标记为未决(在未决位图中置位)。
- 如果该信号未被阻塞,内核会尝试按照其预设动作(默认、忽略、捕获)进行递达。
- 操作: 进程使用
sigprocmask()系统调用来修改其当前的信号屏蔽字(阻塞集合)。可以添加阻塞信号、解除阻塞信号或替换整个阻塞集合。 - 关键点: 阻塞直接影响信号能否从产生直接进入递达阶段。阻塞发生在信号递达之前。
- 忽略 (Ignore):
- 本质: 这是信号递达后的一种处理动作。它是在信号成功通过阻塞屏障并到达递达阶段时,进程选择执行的一种操作——即“什么都不做”。
- 与阻塞的区别(核心!):
- 时机不同: 阻塞作用于递达之前,是信号处理的准入控制。忽略作用于递达之时,是信号处理的具体行为。
- 效果不同:
- 阻塞: 信号被卡在未决状态,永远不会被执行任何动作(包括默认动作、捕获动作和忽略动作)。信号就像被“冻结”了。
- 忽略: 信号已经通过了阻塞检查,被递达了。只是在递达时,进程选择执行“忽略”这个动作(
SIG_IGN),即丢弃该信号。
- 状态变化:
- 一个被阻塞的信号会保持在未决状态。
- 一个被忽略的信号在递达后(执行忽略动作),其未决状态会被清除(因为已经处理完了)。
- 关键点: 忽略是信号处理流水线末端的一个选项。它无法阻止信号进入未决状态或最终被递达(只要没有被阻塞)。它只是让递达时“静默”地完成。
生命周期图示与总结
想象一下信号的生命周期:
- 产生 (Generation): 信号因某种事件(按键、kill 命令、硬件错误等)被创建。
- 检查阻塞 (Block Check):
- 阻塞? -> 信号被放入未决 (Pending) 队列。生命周期在此暂停,直到阻塞解除。
- 未阻塞? -> 信号进入递达 (Delivery) 阶段。
- 递达 (Delivery): 内核执行该信号对应的动作:
- 默认动作 (Default): 执行 OS 定义的操作(终止、停止、忽略等)。
- 忽略动作 (Ignore): 直接丢弃信号(
SIG_IGN)。 - 捕获动作 (Catch): 中断进程执行用户定义的信号处理函数。
核心区别再强调:
- 阻塞 vs 忽略: 这是最容易混淆的点。记住:
- 阻塞 = 不让进门(递达)。 信号被挡在门外(未决状态)。
- 忽略 = 进门后不理睬(递达后处理)。 信号进来了,但你选择无视它。
- 未决: 是信号在“门口”(被阻塞)或“刚进门还没被接待”(未阻塞但内核尚未调度递达)的状态。
- 递达: 是信号“被正式接待处理”的时刻,处理方式可以是默认、忽略或捕获。
深入思考点:
SIGKILL和SIGSTOP: 这两个信号是特殊的。它们不能被阻塞、捕获或忽略。这是操作系统为了保证管理员始终有一种强制终止或停止失控进程的最后手段。尝试阻塞它们会被忽略(阻塞位图设置无效),尝试设置处理程序(signal()/sigaction())会失败。它们一旦产生,只要进程处于可运行状态,内核会强制递达它们(执行终止或停止动作)。- 实时信号 vs 标准信号: 标准信号(1-31)如果被阻塞,后续产生的相同信号通常会被合并(只记录一次未决)。而实时信号(34-64)被阻塞时,产生的每个信号都会被单独记录在未决队列中(队列化),解除阻塞后会按顺序递达。
- 信号处理程序执行环境: 当信号递达并执行捕获动作(用户处理函数)时,进程的执行上下文会被中断并切换到处理函数。处理函数执行完毕后,通常(除非调用
longjmp或终止进程)会返回到被中断的指令继续执行。编写安全的信号处理程序需要特别注意异步安全(async-signal-safe)问题。
pending位图和blocked位图
位图相关知识在:
位图(简单复现)_数据表的位图-CSDN博客
这两个位图是 Linux 内核实现信号未决 (Pending) 和阻塞 (Block) 机制的核心数据结构,它们紧密地存储在进程的进程控制块 (task_struct) 中。
核心概念回顾
pending位图 (信号未决集):- 作用: 记录当前有哪些信号已经产生但尚未递达。
- 状态: 表示信号处于 未决 (Pending) 状态。
- 触发: 当一个信号被发送给进程(无论是外部发送还是进程自身通过
raise/kill发送),并且该信号当前被阻塞,或者内核尚未调度到该进程处理信号时,内核就会将该信号对应的位在pending位图中置位 (set to 1)。 - 清除: 当该信号最终被递达(执行默认动作、忽略动作或捕获动作的处理函数执行完毕)时,内核会将该信号对应的位在
pending位图中清除 (clear to 0)。 - 特点:
- 标准信号 (1-31): 对于同一个标准信号,如果它已经处于未决状态(
pending位已置位),后续产生的相同信号通常不会被再次记录。内核只保留一个“有信号待处理”的标志。这是非队列化的。 - 实时信号 (34-64): 每个产生的实时信号都会被单独记录在
pending位图中(通过更复杂的队列结构实现,但位图状态反映其存在)。这是队列化的。即使同一个实时信号多次产生,只要它们都处于未决状态,pending位图会指示其存在(具体次数和顺序由内核的队列管理)。 - 位图置位仅表示“至少有一个该信号实例待处理”。
- 标准信号 (1-31): 对于同一个标准信号,如果它已经处于未决状态(
block位图 (信号屏蔽字 / 阻塞信号集):- 作用: 记录当前进程主动选择阻塞哪些信号。
- 状态: 表示信号被阻塞 (Blocked)。
- 设置: 进程通过系统调用
sigprocmask(或线程使用pthread_sigmask) 来修改block位图。可以添加 (SIG_BLOCK)、移除 (SIG_UNBLOCK) 阻塞信号,或完全替换 (SIG_SETMASK) 阻塞集合。 - 影响:
- 如果一个信号在
block位图中被置位(即被阻塞),那么当这个信号产生时,它不会立即递达,而是被放入pending位图(状态变为未决)。 - 只有当一个信号在
block位图中未被置位(即未被阻塞)时,它产生后内核才会尝试将其递达(如果此时进程是可中断的,通常在从内核态返回用户态之前检查并处理)。
- 如果一个信号在
- 特点:
- 阻塞是进程/线程主动设置的一种临时屏蔽机制。
SIGKILL(9) 和SIGSTOP(19) 不能被阻塞。尝试将它们添加到block位图的操作会被内核忽略。这是操作系统保证系统管理员始终能控制进程的机制。- 阻塞影响的是信号的递达时机,不影响信号的产生。被阻塞的信号依然会产生并被记录在
pending位图中。
数据结构:sigset_t
在 Linux 中,pending和 block位图的具体实现类型是 sigset_t。这是一个信号集 (signal set) 类型。
- 本质:
sigset_t是一个位掩码 (bitmask),通常是一个足够大的无符号整数(例如 32 位、64 位或 128 位)或由其组成的数组。每一位对应一个信号编号。 - 映射:
- 位 0 (最低位) 通常保留不使用(信号编号从 1 开始)。
- 位 1 对应信号
SIGHUP(1)。 - 位 2 对应信号
SIGINT(2)。 - …
- 位
n对应信号编号n。
- 操作: 用户空间程序不应该直接操作
sigset_t的位,而应该使用标准库提供的函数:sigemptyset(sigset_t *set): 初始化set为空集(所有位清零)。sigfillset(sigset_t *set): 初始化set为包含所有信号(所有位置一)。sigaddset(sigset_t *set, int signum): 将信号signum添加到set(对应位置一)。sigdelset(sigset_t *set, int signum): 将信号signum从set中移除(对应位清零)。sigismember(const sigset_t *set, int signum): 检查信号signum是否在set中(对应位是否为 1)。
- 内核存储: 在进程的
task_struct中,至少有两个sigset_t类型的字段:pending: 存储当前未决信号集。blocked(或sigblock,sigmask): 存储当前阻塞信号集(信号屏蔽字)。
内核处理流程 (简化)
当一个信号 sig被发送给进程 P 时:
- 内核接收信号: 内核中断当前执行流,处理信号发送请求。
- 检查
block(屏蔽字):- 如果
sig在 P 的blocked位图中被置位 (阻塞):- 内核将
sig在 P 的pending位图中置位(标记为未决)。 - 内核返回,进程 P 继续其之前的执行(信号被延迟处理)。
- 内核将
- 如果
sig不在 P 的blocked位图中 (未阻塞):- 内核尝试立即递达信号
sig。这发生在内核即将将控制权交还给进程 P 的用户空间代码之前(在return from kernel mode时)。 - 递达动作:
- 默认动作: 执行内核预定义的操作(终止、停止、忽略等)。
- 忽略动作 (
SIG_IGN): 内核清除pending中sig的位(如果之前因其他原因设置),不做其他操作。 - 捕获动作 (用户处理函数): 内核设置用户栈帧,准备跳转到用户注册的信号处理函数
handler。在调用handler之前,内核通常会自动将sig添加到进程当前的blocked位图中(防止处理函数被同一个信号递归中断),并清除pending中sig的位。处理函数执行完毕后,内核通常会恢复之前的blocked位图状态。
- 内核尝试立即递达信号
- 如果
- 进程解除阻塞 (
sigprocmask):- 当进程 P 调用
sigprocmask(SIG_UNBLOCK, &set, NULL)或sigprocmask(SIG_SETMASK, &newmask, NULL)(其中newmask不再阻塞某些信号)时,内核会修改 P 的blocked位图。 - 内核在修改
blocked位图后,会立即检查pending位图:- 对于
blocked位图中被清除(即解除阻塞)的信号位,如果该信号在pending位图中被置位(即存在未决实例),则内核会立即安排递达该信号(遵循步骤 2 中的递达规则)。 - 解除阻塞操作是触发处理未决信号的关键时机之一(另一个是进程从内核态返回用户态时)。
- 对于
- 当进程 P 调用
关键点总结
pending位图:记录已产生但未处理的信号(未决状态)。是信号等待处理的标志。block位图:控制哪些信号暂时不允许递达。是信号处理的开关/过滤器。- 交互:
- 信号产生 -> 检查
block-> 若阻塞,则置位pending;若未阻塞,则尝试递达。 - 解除阻塞信号 -> 检查
pending-> 若该信号未决,则递达。
- 信号产生 -> 检查
sigset_t: 用户空间操作信号集的抽象数据类型,底层是位图。SIGKILL/SIGSTOP: 特权信号,无法被阻塞 (block对其无效),无法被捕获或忽略。- 标准信号 vs 实时信号: 主要区别在于
pending的语义(非队列化 vs 队列化),block的作用机制相同。
理解技巧
- 想象
block位图是一道门禁。信号想进门(递达)。 - 如果信号在
block名单上(对应位置 1),它就被拦在门外,只能在门外的等候区 (pending位图置 1) 等着。 - 当门卫 (
sigprocmask) 把某个信号从block名单上划掉(解除阻塞),门卫会立刻检查等候区 (pending)。 - 如果发现该信号在等候区 (
pending位为 1),就立刻放它进门处理(递达)。 - 信号
SIGKILL和SIGSTOP是超级 VIP,它们无视门禁 (block),想来就来,门卫必须立刻放行处理(强制递达)。
task_struct内部中pending和blocked
// 这是5.6.1版本的Linux系统中的task_struct部分代码
struct task_struct {
// ...
/* Signal handlers: */
struct signal_struct *signal; // (1) 进程组共享的信号信息
struct sighand_struct __rcu *sighand; // (2) 指向信号处理程序描述符
sigset_t blocked; // (3) 当前线程的信号屏蔽字(阻塞掩码)
sigset_t real_blocked; // (4) 临时信号屏蔽字(用于TIF_SIGPENDING)
/* Restored if set_restore_sigmask() was used: */
sigset_t saved_sigmask; // (5) 保存的信号屏蔽字(用于系统调用重启等)
struct sigpending pending; // (6) 挂起(未决)信号队列
// ...
};
1. struct signal_struct *signal; // 不是本节重点,简单了解
- 作用: 这是一个指向
signal_struct结构体的指针。这个结构体包含了整个线程组(通常就是一个进程及其所有线程)共享的信号相关信息。 - 关键内容 (
signal_struct内部):struct sigpending shared_pending;: 共享的挂起信号队列。这是发送给整个线程组的信号(例如,通过kill(pid, sig)发送给进程 PID)的存放位置。所有线程共享这个队列。当内核需要向线程组发送信号时,信号会先放在这里。struct rlimit rlim[RLIM_NLIMITS];: 进程资源限制。这些限制(如最大文件打开数RLIMIT_NOFILE、最大栈大小RLIMIT_STACK等)是进程级别的,所有线程共享相同的限制。int group_exit_code;: 线程组退出状态码。当线程组开始退出时(例如,主线程退出或收到终止信号),这个字段存储退出状态。struct task_struct *group_exit_task;: 执行线程组退出的任务。指向负责执行线程组退出流程(如调用do_exit())的任务。struct tty_struct *tty;: 控制终端。记录进程的控制终端信息,与控制终端相关的信号(如SIGHUP,SIGTTOU)处理需要用到。struct pid *tty_old_pgrp;: 旧的终端前台进程组 ID。用于作业控制。int leader;: 是否为会话首进程 (Session Leader)。struct list_head thread_head;: 线程链表头。指向该线程组中所有线程的task_struct链表。wait_queue_head_t wait_chldexit;: 等待子进程退出的等待队列。waitpid()等系统调用会用到。...(还有其他成员,如统计信息、计时器等)。
- 重要性:
signal_struct是进程级别(线程组级别) 信号信息的容器。它确保了属于同一个进程的所有线程在资源限制、控制终端、共享信号处理等方面具有一致的视图。shared_pending是实现“发送给进程的信号能被任意线程处理”的关键。
2. struct sighand_struct __rcu *sighand; // 重点
作用: 这是一个指向
sighand_struct结构体的指针(使用__rcu注解,表示通过 RCU 机制保护,用于安全并发访问)。这个结构体包含了该线程(任务)的信号处理程序定义。关键内容 (
sighand_struct内部):atomic_t count;: 引用计数器。因为sighand_struct可以在线程间共享(默认情况下,新线程继承父线程的信号处理设置),或者被克隆(CLONE_SIGHAND标志)。count记录有多少个task_struct引用了这个sighand_struct。struct k_sigaction action[_NSIG];: 信号处理动作数组。这是最核心的部分!它是一个数组,索引对应信号编号(SIGKILL和SIGSTOP的处理程序虽然无效,但位置保留)。每个struct k_sigaction元素定义了当信号signum递达时,内核应该执行的动作:struct k_sigaction { struct sigaction sa; // 兼容用户空间的 sigaction 结构 // 或者更常见的内核内部表示: // void (*handler)(int); // 用户空间处理函数地址 (SIG_DFL, SIG_IGN, 或用户函数) // unsigned long flags; // 标志位 (SA_RESTART, SA_SIGINFO, SA_NOCLDSTOP 等) // void (*restorer)(void); // 恢复函数 (通常由 libc 提供,用于恢复上下文) // sigset_t mask; // 执行此信号处理程序时阻塞的信号集 };handler: 指定处理方式:SIG_DFL(默认动作),SIG_IGN(忽略), 或用户自定义函数的地址。flags: 控制处理行为的标志,如SA_RESTART(被信号中断的系统调用自动重启),SA_SIGINFO(处理程序需要额外信息,使用sa_sigaction而非sa_handler),SA_NOCLDSTOP(子进程停止时不产生SIGCHLD) 等。mask: 当内核正在执行这个特定信号的处理程序时,自动阻塞哪些其他信号。这通常包括被处理的信号本身(防止递归中断),也可以包括用户指定的其他信号。
spinlock_t siglock;: 自旋锁。用于保护对sighand_struct的并发访问(例如,修改action数组)。
重要性:
sighand_struct定义了线程如何响应信号。action数组存储了每个信号的具体处理程序、标志和屏蔽字。count和siglock确保了在多线程环境下安全地访问和修改这些处理程序。每个线程可以有自己的sighand_struct(如果使用了CLONE_SIGHAND则共享),这使得线程可以独立修改自己的信号处理方式(尽管共享signal_struct中的其他信息)。
3. sigset_t blocked; // 重点
- 作用: 这是当前线程的信号屏蔽字 (Signal Mask)。它是一个位图(
sigset_t类型),每一位代表一个信号。 - 工作原理:
- 如果某个信号对应的位在
blocked中被置位(=1),则表示该信号当前被此线程阻塞。 - 当一个被阻塞的信号产生时(无论是发送给线程还是线程组),它不会立即递达给这个线程。对于线程私有信号,它会被放入该线程的
pending队列(见下文)。对于线程组共享信号,它会被放入signal->shared_pending队列。 - 只有当该信号在
blocked中的位被清除(=0)时(通过sigprocmask/pthread_sigmask),内核才会检查pending或shared_pending中是否有该信号的未决实例,并尝试递达它。
- 如果某个信号对应的位在
- 操作: 线程通过
sigprocmask(单线程进程) 或pthread_sigmask(多线程进程) 系统调用修改自己的blocked掩码。 - 例外:
SIGKILL(9) 和SIGSTOP(19) 不能被阻塞。尝试将它们添加到blocked掩码的操作会被内核忽略。 - 重要性:
blocked是线程主动控制信号递达时机的主要机制。它保护代码关键区域不被信号中断,或延迟处理非关键信号。
4. sigset_t real_blocked; // 较为重点
- 作用: 这是一个临时的信号屏蔽字。它的主要用途与内核内部的
TIF_SIGPENDING标志位协作。 - 工作原理:
- 当内核需要为一个线程处理未决信号时(例如,在从系统调用或中断返回用户空间之前),它会检查线程的
TIF_SIGPENDING标志是否被设置(表示有待处理信号)。 - 在实际调用信号处理程序之前,内核会执行一个关键操作:
- 将线程当前的
blocked掩码临时保存到real_blocked中。 - 将线程的
blocked掩码替换为即将执行的信号处理程序的mask(定义在sighand->action[signum].sa.mask中)。这通常包括阻塞当前正在处理的信号本身(防止递归)以及用户指定的其他信号。
- 将线程当前的
- 这样,在信号处理程序执行期间,
blocked掩码反映的是处理程序要求的屏蔽集。 - 当信号处理程序返回后,内核通过
sigreturn系统调用(通常由restorer函数触发)恢复线程原始的blocked掩码(从real_blocked复制回来)。
- 当内核需要为一个线程处理未决信号时(例如,在从系统调用或中断返回用户空间之前),它会检查线程的
- 重要性:
real_blocked是实现信号处理程序执行期间临时修改信号屏蔽字这一语义的关键。它保存了信号处理程序执行前线程的“真实”阻塞状态,以便在处理程序结束后恢复。用户通常不直接操作real_blocked,它由内核在信号递达流程中自动管理。
5. sigset_t saved_sigmask; // 简单了解
- 作用: 用于保存和恢复线程的信号屏蔽字,主要服务于特定的系统调用行为(如
pselect(),ppoll(),epoll_pwait())和pthread同步原语。 - 工作原理:
- 当线程调用一个设计为临时解除信号阻塞并等待事件的系统调用(如
pselect(nfds, readfds, writefds, exceptfds, timeout, sigmask))时:- 内核会将线程当前的
blocked掩码保存到saved_sigmask中。 - 将线程的
blocked掩码临时设置为用户传入的sigmask参数(通常是一个在等待期间希望解除阻塞的信号集)。 - 然后线程进入等待状态。
- 内核会将线程当前的
- 当等待的事件发生(或超时)后,线程从系统调用返回。
- 在返回用户空间之前,内核会将
saved_sigmask中的值恢复到blocked掩码中。
- 当线程调用一个设计为临时解除信号阻塞并等待事件的系统调用(如
- 与
set_restore_sigmask(): 内核函数set_restore_sigmask()用于设置一个标志,告诉内核在系统调用退出路径上需要执行上述的恢复操作(将saved_sigmask恢复到blocked)。注释/* Restored if set_restore_sigmask() was used: */明确指出了这一点。 - 重要性:
saved_sigmask使得系统调用能够原子地修改信号屏蔽字并进入等待状态,并在返回时自动恢复之前的屏蔽状态。这避免了在用户空间手动保存、修改、恢复屏蔽字可能出现的竞态条件。
6. struct sigpending pending; // 重点 // 实时信号部分可以仅作简单了解
作用: 存储发送给这个特定线程的、尚未递达(未决)的信号。
结构 (
struct sigpending内部):struct sigpending { struct list_head list; // (6a) 挂起信号链表的头 sigset_t signal; // (6b) 挂起信号位图 };sigset_t signal;(6b): 一个位图,每一位代表一个信号。如果某位被置位(=1),表示至少有一个该类型信号的实例正挂起(未决)在此线程的队列中。对于标准信号 (1-31): 内核通常只记录“有”或“无”,多次发送同一标准信号可能只在该位图上体现一次(非队列化)。对于实时信号 (34-64): 该位图仅表示存在挂起的实时信号,具体次数和顺序由链表管理。struct list_head list;(6a): 一个链表头,链接所有挂起的实时信号实例。每个挂起的信号(主要是实时信号)由一个struct sigqueue结构表示:struct sigqueue { struct list_head list; // 链表节点 int flags; // 标志 (如 SIGQUEUE_PREALLOC) siginfo_t info; // (6c) 携带的信号信息 struct user_struct *user; // 资源计数的用户 };siginfo_t info;(6c): 这是最重要的成员。它包含了关于信号的详细信息:int si_signo;: 信号编号。int si_code;: 信号来源代码(如SI_USER,SI_KERNEL,SI_QUEUE,SI_TIMER,SI_ASYNCIO等)。union: 一个联合体,包含信号相关的附加数据。例如:- 发送者 PID (
pid_t si_pid;) - 发送者 UID (
uid_t si_uid;) - 定时器 ID (
void *si_tid;) - 用户自定义值 (
int si_int; void *si_ptr;) - 导致信号的地址 (
void *si_addr;) - 导致信号的文件描述符 (
int si_fd;) - 子进程退出状态 (
int si_status;) 等。
- 发送者 PID (
信号入队:
- 当信号被发送给一个特定线程(例如,使用
pthread_kill()或tgkill())时,内核会尝试将其放入目标线程的pending.list链表(实时信号)或设置pending.signal位图(标准信号)。 - 发送给线程组(进程)的信号(使用
kill())会被放入signal->shared_pending。
- 当信号被发送给一个特定线程(例如,使用
信号递达:
- 当内核决定为线程递达一个信号时:
- 对于标准信号:通常从
pending.signal位图判断是否存在,然后根据sighand->action[]执行动作。递达后清除位图中的对应位。 - 对于实时信号(或需要
siginfo的标准信号):内核会从pending.list链表中取出一个struct sigqueue,提取其中的siginfo_t信息,传递给信号处理程序(如果设置了SA_SIGINFO)。处理完毕后,释放该sigqueue结构。如果链表空了,则清除pending.signal中的对应位。
- 对于标准信号:通常从
- 当内核决定为线程递达一个信号时:
重要性:
pending结构是线程私有未决信号队列的核心。list链表实现了实时信号的队列化(FIFO),确保多次发送的实时信号都能被依次处理。signal位图提供了快速判断是否有任何类型信号挂起的方式。siginfo_t使得信号处理程序能够获取关于信号来源和原因的详细信息。
总结:
signal_struct: 进程(线程组)级别的共享信息(共享挂起信号、资源限制、控制终端等)。sighand_struct: 线程级别的信号处理程序定义(处理函数、标志、处理时屏蔽字)。blocked: 线程当前生效的信号屏蔽字(阻塞掩码)。real_blocked: 保存信号处理程序执行前屏蔽字的临时存储,用于在信号处理期间应用处理程序指定的屏蔽字。saved_sigmask: 保存系统调用/等待前屏蔽字,用于在特定系统调用返回后恢复原始屏蔽字。pending: 线程私有的挂起(未决)信号队列(链表用于实时信号队列化和携带siginfo,位图用于快速检测)。
这些结构体成员紧密协作,共同实现了 Linux 内核复杂而强大的信号处理机制,涵盖了信号产生、阻塞、挂起、递达、处理程序执行、屏蔽字管理以及进程/线程组语义等各个方面。
与信号阻塞相关的系统调用
相关函数的详细讲解在:Linux系统–信号–信号屏蔽(阻塞)核心函数-CSDN博客
简略讲解:
核心函数:sigprocmask和 pthread_sigmask
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);- 作用: 这是用于单线程进程或进程的主线程修改其信号屏蔽字 (
blocked位图) 的主要系统调用。它允许进程检查或修改当前阻塞哪些信号。 - 参数:
how: 指定如何修改当前的阻塞信号集 (blocked)。可选值:SIG_BLOCK: 将set指向的信号集添加到当前的阻塞信号集中。新的阻塞集 = 当前阻塞集 UNIONset。SIG_UNBLOCK: 将set指向的信号集从当前的阻塞信号集中移除。新的阻塞集 = 当前阻塞集 MINUSset。SIG_SETMASK: 直接将当前的阻塞信号集替换为set指向的信号集。新的阻塞集 =set。
set: 指向一个sigset_t类型的信号集。它包含了根据how参数要阻塞、解除阻塞或设置的信号集合。如果set是NULL,则how参数被忽略,函数仅用于获取当前的阻塞集(通过oldset)。oldset: 指向一个sigset_t类型的变量。如果非NULL,函数会在修改阻塞集之前,将旧的阻塞信号集存储在这个变量中。这允许你稍后恢复之前的阻塞状态。如果不需要保存旧状态,可以设置为NULL。
- 返回值: 成功返回 0;失败返回 -1 并设置
errno。 - 重要限制:
SIGKILL(9) 和SIGSTOP(19) 不能被阻塞。尝试将它们包含在set中会被内核静默忽略。 - 内核动作: 成功调用后,内核会更新进程的
task_struct->blocked字段。更重要的是,在修改blocked之后,内核会立即检查pending(线程私有) 和signal->shared_pending(进程共享) 队列:- 对于任何刚被解除阻塞(即从
blocked集中移除)的信号,如果它在pending或shared_pending中处于未决状态 (pending位图置位),内核会安排立即递达该信号(在当前调用返回到用户空间之前或之后不久)。
- 对于任何刚被解除阻塞(即从
- 适用场景: 主要用于传统的单线程进程,或者在多线程程序中由主线程使用(但要注意线程安全性,见下文)。
- 作用: 这是用于单线程进程或进程的主线程修改其信号屏蔽字 (
int pthread_sigmask(int how, const sigset_t *set, sigset_t *oldset);- 作用: 这是 POSIX 线程 (
pthread) API 中用于修改调用线程的信号屏蔽字 (blocked位图) 的函数。它的功能与sigprocmask完全相同。 - 参数: 与
sigprocmask完全一致 (how,set,oldset)。 - 返回值: 成功返回 0;失败返回一个非零的错误码(POSIX 线程错误码,不是
errno)。 - 重要区别:
- 线程作用域:
pthread_sigmask只影响调用它的那个线程的信号屏蔽字。每个线程都有自己的blocked位图。 sigprocmask在多线程中的行为: 在 POSIX 多线程程序中,sigprocmask的行为是未定义的(Undefined Behavior)。它可能只影响主线程,也可能影响整个进程,具体实现依赖。因此,在多线程程序中,必须使用pthread_sigmask来修改任何线程(包括主线程)的信号屏蔽字。
- 线程作用域:
- 内核动作: 与
sigprocmask相同:更新调用线程的task_struct->blocked,并检查该线程的pending队列和进程的shared_pending队列,递达刚解除阻塞的未决信号。 - 适用场景:所有多线程程序中的信号屏蔽操作。
- 作用: 这是 POSIX 线程 (
辅助函数:操作信号集 (sigset_t)
要使用 sigprocmask或 pthread_sigmask,你需要能够创建和操作 sigset_t类型的信号集。以下是最常用的函数:
int sigemptyset(sigset_t *set);- 作用: 初始化
set指向的信号集,使其不包含任何信号(所有位清零)。
- 作用: 初始化
int sigfillset(sigset_t *set);- 作用: 初始化
set指向的信号集,使其包含所有支持的信号(所有位置一)。注意:SIGKILL和SIGSTOP理论上也会被包含,但后续阻塞操作会忽略它们。
- 作用: 初始化
int sigaddset(sigset_t *set, int signum);- 作用: 将信号
signum添加到set指向的信号集中(将对应位置一)。
- 作用: 将信号
int sigdelset(sigset_t *set, int signum);- 作用: 将信号
signum从set指向的信号集中移除(将对应位清零)。
- 作用: 将信号
int sigismember(const sigset_t *set, int signum);- 作用: 检查信号
signum是否是set指向的信号集的成员(对应位是否为 1)。是则返回 1,不是则返回 0,出错返回 -1。
- 作用: 检查信号
让一个进程阻塞某个信号的具体使用流程
以下流程以单线程进程使用 sigprocmask为例。如果是多线程程序中的某个线程,只需将 sigprocmask替换为 pthread_sigmask。
目标: 让进程在执行一段关键代码(临界区)时阻塞 SIGINT(Ctrl+C) 信号,防止被中断。执行完临界区后恢复原来的信号屏蔽状态。
步骤:
声明变量:
#include <signal.h>sigset_t new_mask, old_mask; // 定义新的信号集和用于保存旧信号集的变量初始化新的信号集:
if (sigemptyset(&new_mask) == -1) { perror("sigemptyset"); exit(EXIT_FAILURE); }创建一个空的信号集
new_mask。添加要阻塞的信号:
if (sigaddset(&new_mask, SIGINT) == -1) { // 将 SIGINT 添加到 new_mask perror("sigaddset"); exit(EXIT_FAILURE); } // 如果需要阻塞多个信号,可以多次调用 sigaddset // if (sigaddset(&new_mask, SIGQUIT) == -1) { ... }设置新的阻塞信号集 (屏蔽字),并保存旧的:
if (sigprocmask(SIG_BLOCK, &new_mask, &old_mask) == -1) { perror("sigprocmask"); exit(EXIT_FAILURE); }SIG_BLOCK: 表示我们要将new_mask中的信号 (SIGINT) 添加到当前阻塞集中。&old_mask: 函数会将修改前的阻塞集保存在old_mask中,以便后续恢复。
执行临界区代码:
/* 这里是你的关键代码区域 (临界区) */ /* 在此区域执行期间,SIGINT 信号将被阻塞。如果用户按下 Ctrl+C, 信号会被标记为未决 (pending),但不会递达 (执行处理程序或终止进程), 直到我们解除阻塞。 */ do_critical_work();恢复旧的阻塞信号集:
if (sigprocmask(SIG_SETMASK, &old_mask, NULL) == -1) { perror("sigprocmask (restore)"); exit(EXIT_FAILURE); }SIG_SETMASK: 表示我们要直接将阻塞集设置为old_mask的值(即恢复之前的阻塞状态)。NULL: 因为我们不需要再次保存当前(即将被替换的)阻塞集,所以第三个参数设为NULL。- 关键点: 在恢复旧屏蔽字 (
old_mask) 的过程中,内核会检查SIGINT是否在恢复后被解除了阻塞(即old_mask是否阻塞SIGINT?)。如果old_mask不阻塞SIGINT,并且在我们阻塞期间有SIGINT信号产生(处于未决状态),那么内核会立即递达这个SIGINT信号(执行默认动作终止进程,或执行用户注册的处理函数)。
完整示例代码 (单线程):
#include <stdio.h>#include <stdlib.h>#include <signal.h>#include <unistd.h> // for sleepvoid do_critical_work(void) {printf("Entering critical section...\n");sleep(5); // 模拟耗时操作,期间可以尝试按 Ctrl+Cprintf("Exiting critical section.\n");}int main(void) {sigset_t new_mask, old_mask;// 1. 创建空信号集if (sigemptyset(&new_mask) == -1) {perror("sigemptyset");exit(EXIT_FAILURE);}// 2. 添加 SIGINT 到新信号集if (sigaddset(&new_mask, SIGINT) == -1) {perror("sigaddset");exit(EXIT_FAILURE);}// 3. 阻塞 SIGINT,并保存旧屏蔽字if (sigprocmask(SIG_BLOCK, &new_mask, &old_mask) == -1) {perror("sigprocmask (block)");exit(EXIT_FAILURE);}// 4. 执行临界区代码 (此时 SIGINT 被阻塞)do_critical_work();// 5. 恢复旧的信号屏蔽字 (解除对 SIGINT 的阻塞)if (sigprocmask(SIG_SETMASK, &old_mask, NULL) == -1) {perror("sigprocmask (restore)");exit(EXIT_FAILURE);}// 如果临界区期间产生了 SIGINT,并且旧屏蔽字不阻塞它,这里可能会立即递达 SIGINT!printf("Back to normal state. You can now interrupt with Ctrl+C.\n");pause(); // 等待一个信号 (方便测试)return 0;}
运行与观察:
- 编译并运行程序。
- 在程序打印
"Entering critical section..."并开始睡眠的 5 秒内,按下Ctrl+C。 - 你会发现程序没有立即退出!它继续完成了睡眠,打印
"Exiting critical section.",然后打印"Back to normal state..."。 - 这证明
SIGINT在临界区被成功阻塞了。信号被标记为未决 (pending)。 - 当调用
sigprocmask(SIG_SETMASK, &old_mask, NULL)恢复旧屏蔽字(通常旧屏蔽字不阻塞SIGINT)时,内核检测到未决的SIGINT并立即递达它。因为SIGINT的默认动作是终止进程,所以程序会在恢复屏蔽字后立即退出,可能来不及打印最后一条信息或只打印一部分。要看到最后一条信息,你可以为SIGINT注册一个处理函数,在函数里打印信息而不是直接退出。
多线程程序注意事项:
- 在
main函数创建任何线程之前,主线程可以使用sigprocmask设置初始屏蔽字,这些设置通常会被新创建的线程继承。 - 在任何线程(包括主线程)中需要修改自身的信号屏蔽字时,必须使用
pthread_sigmask。 - 发送给进程的信号 (
kill(pid, sig)) 会进入shared_pending队列,可以被任意一个不阻塞该信号的线程处理。 - 发送给特定线程的信号 (
pthread_kill(tid, sig)) 会进入目标线程的私有pending队列,只有该线程自己能处理(当其解除阻塞时)。
总结关键流程:
- 准备信号集: 使用
sigemptyset,sigaddset(或sigfillset,sigdelset) 构建一个包含你希望阻塞的信号的sigset_t。 - 阻塞信号 (设置屏蔽字): 使用
sigprocmask(单线程/主线程初始设置) 或pthread_sigmask(多线程/任何线程) 的SIG_BLOCK操作(或SIG_SETMASK),将新构建的信号集应用到当前线程的屏蔽字中。强烈建议保存旧屏蔽字 (oldset)。 - 执行受保护代码: 在阻塞状态下执行你不希望被特定信号中断的代码。
- 恢复屏蔽字: 使用
sigprocmask或pthread_sigmask的SIG_SETMASK操作,将屏蔽字恢复为之前保存的旧值 (oldset)。这一步隐含着解除对之前阻塞信号的阻塞,并可能触发之前未决信号的递达。
执行代码,观察现象
在前面的代码中,我们已经尝试过正常的阻塞信号了,接下来我们写代码来验证一些别的事情:
我们都知道,信号递达的时候,进程中的pending位图中相应的位置要被置为0。
结论1:那么是先将进程中的pending位图中相应的位置 置为0,再递达处理?
结论2:还是先递达处理,再将进程中的pending位图中相应的位置 置为0?
#include <iostream>#include <signal.h>#include <unistd.h>#include <cassert>#include <sys/wait.h>// 打印未决信号集void PrintSig(sigset_t &pending){std::cout << "Pending bitmap: ";// 虽然我们没办法直接打印未决信号集// 但是我们可以借助sigismember函数,判断未决信号集中是否有相应的信号// 如果有就打印1// 没有就打印0for (int signo = 31; signo > 0; signo--){if (sigismember(&pending, signo)){std::cout << "1";}else{std::cout << "0";}}std::cout << std::endl;}void handler(int signo){sigset_t pending;sigemptyset(&pending);int n = sigpending(&pending); // 我正在处理2号信号!!assert(n == 0);// 3. 打印pending位图中的收到的信号std::cout << "递达中...: ";PrintSig(pending); // 0: 递达之前,pending 2号已经被清0. 1: pending 2号被清0一定是递达之后std::cout << signo << " 号信号被递达处理..." << std::endl;}int main(){// 对2号信号进行自定义捕捉 --- 不让进程因为2号信号而终止signal(2, handler);// 1. 屏蔽2号信号// 声明两个新的信号集,block用来设置要屏蔽的信号,oblock用来保存旧的信号集sigset_t block, oblock;// 对两个信号集进行初始化sigemptyset(&block);sigemptyset(&oblock);// 添加要阻塞的信号// 注意: 这里根本就没有设置进当前进程的PCB blocked位图中// 这里只是添加到了我们创建的block信号集里sigaddset(&block, 2);// 1.1 开始屏蔽2号信号,其实就是设置进入内核中int n = sigprocmask(SIG_SETMASK, &block, &oblock);assert(n == 0);// 判断是否执行成功// 打印信息std::cout << "block 2 signal success" << std::endl;std::cout << "pid: " << getpid() << std::endl;int cnt = 0;while (true){// 2. 获取进程的pending位图// 也就是获取现在有多少未决信号// 同样是先声明新的信号集sigset_t pending;// 初始化sigemptyset(&pending);// 获取未决信号集n = sigpending(&pending);assert(n == 0);// 判断是否执行成功// 3. 打印pending位图中的收到的信号PrintSig(pending);cnt++;// 4. 解除对2号信号的屏蔽if (cnt == 20){std::cout << "解除对2号信号的屏蔽" << std::endl;n = sigprocmask(SIG_UNBLOCK, &block, &oblock); // 2号信号会被立即递达, 默认处理是终止进程assert(n == 0);}sleep(1);}return 0;}
这里我再给大家简单介绍有一下这个代码的思路:(我们以2号信号为例)
我们的思路是在信号递达处理函数中打印出未决信号集的情况,来判断前面问题中提到的先后。如果在递达处理函数中打印出来的未决信号集中2号信号的标志位已经被置0,则是结论1。反之就是结论2。
所以我们得自定义进程对2号信号的处理函数。让信号递达的时候,打印未决信号集的详情。
因为只有当信号被进程阻塞(屏蔽)的时候,信号才会被放入未决信号集。(如果没有阻塞信号,内核会倾向于立刻处理这个信号,导致我们无法看到未决信号集的一个变化情况)
所以我们得先阻塞2号信号。阻塞成功后打印信息来提醒我们情况,其中最主要的是进程PID,因为我们自定义了进程对2号信号的处理函数,所以我们得使用别的信号来终止进程。
紧接着就进入到循环中,我们每隔一秒打印一次未决信号集的情况,一共打印20次,也就是20秒,在这20秒时间中,我们要给进程发送2号信号,这样未决信号集中的2号信号标志位才会被置为1。
当打印了20次未决信号集后,我们就解除进程对2号信号的阻塞,进程执行自定义的处理函数,打印并观察未决信号集,发现:
未决信号集在递达处理过程中就已经被处理了,也就是说:进程中的pending位图中相应的位置已经被置为0了。
所以由此我们就可以得出在标准信号中:是先将进程中的pending位图中相应的位置 置为0,再递达处理。

在 Linux 内核中,信号递达时 pending 位图的清零时机取决于信号的类型(标准信号 vs 实时信号)以及信号处理程序是否需要 siginfo_t信息。让我们分情况详细说明:
核心原则:状态一致性
内核的核心目标是维护信号状态的一致性。在信号被真正处理(递达)之前,它必须被标记为“正在处理中”,以防止在处理过程中产生歧义(例如,同一个信号实例被处理两次)。
情况 1:标准信号 (Signals 1-31) - 非队列化,通常不需要 siginfo
- 检查 pending: 内核决定递达一个标准信号
sig。 - 清除 pending 位图:首先,内核会立即清除该线程的
pending.signal位图中对应于sig的位(将其置为 0)。这是关键一步!- 为什么先清除? 因为标准信号是非队列化的。
pending.signal位图中的置位仅表示“至少有一个sig信号待处理”。清除它表示“这个待处理的信号实例现在被认领处理了”。即使后续在信号处理程序执行期间或之前又有新的sig信号产生,它们会被视为新的待处理实例(可能再次置位 pending 位图,也可能被丢弃,取决于信号类型和产生时机)。
- 为什么先清除? 因为标准信号是非队列化的。
- 准备递达: 内核根据
sighand->action[sig]的设置准备执行动作(默认、忽略、捕获)。 - 执行递达动作:
- 忽略 (
SIG_IGN): 内核不做其他操作(pending 位图在第 2 步已清除)。 - 默认动作: 内核执行默认操作(如终止进程)。pending 位图状态已无关紧要。
- 捕获动作 (用户处理程序):
- 内核会临时修改线程的
blocked掩码(通常阻塞sig本身和action[sig].sa.mask中指定的信号),并将旧的blocked掩码保存到real_blocked。 - 内核设置用户栈帧,准备调用用户注册的信号处理函数
handler。 - 控制权转移到用户空间的
handler函数执行。 - 用户处理函数执行完毕(或调用
longjmp/exit等)。 - 如果处理函数正常返回,内核通过
sigreturn系统调用恢复原始的blocked掩码(从real_blocked恢复)。
- 内核会临时修改线程的
- 忽略 (
总结 (标准信号):先清除 pending 位图,再执行递达动作。
情况 2:实时信号 (Signals 34-64) 或 需要 SA_SIGINFO的标准信号 - 队列化,携带 siginfo
- 检查 pending: 内核决定递达一个信号
sig(实时信号或设置了SA_SIGINFO的标准信号)。 - 获取 sigqueue 结构: 内核从该线程的
pending.list链表中取出一个代表该信号实例的struct sigqueue节点。 - 更新 pending 状态:
- 内核检查
pending.list链表中是否还有其他属于信号sig的sigqueue节点。 - 如果没有其他
sig节点了: 内核清除pending.signal位图中对应于sig的位(置 0)。这表示该信号类型暂时没有未决实例了。 - 如果还有其他
sig节点:pending.signal位图中sig对应的位保持置位 (1),表示仍有同类型信号在排队等待。
- 内核检查
- 准备递达: 内核提取
sigqueue->info中的siginfo_t信息。 - 执行递达动作:
- 忽略 (
SIG_IGN): 内核释放取出的sigqueue结构(pending 状态已在第 3 步更新)。 - 默认动作: 内核执行默认操作并释放
sigqueue。 - 捕获动作 (用户处理程序):
- 内核临时修改
blocked掩码(保存到real_blocked)。 - 内核设置用户栈帧,并将
siginfo_t信息(以及可能的ucontext)传递给用户注册的处理函数(sa_sigaction或三参数的sa_handler)。 - 控制权转移到用户空间的信号处理函数执行。
- 用户处理函数执行完毕。
- 内核通过
sigreturn恢复原始的blocked掩码。 - 内核释放取出的
sigqueue结构。
- 内核临时修改
- 忽略 (
总结 (实时信号/SA_SIGINFO):
- 先从
pending.list取出一个sigqueue节点(代表一个具体的信号实例)。 - 然后根据队列中是否还有同类型信号,更新
pending.signal位图(可能清零,也可能保持置位)。 - 最后执行递达动作(包括调用用户处理程序)并释放
sigqueue结构。
为什么这样设计?
- 状态原子性: 对于标准信号,先清除 pending 位图确保了“认领”该信号实例的原子性。一旦清除,即使新信号立刻产生,也被视为新事件。
- 队列化管理: 对于实时信号,
pending.signal位图指示的是“该信号类型是否有未决实例”,而不是实例数量。取出一个节点后,需要检查队列是否为空来决定是否清除位图。位图的存在是为了让内核快速判断是否有某类信号待处理,而不必遍历链表。 siginfo_t的生命周期:siginfo_t信息存储在sigqueue结构中。内核必须在确保该信息被安全传递(或决定丢弃)之后,才能释放sigqueue。在用户处理程序执行期间,siginfo_t是有效的(位于用户栈或寄存器中)。- 避免重复处理: 清除 pending 状态(位图或取出节点)的操作发生在实际执行处理动作之前,这严格保证了同一个信号实例不会被递达两次。
结论:
- 标准信号 (非 SA_SIGINFO):先清除
pending.signal位图中的对应位,再执行递达动作。 - 实时信号 或 设置了
SA_SIGINFO的标准信号:- 先从
pending.list取出代表该信号实例的sigqueue节点。 - 然后根据队列情况更新
pending.signal位图(可能清零对应位)。 - 最后执行递达动作(传递
siginfo_t)并释放sigqueue结构。
- 先从