﻿# 多线程程序的信号投递 —— 内核态/用户态切换与"哪个线程执行 handler"的完整时序

> [signals-kernel.md](/concepts/process/task-resources/signals-kernel.md) 讲了信号的内核数据结构（`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 那个 |

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<main>> #C8E6C9
  BorderColor<<main>> #388E3C
  BackgroundColor<<child>> #FFE0B2
  BorderColor<<child>> #EF6C00
}
rectangle "线程组 tgid=1234" {
  rectangle "主线程（线程组组长）\npid=1234  tgid=1234\ngroup_leader → 自己" <<main>> as MAIN
  rectangle "工作线程 1\npid=1235  tgid=1234\ngroup_leader → 主线程" <<child>> as T1
  rectangle "工作线程 2\npid=1236  tgid=1234\ngroup_leader → 主线程" <<child>> as T2
}
note bottom of MAIN : pid == tgid → 这就是"主线程"\n创建时没带 CLONE_THREAD
note bottom of T1 : pid != tgid → "子线程"\n创建时带了 CLONE_THREAD
@enduml
```

**主线程 = 线程组组长（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.md](/concepts/process/task-resources/signals-kernel.md)）：

| 信号资源 | 全进程共享 / 每线程独立 | 对应内核结构 |
|---------|----------------------|------------|
| 信号处理动作表（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()`，它要回答一个问题：**信号到了共享未决集，应该叫醒/标记哪个线程去处理？**

```bash
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 唤醒任意一个线程（强行终止）
```

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #37474F
}
participant "kill(pid) 调用者" as CALLER
participant "内核 __send_signal()" as KSIG
participant "线程组\n3 个 task" as GROUP
CALLER -> KSIG : kill(pid, SIGUSR1)
KSIG -> KSIG : 置位 shared_pending[SIGUSR1]
KSIG -> KSIG : complete_signal():\n遍历线程组
KSIG -> GROUP : 检查线程 A: blocked? → 没屏蔽 → 选中!\n置 TIF_SIGPENDING
note over GROUP : 线程 A 的 private_pending 也有 SIGUSR1\n（内核从 shared 拷一份到私有待处理）
note over GROUP : 线程 B: blocked? → 屏蔽了 → 跳过
note over GROUP : 线程 C: blocked? → 没屏蔽 → 但 A 已被选中,\n不再重复唤醒
note over KSIG : 如果 A 在睡眠 → signal_wake_up(A) 唤醒
@enduml
```

**选线程的唯一标准就是"谁的屏蔽字没挡住它"**——先遍历到的、没屏蔽的、能处理信号的线程先得。这不保证是主线程，也不保证是"最闲"的那个。**遍历顺序取决于线程组的链表顺序（基本上是创建顺序）**。

### 1.3 如果所有线程都屏蔽了这个信号呢？

信号卡在 `shared_pending` 里，直到某个线程调用 `pthread_sigmask` 解除屏蔽。解除屏蔽的瞬间，内核会重新走一遍 `complete_signal()`，让解除屏蔽的那个线程处理。

如果信号是 SIGKILL（不可屏蔽）：内核会强行选中一个线程（通常是被 `kill` 的那个进程的主线程），在下一次返回用户态时终止整个线程组。

## 二、完整时序：从信号产生到 handler 执行完毕

这是本文的核心——把信号投递拆成 **内核态 ↔ 用户态** 的每一步。

### 2.1 全景时序图

