多线程程序的信号投递 —— 内核态/用户态切换与"哪个线程执行 handler"的完整时序
signals-kernel 讲了信号的内核数据结构(
sighand_struct/signal_struct)和基本生命周期,第三节提到多线程下kill(pid)"任选一个没屏蔽的线程"处理。本篇把这件事拆开、拉开时序讲透:Linux 怎么区分主线程和普通线程、进程级信号到底怎么选中一个线程、信号从产生到 handler 执行完经历了多少次内核↔用户态切换、每一步在哪个特权级、以及这对编程意味着什么。如果你只记得一句话:信号的"投递时机"是线程从内核态返回用户态的那一瞬间,选哪个线程由complete_signal()遍历线程组决定,handler 在内核"伪造"的返回路径上执行。
零、先破题:Linux 怎么区分"主线程"和"普通线程"
0.1 tgid、pid、group_leader
Linux 内核不区分"进程"和"线程"——它只有一种调度实体叫 task(struct task_struct),每个 task 有自己的 pid(内核态线程 ID,用户态说的 TID)。所谓"同一个进程的多个线程",本质是一组 task 共享同一个 tgid(thread group id,即用户看到的 PID):
| 叫什么 | 内核字段 | 含义 | 示例(进程 PID=1234,有主线程+2 个工作线程) |
|---|---|---|---|
| 用户态 PID | task->tgid | 线程组 ID,全组相同 | 三个 task 的 tgid 都是 1234 |
| 内核 TID / 用户态 TID | task->pid | 每个 task 唯一 | 1234, 1235, 1236 |
| 线程组组长 | task->group_leader | 指向 tgid 等于自己 pid 的那个 task | 三个 task 的 group_leader 都指向 pid=1234 那个 |

主线程 = 线程组组长(thread group leader):用 fork() 或不带 CLONE_THREAD 的 clone() 创建出来的第一个 task,它的 pid == tgid,且 group_leader 指向自己。后续用带 CLONE_THREAD 的 clone()(pthread_create)创建的 task,tgid 和 group_leader 都指向组长,但自己的 pid 不同。
关键推论:内核确实记录了"谁是组长"(
group_leader),但信号投递时并不优待组长——kill(pid)选线程的逻辑不看你是不是主线程,只看你屏蔽没屏蔽这个信号。主线程只是在某些场景(如exit()时组长先跑路、SIGSTOP发给组长代表全组)有特殊语义,信号投递层面人人平等。
0.2 信号相关的共享与独立
回顾(详见 signals-kernel):
| 信号资源 | 全进程共享 / 每线程独立 | 对应内核结构 |
|---|---|---|
| 信号处理动作表(handler) | 全进程共享一份 | sighand_struct(CLONE_SIGHAND) |
| 发给整个进程的未决信号 | 全进程共享 | signal_struct → shared_pending |
| 发给特定线程的未决信号 | 每线程独立 | task_struct → pending(私有) |
| 信号屏蔽字(blocked mask) | 每线程独立 | task_struct → blocked |
核心记忆:handler 大家共用一套,但屏蔽字每人自己的——这恰恰是内核选线程的唯一依据:谁能处理这个信号,不看谁是主线程,看谁没有屏蔽它。
一、信号投递决策:内核怎么选中一个线程
1.1 三类信号的线程归属
| 信号类别 | 触发方式 | 走到哪个未决集 | 处理线程 |
|---|---|---|---|
| 进程级信号 | kill(pid)、Ctrl-C(SIGINT)、终端 SIGQUIT、SIGCHLD、SIGALRM、SIGTERM | 共享未决集 shared_pending | 线程组中任选一个未屏蔽该信号的线程 |
| 线程级信号 | pthread_kill(tid)、tgkill(tgid, tid, sig) | 私有未决集 private_pending | 指定线程(不给别人) |
| 同步异常信号 | SIGSEGV(非法内存)、SIGFPE(算术异常)、SIGILL(非法指令)、SIGBUS | 私有未决集 | 出异常的那个线程(只能是它) |
1.2 complete_signal() 的选线程算法
进程级信号的核心在 kernel/signal.c:complete_signal(),它要回答一个问题:信号到了共享未决集,应该叫醒/标记哪个线程去处理?
complete_signal(sig, current, type):
1. 如果 signal_struct->shared_pending 里已有同种信号在排队 → return(不重复唤醒)
2. 遍历线程组 (for each task t in thread_group):
a. 如果 t 屏蔽了该信号 (sigismember(&t->blocked, sig)) → 跳过
b. 如果 t 正在退出 (exiting) → 跳过
c. 如果 wants_signal(sig, t) 返回 true → 选中它!
- 设置 t->thread_info->flags |= TIF_SIGPENDING
- 如果 t 不是 current(当前正在跑的线程)且它在睡眠:
→ signal_wake_up(t) 唤醒它
- return
3. 如果所有线程都屏蔽了这个信号:
→ 信号留在 shared_pending,等某个线程解除屏蔽
→ 如果信号是 SIGKILL 且线程组被标记为 fatal →
signal_wake_up 唤醒任意一个线程(强行终止)
选线程的唯一标准就是"谁的屏蔽字没挡住它"——先遍历到的、没屏蔽的、能处理信号的线程先得。这不保证是主线程,也不保证是"最闲"的那个。遍历顺序取决于线程组的链表顺序(基本上是创建顺序)。
1.3 如果所有线程都屏蔽了这个信号呢?
信号卡在 shared_pending 里,直到某个线程调用 pthread_sigmask 解除屏蔽。解除屏蔽的瞬间,内核会重新走一遍 complete_signal(),让解除屏蔽的那个线程处理。
如果信号是 SIGKILL(不可屏蔽):内核会强行选中一个线程(通常是被 kill 的那个进程的主线程),在下一次返回用户态时终止整个线程组。
二、完整时序:从信号产生到 handler 执行完毕
这是本文的核心——把信号投递拆成 内核态 ↔ 用户态 的每一步。
2.1 全景时序图
前提:进程有两个线程(主线程 T1 跑在 CPU0,工作线程 T2 睡眠中)。外部 kill(pid, SIGUSR1),T2 没有屏蔽 SIGUSR1,T1 屏蔽了。