前提：进程有两个线程（主线程 T1 跑在 CPU0，工作线程 T2 睡眠中）。外部 `kill(pid, SIGUSR1)`，T2 没有屏蔽 SIGUSR1，T1 屏蔽了。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #37474F
}
participant "CPU0\n主线程 T1\n(用户态跑着)" as U_T1
participant "CPU0\n内核态" as K_CPU0
participant "CPU1\n内核态" as K_CPU1
participant "CPU1\n工作线程 T2\n(睡眠中 → 被唤醒)" as U_T2
participant "发送方\nkill(pid, SIGUSR1)" as SENDER
== 阶段①：信号产生（内核态） ==
SENDER -> K_CPU1 : syscall kill(pid, SIGUSR1)（发送方在 CPU1 上）
K_CPU1 -> K_CPU1 : __send_signal():\n置位 shared_pending 的 SIGUSR1 位
K_CPU1 -> K_CPU1 : complete_signal() 遍历线程组:\nT1: 屏蔽了 SIGUSR1 → 跳过\nT2: 没屏蔽 → 选中 T2！
K_CPU1 -> K_CPU1 : T2->thread_info->flags |= TIF_SIGPENDING
K_CPU1 -> K_CPU1 : T2 在睡眠 → signal_wake_up(T2)\n把 T2 设为 TASK_RUNNING, 扔进就绪队列
K_CPU1 --> SENDER : kill() 返回（发送方完事）
== 阶段②：T2 被调度、进内核（调度切换，仍在内核态） ==
note over K_CPU1 : CPU1 调度器选中 T2
K_CPU1 -> K_CPU1 : context_switch → T2\nT2 在内核态醒来（__schedule() 返回后）
K_CPU1 -> K_CPU1 : T2 走"返回到用户态"的路径\n在 exit_to_user_mode_loop() 中：
K_CPU1 -> K_CPU1 : 检查 TIF_SIGPENDING → 置位！\n调用 do_signal() → get_signal()
K_CPU1 -> K_CPU1 : get_signal() 从 shared_pending\n取出 SIGUSR1, 清掉 pending 位
K_CPU1 -> K_CPU1 : handler 不是 SIG_DFL/SIG_IGN\n→ 准备在用户态执行 handler
== 阶段③：内核"伪造"返回路径，跳到 handler（内核态 → 用户态） ==
K_CPU1 -> K_CPU1 : setup_rt_frame():\n① 在 T2 的用户栈上压入 sigframe\n   (保存 T2 当前所有寄存器、原 RIP/RSP)
K_CPU1 -> K_CPU1 : ② 修改 pt_regs:\n   regs->rip = handler 地址\n   regs->rsp = 新的用户栈位置\n   regs->rdi = 信号号（handler 的第一参数）
K_CPU1 -> K_CPU1 : ③ 在用户栈上放好 sigreturn 的蹦床\n   (restorer = VDSO __kernel_rt_sigreturn)
K_CPU1 -> U_T2 : ④ sysretq 返回用户态\n→ 但 RIP 已不是原来的代码\n→ 而是信号 handler！
== 阶段④：handler 执行（用户态） ==
U_T2 -> U_T2 : void handler(int sig) {\n    printf("got signal %d\n", sig);\n}
note over U_T2 : handler 跑在 T2 的用户栈上\n栈顶往下是 sigframe（保存的上下文）
== 阶段⑤：handler 返回 → sigreturn → 恢复现场（用户态 → 内核态 → 用户态） ==
U_T2 -> K_CPU1 : handler 执行 ret → 跳转到 restorer\nrestorer 发起 syscall rt_sigreturn()
K_CPU1 -> K_CPU1 : sys_rt_sigreturn():\n从用户栈上的 sigframe 恢复\n所有寄存器的原值（RIP、RSP、RFLAGS…）
K_CPU1 -> K_CPU1 : regs->rip = 原来的 RIP（handler 执行前 T2 正要跑的那条指令）
K_CPU1 -> U_T2 : sysretq 返回用户态\n→ RIP 恢复 → T2 继续跑原来被打断的代码！
@enduml
```

### 2.2 分步详解

下面把上图的每个阶段拆开，说明在哪个特权级、做了什么。

#### 阶段①：信号产生——纯内核态

`kill(pid, SIGUSR1)` 是一个系统调用，发送方线程进入内核态执行 `__send_signal()`：

1. **判断目标**：发给进程 → 置位 `signal_struct.shared_pending`（一个 `sigset_t` 位掩码，第 SIGUSR1 位置 1）
2. **选线程**：`complete_signal()` 遍历 `thread_group` 链表，找到第一个没屏蔽该信号的线程 T2
3. **标记 TIF_SIGPENDING**：设置 T2 的 `thread_info->flags` 中的 `TIF_SIGPENDING` 位——**这是本阶段最关键的一步**，意味"这个线程有信号等着处理"（但还没处理，只是贴了个标签）
4. **唤醒 T2**（如果它在睡眠）：调用 `signal_wake_up()` → `wake_up_state(TASK_INTERRUPTIBLE)` → 把 T2 从等待队列移到就绪队列，状态变 `TASK_RUNNING`
5. `kill()` 系统调用返回，发送方继续跑

> **注意**：这时信号**还没投递**（deliver），只是产生了（generate）并标记了目标线程。投递要等到目标线程"下次从内核态返回用户态"。

#### 阶段②：T2 被调度、在内核态检查信号——纯内核态

T2 可能本来就醒着（下一段解释"已经在跑的线程"），也可能像上图一样在睡眠。以睡眠场景为例：

1. **调度器选中 T2**：CPU 的调度器从就绪队列挑出 T2，执行 `context_switch`（切换细节见 [context-switch.md](/concepts/process/context-switch.md)），T2 的 `__schedule()` 调用返回
2. **返回到用户态的必经之路**：每个线程从内核态出来之前，都要经过 `exit_to_user_mode_loop()`（x86-64 上大约等价于 `arch_exit_to_user_mode_prepare` → `exit_to_user_mode_loop`），这个函数里有一个关键检查：

```c
// 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;
    }
}
```

3. **`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()`）：

```bash
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`（有锁）等，否则可能死锁。详见 [../crash/signals.md](/crash/signals.md) 和 [fork-and-threads.md](/concepts/process/fork-and-threads.md)。

#### 阶段⑤：sigreturn 恢复现场——用户态→内核态→用户态

handler 执行完毕，执行 `ret` 指令：

1. `ret` 从栈上弹出返回地址 → 这是 VDSO 里的 `__kernel_rt_sigreturn` 蹦床
2. 蹦床发起 `syscall __NR_rt_sigreturn`（进入内核态）
3. 内核 `sys_rt_sigreturn()` 从用户栈上的 **sigframe** 读出之前保存的所有寄存器值
4. 恢复 `pt_regs`：RIP 回到被打断的位置（T2 被唤醒后正要返回到的那条用户指令）、RSP 回到原来的用户栈位置
5. `sysretq` 返回用户态 → T2 从"被信号打断前正要执行的位置"继续跑，仿佛什么都没发生

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<u>> #E3F2FD
  BorderColor<<u>> #1976D2
  BackgroundColor<<k>> #FFCDD2
  BorderColor<<k>> #C62828
}
rectangle "用户栈（T2）" <<u>> as USTACK {
  rectangle "↓ 低地址\nsigframe:\n · 保存的 RIP/RSP/RFLAGS\n · 保存的通用寄存器\n · restorer 地址\n\nhandler 栈帧:\n · handler 的局部变量\n\n↑ 高地址\n原用户栈内容" as SF
}
rectangle "内核" <<k>> as KERNEL {
  rectangle "T2 的内核栈\n· pt_regs (RIP=handler, RSP=新栈顶)\n· 返回路径信息" as KSTACK
  rectangle "T2 的 task_struct\n· TIF_SIGPENDING(处理前被清掉)\n· blocked 屏蔽字" as TASK
}
note right of SF
  ① setup_rt_frame: 内核写 sigframe 到用户栈
  ② 修改 pt_regs 中的 RIP/RSP
  ③ handler 跑完 → ret → restorer → rt_sigreturn()
  ④ sigreturn: 从 sigframe 恢复 pt_regs
  ⑤ sysretq → 回到原 RIP, 栈也恢复
@end note
@enduml
```

### 2.3 如果目标线程"正在跑"（TASK_RUNNING 且排在 CPU 上）

上面的时序图以 T2 睡眠为例。如果 T2 刚好**正在某个 CPU 上跑用户态代码**，情况略有不同：

1. `complete_signal()` 发现 T2 是 `current`（当前正在跑的线程）且没屏蔽信号
2. 仍然设置 `TIF_SIGPENDING`
3. **不调用 `signal_wake_up()`**（不需要唤醒，它本来就醒着）
4. 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()` 阻塞等信号：

```c
// 主线程设置
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：指定线程处理（其余线程全屏蔽）

```c
// 所有线程启动时都屏蔽 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>` | 追查谁在往这个进程/线程发信号 |

**典型排查场景**：

```bash
# 多线程程序里 SIGTERM 没反应
cat /proc/<pid>/status | grep SigBlk    # 先看主线程屏蔽了没
# 如果主线程屏蔽了，查每个线程：
for tid in /proc/<pid>/task/*/; do
    echo "=== $(basename $tid) ==="
    grep SigBlk "$tid/status"
done
# 如果所有线程都屏蔽了 → 信号卡在 ShdPnd 里，解除屏蔽才能投递
```

## 五、总结

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor #E3F2FD
  BorderColor     #1976D2
}
rectangle "多线程信号投递核心逻辑" {
  rectangle "产生\nkill() → shared_pending\npthread_kill() → private_pending\n异常 → private_pending" as GEN #C8E6C9
  rectangle "选线程\ncomplete_signal() 遍历线程组\n找第一个未屏蔽该信号的线程\n→ 置 TIF_SIGPENDING\n→ 若睡眠则唤醒" as SEL #FFE0B2
  rectangle "投递\n线程从内核态返回用户态时\n检查 TIF_SIGPENDING → get_signal() 出队\n→ setup_rt_frame 伪造 RIP=handler\n→ sysretq 跳到 handler" as DEL #E1BEE7
  rectangle "返回\nhandler ret → VDSO restorer\n→ rt_sigreturn() syscall\n→ 从 sigframe 恢复原 RIP/RSP\n→ 继续原代码" as RET #B2EBF2
}
GEN --> SEL
SEL --> DEL
DEL --> RET
@enduml
```

**一句话总结**：

> **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.md](/concepts/process/task-resources/signals-kernel.md) —— 信号内核数据结构（`sighand_struct` / `signal_struct`）
- [../../crash/signals.md](/crash/signals.md) —— 哪些信号导致崩溃、怎么反推 bug
- [fork-and-threads.md](/concepts/process/fork-and-threads.md) —— 多线程 fork 的信号相关陷阱
- [context-switch.md](/concepts/process/context-switch.md) —— 上下文切换的完整机制
- [syscall-details.md](/concepts/process/syscall-details.md) —— syscall/sysretq 模式切换的硬件细节
- [interrupts.md](/concepts/process/interrupts.md) —— 中断处理流程（信号投递的"检查点"）