2.2 分步详解
下面把上图的每个阶段拆开,说明在哪个特权级、做了什么。
阶段①:信号产生——纯内核态
kill(pid, SIGUSR1) 是一个系统调用,发送方线程进入内核态执行 __send_signal():
- 判断目标:发给进程 → 置位
signal_struct.shared_pending(一个sigset_t位掩码,第 SIGUSR1 位置 1) - 选线程:
complete_signal()遍历thread_group链表,找到第一个没屏蔽该信号的线程 T2 - 标记 TIF_SIGPENDING:设置 T2 的
thread_info->flags中的TIF_SIGPENDING位——这是本阶段最关键的一步,意味"这个线程有信号等着处理"(但还没处理,只是贴了个标签) - 唤醒 T2(如果它在睡眠):调用
signal_wake_up()→wake_up_state(TASK_INTERRUPTIBLE)→ 把 T2 从等待队列移到就绪队列,状态变TASK_RUNNING kill()系统调用返回,发送方继续跑
注意:这时信号还没投递(deliver),只是产生了(generate)并标记了目标线程。投递要等到目标线程"下次从内核态返回用户态"。
阶段②:T2 被调度、在内核态检查信号——纯内核态
T2 可能本来就醒着(下一段解释"已经在跑的线程"),也可能像上图一样在睡眠。以睡眠场景为例:
- 调度器选中 T2:CPU 的调度器从就绪队列挑出 T2,执行
context_switch(切换细节见 context-switch),T2 的__schedule()调用返回 - 返回到用户态的必经之路:每个线程从内核态出来之前,都要经过
exit_to_user_mode_loop()(x86-64 上大约等价于arch_exit_to_user_mode_prepare→exit_to_user_mode_loop),这个函数里有一个关键检查:
// kernel/entry/common.c (简化逻辑)
static void exit_to_user_mode_loop(struct pt_regs *regs) {
while (true) {
if (ti_work & _TIF_SIGPENDING) {
arch_do_signal_or_restart(regs);
// ↑ 这里面最终调用 do_signal() → get_signal()
}
// ... 其他 pending work 检查
if (!(ti_work & EXIT_TO_USER_MODE_WORK))
break;
}
}get_signal()出队:从shared_pending(和private_pending)中取出信号(优先级:先私有后共享),清掉 pending 位,然后判断处置方式:SIG_DFL默认动作(Term/Core/Ign/Stop)→ 内核直接处理,不回用户态了SIG_IGN→ 忽略,什么也不做,继续循环- 自定义 handler → 返回信号号和
ka(struct k_sigaction),准备投递
到这里,信号才真正出队、决定要投递了。之前 pending 状态下的信号还在"排队"。
阶段③:内核伪造返回路径——内核态,但操作目标是用户栈
如果信号有自定义 handler,内核不能简单地"回原来的地方"——它要让线程的执行流拐到 handler 去。这一步叫 setup_rt_frame()(实时信号帧设置,普通信号用 setup_frame()):
setup_rt_frame(ksig, regs):
1. get_sigframe() → 在用户栈上算出 sigframe 的地址
(通常是 RSP - sizeof(sigframe),对齐到 16 字节)
2. 把当前 pt_regs 的内容(RIP、RSP、RFLAGS、各通用寄存器)
填进 sigframe 里(= 保存"被打断时的现场")
3. 修改 pt_regs(这就是内核态能做的事——直接改线程被冻住时的寄存器快照):
regs->rip = ka->sa_handler (→ 下次回用户态时跑 handler 而不是原代码)
regs->rsp = sigframe 地址 (→ 用户栈指针也变了)
regs->rdi = sig (→ handler 第一个参数 = 信号号)
4. 在用户栈上写入"restorer"蹦床代码的地址(VDSO 里的 __kernel_rt_sigreturn)
→ 放到 sigframe 的 return address 位置
→ handler 执行 ret 时,不会回到原调用点,而是跳到 restorer这一步信息量巨大:
- 内核在内核态,但可以直接读写用户栈(内核有权限访问所有内存),把 sigframe 写进去
- 内核修改了
pt_regs(存在内核栈上的寄存器快照),这决定了sysretq返回后的 RIP/RSP - handler 其实不是被"调用"的,而是被**"切换到"**的——内核偷换了 RIP,线程一出来就跑 handler
- restorer 是 handler 返回后恢复现场的关键(详见阶段⑤)
阶段④:handler 在用户态执行
sysretq 回到 ring 3,但 RIP 已经是 handler 的地址。从线程的角度看,它只是"从某个内核路径(睡眠被唤醒、或 syscall 返回)出来后跑了一段代码"——handler 里的局部变量用 T2 自己的用户栈,可以安全访问全局变量(因为同进程线程共享地址空间)。
重要限制:handler 跑在异步信号上下文中,线程原本在做什么完全不确定——可能正持有一把锁(pthread_mutex_lock 到一半就被打断了)。所以 handler 里只能调用 async-signal-safe 的函数(write、_exit、sem_post 等),绝对不能调 printf(内部有锁)、malloc(有锁)等,否则可能死锁。详见 signals 和 fork-and-threads。
阶段⑤:sigreturn 恢复现场——用户态→内核态→用户态
handler 执行完毕,执行 ret 指令:
ret从栈上弹出返回地址 → 这是 VDSO 里的__kernel_rt_sigreturn蹦床- 蹦床发起
syscall __NR_rt_sigreturn(进入内核态) - 内核
sys_rt_sigreturn()从用户栈上的 sigframe 读出之前保存的所有寄存器值 - 恢复
pt_regs:RIP 回到被打断的位置(T2 被唤醒后正要返回到的那条用户指令)、RSP 回到原来的用户栈位置 sysretq返回用户态 → T2 从"被信号打断前正要执行的位置"继续跑,仿佛什么都没发生

2.3 如果目标线程"正在跑"(TASK_RUNNING 且排在 CPU 上)
上面的时序图以 T2 睡眠为例。如果 T2 刚好正在某个 CPU 上跑用户态代码,情况略有不同:
complete_signal()发现 T2 是current(当前正在跑的线程)且没屏蔽信号- 仍然设置
TIF_SIGPENDING - 不调用
signal_wake_up()(不需要唤醒,它本来就醒着) - T2 会在下一次进入内核态再返回时(比如下一次时钟中断、或下一次 syscall 返回时)检查
TIF_SIGPENDING并处理
关键理解:信号永远不会"立刻"打断用户态代码。它只是贴了个标签(
TIF_SIGPENDING),等线程下次从内核态出来时再检查。这意味着——如果 T2 正在跑一个纯用户态的while(1)死循环且没有系统调用、没有缺页异常、也没有被调度出去,信号可能永远不被投递(当然现实中时钟中断会周期性把它拉进内核,所以最坏情况下延迟是一个时间片,通常 HZ=250 则最多 4ms)。
2.4 特权级切换次数汇总
以时序图中的完整过程为例,统计用户态↔内核态切换:
| # | 事件 | 方向 | 谁做的 |
|---|---|---|---|
| 1 | 发送方 kill() 系统调用 | 用户→内核 | 任意线程/进程 |
| 2 | kill() 返回 | 内核→用户 | 发送方 |
| 3 | T2 被调度(此前可能经历了切换进入内核) | 内核→用户(但先到 handler) | T2 |
| 4 | handler 结束后 rt_sigreturn() | 用户→内核 | T2 |
| 5 | rt_sigreturn 返回 | 内核→用户 | T2(回到原代码) |
从 T2 视角:经历了两次"进入内核→返回用户态"(调度醒来+检查信号 → handler → sigreturn → 恢复原代码),但中间那次"进入内核"实际上没有离开内核(T2 本来就因为被调度而在内核态等待),所以 T2 的额外开销主要体现在:
- 一次
setup_rt_frame(写 sigframe 到用户栈) - handler 执行本身
- 一次
sys_rt_sigreturn(额外系统调用)
三、实践:三种发送方式 + 线程选择对照
3.1 场景速查表
| 场景 | 你用哪个 API | 信号进了哪个未决集 | 哪个线程执行 handler |
|---|---|---|---|
| 外部 kill、Ctrl-C 终端信号 | kill(pid, SIGINT) | shared_pending | 任意一个未屏蔽 SIGINT 的线程(不一定是主线程!) |
| 代码里主动发 | pthread_kill(pthread_self(), SIGUSR1) | 本线程 private_pending | 本线程(精确投递) |
| 代码里发给特定线程 | pthread_kill(target_tid, SIGUSR1) | target 的 private_pending | target 线程 |
| 空指针 / 除零 / 非法指令 | 不需要发,CPU 异常自动产生 | 出异常线程的 private_pending | 出异常的线程(只能它处理) |
| alarm / setitimer 定时器 | alarm(n) | shared_pending | 任意一个未屏蔽 SIGALRM 的线程 |
| 子进程退出 | (自动) | shared_pending | 父进程里任意一个未屏蔽 SIGCHLD 的线程 |
3.2 常见编程模式
模式 A:sigwait 专职线程(推荐)
所有线程启动时屏蔽目标信号,单独一个专职线程调 sigwait() 阻塞等信号:
// 主线程设置
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
pthread_sigmask(SIG_BLOCK, &set, NULL); // 主线程屏蔽
// 启动专职信号处理线程
pthread_t sig_thread;
pthread_create(&sig_thread, NULL, signal_handler_thread, &set);
// 专职线程里
void *signal_handler_thread(void *arg) {
sigset_t *set = (sigset_t *)arg;
int sig;
while (1) {
sigwait(set, &sig); // 阻塞等到信号
// ... 安全地处理(可以调任何函数,因为不在信号 handler 里)
}
}优势:sigwait 是同步的——它在用户态正常阻塞等待,信号投递时不会被"打断",而是在内核里完成投递后让 sigwait 返回。所以处理逻辑可以调用任何函数,没有 async-signal-safe 限制。
模式 B:指定线程处理(其余线程全屏蔽)
// 所有线程启动时都屏蔽 SIGUSR1
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
pthread_sigmask(SIG_BLOCK, &set, NULL);
// 只有"处理线程"解除屏蔽
pthread_sigmask(SIG_UNBLOCK, &set, NULL);
signal(SIGUSR1, my_handler);因为只有处理线程没屏蔽 SIGUSR1,complete_signal() 遍历时就只能选中它。这是利用屏蔽字精确控制"哪个线程处理进程级信号"的标准做法。
模式 C:默认"谁接到算谁"(不推荐)
不设置任何屏蔽字,让 kill(pid) 的信号随机落到某个线程。因为 handler 共享、线程调度不确定,行为不可预测——不要依赖这种方式。
3.3 SIGSEGV/SIGFPE 等同步异常的特殊之处
同步信号由 CPU 硬件异常 直接产生:
- 线程执行
mov [0], 1→ MMU 缺页 → 内核发现地址非法 → 直接给当前线程的private_pending塞 SIGSEGV - 不经过
complete_signal()的选线程逻辑——异常发生在哪个线程,信号就给哪个线程 - 投递时机同样是"当前线程从异常处理返回用户态时",但因为异常处理本身就在内核态,所以是处理完异常即将返回时立即投递
- 一个常见陷阱:如果 SIGSEGV 的 handler 又触发了 SIGSEGV(二次异常),内核会直接终止线程
四、观测:怎么看到信号投递过程
| 手段 | 命令/文件 | 看什么 |
|---|---|---|
| 跟踪信号投递 | strace -e trace=signal -f ./prog | 看到 kill()、--- SIGUSR1 {si_signo=SIGUSR1, ...} ---、rt_sigreturn() |
| /proc 信号状态 | cat /proc/<pid>/status | grep -E 'Sig|Shd' | SigCgt(已捕获)、SigBlk(屏蔽)、SigPnd/ShdPnd(未决) |
| /proc 各线程信号状态 | cat /proc/<pid>/task/<tid>/status | grep Sig | 每个线程的屏蔽字和私有未决集 |
| 看线程被信号杀死 | dmesg | grep -i signal | 内核日志记录哪个线程被哪个信号杀死 |
| 信号发送者追踪 | strace -e kill,tgkill -p <pid> | 追查谁在往这个进程/线程发信号 |
典型排查场景:
# 多线程程序里 SIGTERM 没反应
cat /proc/<pid>/status | grep SigBlk # 先看主线程屏蔽了没
# 如果主线程屏蔽了,查每个线程:
for tid in /proc/<pid>/task/*/; do
echo "=== $(basename $tid) ==="
grep SigBlk "$tid/status"
done
# 如果所有线程都屏蔽了 → 信号卡在 ShdPnd 里,解除屏蔽才能投递五、总结

一句话总结:
Linux 通过
tgid(同组共享)和group_leader(组长指针)区分主线程和普通线程,但信号投递不优待主线程——kill(pid)的进程级信号由complete_signal()遍历线程组、选第一个未屏蔽该信号的线程,在它的thread_info里贴TIF_SIGPENDING标签;真正的投递发生在这个线程下次从内核态返回用户态的瞬间(exit_to_user_mode_loop检查标签 →get_signal出队 →setup_rt_frame把用户栈上的 sigframe 写好、pt_regs.rip改成 handler 地址 →sysretq直接跳到 handler);handler 执行完后通过 VDSO restorer 发起rt_sigreturn()系统调用,内核从 sigframe 恢复原寄存器现场,线程若无其事地继续跑。整个过程涉及两次内核↔用户态切换(对于已在跑的线程,是"下一次进内核再出来时触发"),选线程的唯一标准是"屏蔽字是否挡住了这个信号"。
延伸阅读:
- signals-kernel —— 信号内核数据结构(
sighand_struct/signal_struct) - signals —— 哪些信号导致崩溃、怎么反推 bug
- fork-and-threads —— 多线程 fork 的信号相关陷阱
- context-switch —— 上下文切换的完整机制
- syscall-details —— syscall/sysretq 模式切换的硬件细节
- interrupts —— 中断处理流程(信号投递的"检查点")