﻿# 上下文切换 —— CPU 从一个 task 换到另一个，到底切了什么

> "上下文切换(context switch)"和"CPU 切换线程/进程任务"是不是一回事？——**基本是同一件事的两种说法**，但里面藏着必须分清的层次：上下文切换是那个**机制/实现**（"怎么切"），切进程还是切线程只是这机制被触发的**场景**（"切谁"），而且这两种场景在实现上有**关键差异**（换不换地址空间）。本篇讲清：上下文切换切的"上下文"到底是什么、触发切换的四大场景和完整时序、内核怎么实现这一步（`switch_mm`/`switch_to` 代码级拆解）、切线程 vs 切进程差在哪、还有个常被忽略的第三类（用户↔内核态切换）、量化开销分析与观测手段。

> 相关：什么时候触发切换（调度时机）见 [scheduling.md](/concepts/process/scheduling.md)；被切换的 task 长什么样见 [task-struct.md](/concepts/process/task-struct.md)；换地址空间为什么贵见 [../cache/tlb.md](/concepts/cache/tlb.md)；本文涉及的硬件寄存器（CR3/CR0.TS/XMM/YMM 等）速查见 [x86-64-registers.md](/concepts/process/x86-64-registers.md)；观测见 [../../tools/cpu/pidstat.md](/tools/cpu/pidstat.md)、[../../tools/cpu/vmstat.md](/tools/cpu/vmstat.md)；与中断的关系见 [interrupts.md](/concepts/process/interrupts.md)；read/write 全链路中的切换时机见 [../io/read-write-process.md](/concepts/io/read-write-process.md)。

## 零、先破题：三个词是什么关系

用户常把这几个说法混着用,先理清：

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<m>> #E3F2FD
  BorderColor<<m>> #1976D2
  BackgroundColor<<s>> #C8E6C9
  BorderColor<<s>> #388E3C
  BackgroundColor<<o>> #FFE0B2
  BorderColor<<o>> #EF6C00
}
rectangle "上下文切换 (context switch)\n= **机制/实现**\n'把 CPU 从当前 task 的执行状态\n保存下来,换上另一个 task 的状态'" <<m>> as M
rectangle "CPU 切换进程\n= **触发场景之一**\n换的两个 task 属**不同进程**\n→ 要换地址空间(页表)" <<s>> as A
rectangle "CPU 切换线程\n= **触发场景之二**\n换的两个 task 属**同进程**\n→ 不换地址空间" <<o>> as B
M -down-> A : 这一次切换的双方\n恰好跨进程
M -down-> B : 这一次切换的双方\n是同进程的线程
note bottom of M : Linux 里进程和线程都是 task,\n"切进程/切线程"只是"这次切换的两个 task\n是否属于同一进程"的区别
@enduml
```

> **一句话破题**:**上下文切换是"怎么切"的机制,"切进程"还是"切线程"是"切的双方是谁"的场景区分——不是三个并列的东西。** 因为 Linux 里进程和线程统一都是 **task**(见 [task-struct.md](/concepts/process/task-struct.md)),内核的切换函数**根本不问"这是进程还是线程"**,它只做一件事:保存旧 task 状态、装上新 task 状态。唯一的实质差别是——**如果新旧 task 属于不同进程,要额外换一次地址空间(页表)**;同进程的线程则省掉这一步。

## 一、分类视角：上下文切换的四种类型

§三 从**触发场景**角度拆解了何时发生切换。现在换一个维度：根据 **在切什么** 来分类。Linux 体系下上下文切换可分为四种类型：

| 类型 | 切换主体 | CR3 变吗 | 有 task_struct 吗 | 开销级别 |
|------|---------|:---:|:---:|:---:|
| **进程上下文切换** | 不同进程的 task | ✅ 必换 | ✅ | 最高 |
| **线程上下文切换** | 同一进程内的不同线程 | ❌ 共享 | ✅ | 中等 |
| **中断上下文切换** | 中断处理程序接管 CPU | ❌ 借用 | ❌ 没有 | 轻（但有限制） |
| **内核抢占上下文切换** | 内核态中被更高优先级的 task 抢走 | 取决于新旧 task | ✅ | 等同进程/线程切换 |

### 4.1 进程上下文切换（粒度最大，代价最高）
进程拥有独立的虚拟地址空间、独立的页表、独立的文件描述符表、独立的内存映射。切换进程时：
1. 保存当前进程的用户态寄存器、内核栈、程序状态
2. 将当前进程放回就绪队列
3. **修改 CR3 寄存器**，载入新进程的页表根——整个虚拟地址空间发生替换
4. CR3 一换，TLB 中的地址翻译条目**大面积失效**（详见 [tlb.md](/concepts/cache/tlb.md)）
5. 载入新进程的内核上下文和通用寄存器，恢复执行
核心特征：地址空间隔离导致 CR3 必定变更，后续所有访存都需要重建 TLB。

### 4.2 线程上下文切换（开销显著低于进程）
同一进程内的线程共享虚拟地址空间和页表，CR3 不变。只需置换：
- 通用寄存器、PC、线程私有栈
- 信号掩码、TLS（线程局部存储）

不需要切换：页表、文件描述符表、内存映射、全局变量。因此 TLB 缓存绝大部分可保留，这也是"多线程比多进程轻"的底层原因。

具体对比已在 [§四](#四切线程-vs-切进程差在这一步) 中详细展开。

### 4.3 中断上下文切换（特殊的"借壳"上下文）

CPU 收到外设的中断信号，会打断当前正在运行的线程，跳转到中断处理函数。这形成了**一种特殊的上下文切换**——它没有独立可调度的实体，而是 **"借用"被打断线程的执行环境** 来运行内核中断处理代码。

与真正的进程/线程上下文切换不同，中断上下文切换有以下关键特征：

| 维度 | 进程/线程切换 | 中断上下文"切换" |
|------|:---:|------|
| 有 task_struct | ✅ | ❌ — 用 preempt_count bit 标记 |
| 换 CR3（页表） | 跨进程才换 | ❌ — 借用被打断 task 的地址空间 |
| 运行栈 | task 自己的内核栈 | IST 独立中断栈（per-CPU，16KB） |
| 能睡眠/调度 | ✅ schedule() | ❌ — 无 task 可挂起，睡了全核卡死 |
| 触发真调度 | schedule() 内直接切 | iret 返回前检查 TIF_NEED_RESCHED 才切 |

> 中断处理的完整细节见 [interrupts.md](/concepts/process/interrupts.md)：IST 栈切换的硬件流程（§3.1，含用户态/内核态两张时序图和对比表）、中断上下文的运行时限制（§3.2，含无 task_struct、不换页表、Ring 0 特权）、为什么不能睡眠（§2.4，含危险操作表、死锁场景与防御机制）、中断返回如何触发真正的进程调度（§3.3，含完整时序图）、磁盘 DMA 完成中断端到端示例（§3.4）。

### 4.4 内核抢占上下文切换

Linux 2.6 之后引入内核抢占（Kernel Preemption）。当内核态执行代码时，如果出现更高优先级的 task 需要运行，可以直接抢占当前内核路径：

- 老式非抢占内核：必须等当前内核代码执行完毕、退出内核态之后，才能调度新 task。高优先级任务的调度延迟不可控。
- 抢占内核：内核路径中途就可以被抢占，降低实时响应延迟。

从切换机制来看，内核抢占和普通线程切换完全相同——都要经历 `schedule()` → `context_switch()` → 视情况 `switch_mm` + `switch_to`。但要注意：**内核态持有自旋锁时不可抢占**（`preempt_count > 0`），避免死锁。已在 [§3.3](#33-触发场景三被高优先级任务抢占) 中有详细时序。

### 4.5 特别注意：模式切换 ≠ 上下文切换

有个高频误解要澄清：**系统调用/中断导致的"用户态→内核态"切换，不是上下文切换。**

| | 模式切换(mode switch) | 上下文切换(context switch) |
|---|---|---|
| 换的是 | 同一个 task 的**特权级**(用户态↔内核态) | **两个不同 task** |
| 换 task 吗 | **不换**——还是原来那个进程/线程 | **换**——CPU 去跑另一个 task |
| 触发 | 系统调用、中断、异常 | 调度器决定换 task |
| 换页表吗 | 不换(还是同一进程) | 跨进程才换 |
| 开销 | 较小(见 [syscall.md](/concepts/process/syscall.md)) | 较大(尤其跨进程) |

- **系统调用**([syscall.md](/concepts/process/syscall.md))：进程从用户态陷入内核态执行，**还是同一个进程**，只是特权级变了——这是**模式切换**，不是上下文切换。
- **中断**([interrupts.md](/concepts/process/interrupts.md))：CPU 被打断进内核跑 handler，通常也只是模式切换（借用被打断 task 的上下文）——**除非**中断处理完唤醒了更高优先级任务、触发 `schedule()`，那才引发一次真正的上下文切换。
- 一次上下文切换**通常包含**模式切换（因为切换在内核态做），但模式切换**不一定**带来上下文切换。别把两者等同。

## 二、"上下文"到底指什么

切换要保存/恢复的"上下文",就是**一个 task 在 CPU 上运行所需的全部易失状态**。分三块：

| 上下文 | 具体内容 | 存到哪 | 切线程也要? | 切进程也要? |
|--------|---------|--------|:---:|:---:|
| **CPU 寄存器现场** | 通用寄存器、栈指针 SP、指令指针 PC、标志位、(浮点/SIMD 状态) | task 的内核栈 / `thread_struct` | ✅ | ✅ |
| **内核栈** | 每个 task 独立的内核态栈(陷入内核时用) | task_struct 关联 | ✅ | ✅ |
| **地址空间(页表)** | `mm_struct` → 页表根,装入 **CR3** 寄存器 | mm_struct | ❌(同进程共享 mm) | ✅(不同进程,必须换) |

> **关键**:前两块(寄存器现场 + 内核栈)**任何切换都要换**,因为每个 task 有自己的执行流和内核栈。第三块(地址空间)**只有跨进程才换**——这正是"切进程比切线程贵"的根源。同进程的两个线程共享同一个 `mm_struct`(见 [task-resources/mm-struct.md](/concepts/process/task-resources/mm-struct.md)),切它们时 CR3 不变、页表不动。

## 三、内核怎么实现一次切换：schedule() → context_switch()

切换由内核的 `schedule()` 发起(何时调用见 [scheduling.md](/concepts/process/scheduling.md) §四),核心是 `context_switch()`,它干两件事:**换地址空间 + 换寄存器现场**。

```plantuml
@startuml
skinparam shadowing false
skinparam ArrowColor #37474F
start
:schedule() 决定:当前 task A → 下一个 task B\n(pick_next_task 从运行队列选出 B);
:context_switch(A, B);
if (B 和 A 属于**不同进程**?\n(B->mm != A->mm)) then (是,跨进程)
  :switch_mm():\n把 B 的页表根装入 CR3\n→ **地址空间切换**\n→ TLB 大多失效(见 tlb.md);
else (否,同进程线程 或 B 是内核线程)
  :不换 CR3\n(共享地址空间 / 借用 A 的 mm);
endif
:switch_to():\n① 保存 A 的寄存器现场到 A 的内核栈\n② 恢复 B 的寄存器现场(SP/PC/...)\n③ SP 一换,执行流就"变成了 B";
:CPU 现在跑的是 B\n(从 B 上次被切走的地方继续);
stop
@enduml
```

- **`switch_mm()`——换地址空间**:把新 task 的页表根物理地址写入 **CR3** 寄存器。CR3 一换,CPU 的地址翻译就指向新页表,`0x400000` 这种地址在新进程里翻译到不同的物理页。**跨进程才做这步**;同进程线程 `B->mm == A->mm`,跳过。
- **`switch_to()`——换寄存器现场**:这是切换最"魔法"的一步。它保存旧 task 的寄存器(尤其栈指针 SP)、加载新 task 的寄存器。**当栈指针 SP 被换成 B 的内核栈那一刻,函数返回时就"落到"了 B 的执行流里**——同一个 `switch_to` 调用,进去时是 A、出来时已经是 B(在 B 上次被切走时的那个 `switch_to` 里返回)。
- **惰性浮点/SIMD**:浮点和 SIMD 寄存器(XMM/YMM/ZMM,状态很大)常用"惰性保存"——不是每次都存,而是等新 task 真正用到浮点指令时才触发保存/恢复,省开销。

> **注意：切换全程在内核态完成**。任何一次上下文切换，都是当前 task 先因为某种原因**陷入内核**（系统调用、中断、异常、时钟 tick），在内核里跑到 `schedule()`，才可能切到别的 task。用户态代码自己不能直接切换——它得先进内核。

### 3.1 触发场景一：时间片耗尽（时钟中断→抢占）

最常见的上下文切换触发方式。内核的时钟中断（tick）每 1-10ms 触发一次，检查当前 task 是否用完了时间片：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "Task A\n(用户态运行)" as UA
participant "CPU/时钟\n硬件" as H
participant "内核\n中断处理" as K
participant "调度器\nschedule()" as S
participant "Task B\n(就绪队列)" as B
UA -> UA : Task A 正在用户态执行
H  -> K  : ⏰ 时钟中断到达\n(每 tick, 1-10ms)
note right #FFF9C4: 硬件时钟中断→CPU 自动\n切内核态、切内核栈\n保存 A 的用户态 SS/RSP/RFLAGS/CS/RIP
K  -> K  : tick 中断处理\n更新 jiffies、进程统计\n检查 A 的时间片是否用完
note right: 若 A 的时间片 > 0\n→ 直接返回用户态，不切换
K  -> S  : 时间片耗尽\n→ 调用 schedule()
note right #FFCDD2: 关键：这里 A 在内核态\n被标记为需要重新调度
S  -> S  : pick_next_task()\n从就绪队列选 B
S  -> S  : context_switch(A, B)\n  switch_mm(若跨进程)\n  switch_to(换寄存器现场)
note right #C8E6C9: 切换完成后\nCPU 在 B 的内核态执行
S  -> B  : B 从上次被切走的地方\n(内核态)恢复执行
B  -> B  : B 返回用户态\n恢复 B 的用户态寄存器
note right #C8E6C9: B 现在在用户态跑！
note over UA, B
  <b>切换路径</b>
  A 用户态 →(时钟中断)→ A 内核态 → schedule() → B 内核态 → B 用户态
  A 被标记为 TASK_RUNNING，重新入队等待下次调度
end note
@enduml
```

**时间片耗尽切换的要点：**
- A 从**用户态**被打断进入内核（硬件时钟中断自动完成模式切换）
- 在内核态判断时间片用完，调用 `schedule()`
- `schedule()` 中做真正的上下文切换（A→B）
- B 从内核态返回用户态（B 上次也是被切走时停在内核态的）
- A 的 `task_struct` 状态变为 `TASK_RUNNING`，重新入就绪队列

### 3.2 触发场景二：主动阻塞（等 IO / 等锁 / sleep）

Task 自己调用阻塞操作，主动让出 CPU：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "Task A\n(用户态)" as UA
participant "内核\nVFS/块层" as K
participant "调度器\nschedule()" as S
participant "Task B\n(就绪队列)" as B
UA -> UA : Task A 调用 read(fd, buf, len)
UA -> K  : syscall 陷入内核
K  -> K  : sys_read() → vfs_read()\n→ 检查 page cache
note right: 数据不在 cache 中\n需要从磁盘读取
K  -> K  : 发起磁盘 IO 请求\n把 A 加入等待队列\n设置 A 状态 = TASK_UNINTERRUPTIBLE
note right #FFCDD2: A 主动阻塞\n不再可运行
K  -> S  : 调用 schedule()
note right: A 把自己摘出就绪队列\n让出 CPU
S  -> S  : pick_next_task() 选 B
S  -> S  : context_switch(A, B)
S  -> B  : B 开始运行
note over UA, B #FFF9C4
  <b>关键区别</b>
  A 不是"被抢走"，是<b>主动让出</b>
  A 的状态 = TASK_UNINTERRUPTIBLE(不可中断睡眠)
  直到磁盘 IO 完成→中断唤醒 A→A 重新入就绪队列
  观测：voluntary_ctxt_switches++
end note
...若干毫秒后，磁盘 IO 完成...
K  -> UA : 磁盘中断→唤醒 A\nA 重新入就绪队列\n等待下次被调度
@enduml
```

**主动阻塞切换的要点：**

- A 自己把自己设为睡眠状态（`TASK_UNINTERRUPTIBLE` 或 `TASK_INTERRUPTIBLE`）
- 调用 `schedule()` 时 A 已不在就绪队列中
- 统计为 **voluntary（自愿）上下文切换**
- A 在 IO 完成后被中断处理程序唤醒，重新入队

### 3.3 触发场景三：被高优先级任务抢占

实时任务或刚被唤醒的高优先级任务可以抢占当前正在运行的低优先级任务：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "Task A\n(低优先级\n正在运行)" as A
participant "内核\n中断/唤醒" as K
participant "调度器\nschedule()" as S
participant "Task B\n(高优先级\n刚被唤醒)" as B
A  -> A  : A 正在用户态运行\n(nice=0, 普通进程)
K  -> K  : 某事件发生(中断/另一个 CPU\n释放了 B 等的锁)\n→ try_to_wake_up(B)
note right: B 被唤醒\nB 的优先级 > A 的优先级
K  -> K  : 检查：B 能否抢占 A？\n1. B 优先级更高？✅\n2. A 在内核态可抢占？\n   (preempt_count==0)
note right #FFCDD2: 若 A 正持有自旋锁\n(preempt_count>0)\n→ 不抢占，标记 TIF_NEED_RESCHED\n等 A 放锁时再切
K  -> A  : A 在用户态(总是可抢占)\n→ 设置 TIF_NEED_RESCHED
A  -> K  : 下一个内核入口点\n(系统调用返回/中断返回)\n检查 TIF_NEED_RESCHED → 调用 schedule()
note right: 用户态不能直接切换\n必须借下一次陷入内核的时机
K  -> S  : schedule()
S  -> S  : pick_next_task()\n选 B(优先级更高)
S  -> S  : context_switch(A, B)
S  -> B  : B 开始运行！
note over A, B #FFF9C4
  <b>抢占要点</b>
  用户态总是可抢占的(返回用户态前检查 need_resched)
  内核态只有在 preempt_count==0 时才可抢占
  统计：A 的 nonvoluntary_ctxt_switches++
end note
@enduml
```

**抢占切换的要点：**

- 不是 A 主动让出，是 A **被更高优先级的任务挤走**
- 统计为 **nonvoluntary（非自愿）上下文切换**
- 抢占发生在"A 下次进内核时"——在返回用户态前检查 `TIF_NEED_RESCHED` 标志
- 内核态持有自旋锁时不可抢占（`preempt_count > 0`），防止死锁

### 3.4 触发场景四：系统调用返回前发现需要重新调度

系统调用本身不一定导致切换，但在返回用户态前内核会检查是否需要调度：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "Task A\n(用户态)" as UA
participant "内核\nsyscall" as K
participant "调度器" as S
participant "Task B" as B
UA -> K  : write() 系统调用
K  -> K  : sys_write() 执行\n... 写数据到 socket 缓冲区 ...
note right: 在系统调用执行期间\n可能有中断发生\n唤醒了更高优先级的 B
K  -> K  : 系统调用即将返回\n检查 TIF_NEED_RESCHED
note right #FFCDD2: B 被唤醒后设置了\nA 的 need_resched 标志
K  -> S  : 标志被设置 → schedule()
S  -> S  : context_switch(A, B)
S  -> B  : B 开始运行\n(从中断唤醒后的代码继续)
... B 运行一段时间后...
B  -> S  : B 的时间片用完或阻塞
S  -> S  : context_switch(B, A)
S  -> UA : A 从 syscall 返回处继续\nwrite() 返回给用户态
note over UA, B
  <b>关键</b>
  A 的系统调用已完成(数据已写入)
  只是在"返回用户态前"被切走
  A 自己感觉不到被切过——write() 返回时结果已就绪
end note
@enduml
```

### 3.5 四大场景汇总

| 场景 | 触发方式 | 统计类型 | 典型原因 |
|------|---------|:---:|------|
| 时间片耗尽 | 时钟中断→`schedule()` | nonvoluntary | CPU 密集型任务超时 |
| 主动阻塞 | 自己调 `schedule()`（等 IO/锁/sleep） | voluntary | IO 等待、锁争用、主动 sleep |
| 被高优先级抢占 | 唤醒高优先任务→设 `TIF_NEED_RESCHED`→下次进内核时切 | nonvoluntary | 实时任务唤醒、优先级更高的任务就绪 |
| syscall 返回前发现需调度 | syscall 完成→检查 need_resched→切走 | nonvoluntary | syscall 执行期间有事件唤醒其他任务 |

## 四、切线程 vs 切进程：差在这一步

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<a>> #C8E6C9
  BorderColor<<a>> #388E3C
  BackgroundColor<<b>> #FFCDD2
  BorderColor<<b>> #C62828
}
rectangle "切**同进程的线程**\n· 换寄存器现场 ✅\n· 换内核栈 ✅\n· **不换 CR3**(共享 mm)\n· **TLB 基本保留** ✅\n→ 较便宜" <<a>> as T
rectangle "切**不同进程**\n· 换寄存器现场 ✅\n· 换内核栈 ✅\n· **换 CR3**(switch_mm)\n· **TLB 大多失效** ❌\n  (后续访存要重走 page walk)\n→ 更贵" <<b>> as P
T -[hidden]right-> P
@enduml
```

| | 切同进程线程 | 切不同进程 |
|---|---|---|
| 寄存器现场 | 换 | 换 |
| 内核栈 | 换 | 换 |
| 地址空间/CR3 | **不换** | **换(switch_mm)** |
| TLB | 基本保留(地址空间没变) | **大多失效**,后续访存重走 page walk(见 [tlb.md](/concepts/cache/tlb.md)) |
| L1/L2 缓存 | 大多仍有效(数据地址空间相同) | 多半变冷(工作集不同) |
| 相对开销 | 较小 | 较大 |

- **为什么切进程更贵**:换 CR3 会让 **TLB(地址翻译缓存)大面积失效**——新进程的虚拟地址翻译不在 TLB 里,接下来每次访存都可能触发一次 page walk(4 次访存,见 [tlb.md](/concepts/cache/tlb.md))。缓存也因工作集不同而变冷。所以"切进程"比"切同进程线程"多付一大笔隐性代价。
- **PCID 优化**:现代 x86 有 **PCID(进程上下文标识符)**,给 TLB 表项打上进程标签,换 CR3 时不必全清 TLB——同一进程的翻译还能留着,减轻切进程的 TLB 惩罚。但仍不如"不换地址空间"的线程切换便宜。
- **这也是"多线程比多进程轻"的一个底层原因**:同进程线程间切换省掉了地址空间切换和 TLB 失效。呼应 [task-struct.md](/concepts/process/task-struct.md)"共享得越多越像线程"。

## 五、开销与观测

### 5.1 量化开销：到底有多贵

上下文切换本身消耗 CPU（纯开销，不干活），而且带来 TLB/缓存变冷的间接损失。以下是各环节的量化数据：

**直接开销（必须花的 CPU 周期）：**

| 操作 | 典型开销 | 说明 |
|------|:---:|------|
| 保存/恢复通用寄存器 | ~50-100 周期 | push/pop 或 mov 指令序列 |
| 保存/恢复浮点/SIMD 寄存器 | ~200-500 周期 | XMM/YMM 寄存器组，惰性保存可推迟 |
| 换内核栈（切 SP） | ~10 周期 | 改一个寄存器，但后续访存基于新栈 |
| switch_mm（换 CR3） | ~100-200 周期 | 写 CR3 是特权指令，较慢 |
| 调度器选任务（pick_next_task） | ~50-200 周期 | 取决于调度类和队列长度 |
| **总计（同进程线程）** | **~200-500 周期 ≈ 100-250ns** | 不含 switch_mm |
| **总计（跨进程）** | **~500-1500 周期 ≈ 250-750ns** | 含 switch_mm + TLB 刷新 |

**间接开销（切换后的余震，往往是大头）：**

| 间接代价 | 量级 | 说明 |
|------|:---:|------|
| TLB miss（跨进程后） | 每次访存多 4 次内存访问 | page walk 需逐级查页表（见 [tlb.md](/concepts/cache/tlb.md)） |
| L1 cache miss | 切换后第一批访存几乎全 miss | L1 里全是旧 task 的数据（32KB，换 task 基本全废） |
| L2 cache miss | 视工作集大小，可能部分命中 | L2 较大（256KB-1MB），但不同进程的工作集通常不重叠 |
| 分支预测器冷启动 | 前几千条分支指令预测不准 | 分支预测器记录的是旧 task 的模式 |
| 跨核迁移（cpu-migration） | **额外 x2-x5** | 新核的 L1/L2 完全冷，旧核的缓存数据全浪费 |

**实际测量参考值（LMbench lat_ctx）：**

| 场景 | 典型延迟 |
|------|:---:|
| 同进程两线程切换 | ~1-2 μs |
| 不同进程切换（同核） | ~3-5 μs |
| 不同进程切换 + 跨核迁移 | ~5-15 μs |
| 开 KPTI 后（以上各项） | **+30%~+100%** |

> **关键认知**：上下文切换的"标称开销"（几百 ns ~ 几 μs）看起来不大，但高并发下每秒数万~数十万次切换，累计就是**几个核心的 CPU 时间被纯开销吃掉**。而且间接的 cache/TLB 污染让切换后的 task 执行变慢，这个隐性代价难以精确量化但往往比直接开销更大。

### 5.2 什么算"切换频繁"？

| 每核每秒切换次数 | 评估 |
|:---:|------|
| < 1,000 | 很健康 |
| 1,000 - 10,000 | 正常范围 |
| 10,000 - 50,000 | 偏高，值得关注 |
| 50,000 - 100,000 | 严重，CPU 可能在"颠簸" |
| > 100,000 | 极端，应立刻排查 |

以 3GHz CPU 为例，每次切换 3μs（跨进程），10 万次/秒 = 300ms CPU/秒 = **30% 的 CPU 时间在做切换**。

### 5.3 观测命令

```bash
vmstat 1                 # 'cs' 列 = 全系统每秒上下文切换次数(见 vmstat.md)
pidstat -w 1             # 每进程:cswch/s(自愿)、nvcswch/s(非自愿)
cat /proc/<pid>/status | grep ctxt
#   voluntary_ctxt_switches:    主动让出(等锁/IO/sleep)
#   nonvoluntary_ctxt_switches: 被抢占(时间片耗尽/被高优先级抢)
perf stat -e context-switches,cpu-migrations ./app   # 精确计数
```

- **自愿(voluntary)高**:task 频繁**主动阻塞**——等锁、等 IO、等条件变量。指向锁争用或 IO 密集。
- **非自愿(nonvoluntary)高**:task 频繁**被抢占**——可运行任务多于核数、时间片耗尽、被高优先级抢。指向 CPU 过载或调度压力(见 [scheduling.md](/concepts/process/scheduling.md))。
- **降低切换开销的手段**:绑核减少迁移([thread-affinity.md](/concepts/process/thread-affinity.md))、减少锁争用、适当增大时间片、用协程/事件驱动在用户态调度(避免陷入内核切换)。
- **cpu-migrations**:task 被换到**另一个核**上跑——比同核切换更伤(缓存全冷),见 [scheduling.md](/concepts/process/scheduling.md) §五。

### 5.4 switch_to() 的汇编级实现：SP 一换，人就变了

`switch_to()` 是上下文切换中最核心的步骤，它负责保存旧 task 的寄存器现场并加载新 task 的现场。下面是 x86-64 上的简化实现：

```asm
; arch/x86/entry/entry_64.S (简化版)
; 调用约定: switch_to(prev, next)
; prev 在 rdi, next 在 rsi
switch_to:
    ; ===== 第一步：保存 prev 的寄存器现场 =====
    pushq   %rbp
    pushq   %rbx
    pushq   %r12
    pushq   %r13
    pushq   %r14
    pushq   %r15
    ; 把当前的栈指针（RSP）保存到 prev->thread.sp
    ; prev 的 task_struct 地址在 rdi 中
    movq    %rsp, THREAD_SP(%rdi)    ; prev->thread.sp = RSP
    ; ===== 第二步：加载 next 的寄存器现场 =====
    ; 把 next 的栈指针加载到 RSP
    movq    THREAD_SP(%rsi), %rsp    ; RSP = next->thread.sp
    ; ★★★ 从这一行开始，执行流已经"变成"了 next ★★★
    ; 因为 RSP 指向了 next 的内核栈，
    ; 下面的 pop 操作读的是 next 之前保存的寄存器值
    ; 从 next 的内核栈恢复寄存器
    popq    %r15
    popq    %r14
    popq    %r13
    popq    %r12
    popq    %rbx
    popq    %rbp
    ret    ; 返回到 next 上次被切走时的调用点
```

**为什么 SP 一换就"变成"了另一个 task？**

`switch_to()` 是一个**对称的代码**——prev 和 next 执行的是**完全相同的指令序列**，但一个在"保存"、一个在"恢复"。关键在 `movq %rsp, THREAD_SP(%rdi)` 和 `movq THREAD_SP(%rsi), %rsp` 之间：

```bash
prev 的视角：
  push/push... → 把自己的寄存器压到自己的内核栈
  mov RSP → prev->thread.sp → 记住"我停在这了"
  mov next->thread.sp → RSP → 栈换了！
  接下来的 pop 恢复的是 next 的寄存器 ← 已经在 next 的执行流了
next 的视角：
  上次被切走时，RSP 停在了 popq %r15 那一行
  这次恢复后，继续 pop 出自己之前的寄存器
  ret 返回到自己上次调 switch_to 之后的代码
```

**这就是上下文切换的"魔法" **：两个 task 各自调用 `switch_to()`，各自在 `movq THREAD_SP(%rsi), %rsp` 之后"醒来"——但醒来时看到的是对方的栈和寄存器。整个过程没有魔法函数，只是栈指针的切换。

### 5.5 FPU/SIMD 寄存器：为什么要"惰性"切换

x86-64 的浮点和 SIMD 寄存器组非常庞大：

| 寄存器组 | 数量 | 单个大小 | 总大小 |
|---------|:---:|:---:|:---:|
| x87 FPU | 8 个 | 80 bits | 80 bytes |
| MMX | 8 个 | 64 bits | 64 bytes (与 x87 共用) |
| XMM (SSE) | 16 个 | 128 bits | 256 bytes |
| YMM (AVX) | 16 个 | 256 bits | 512 bytes |
| ZMM (AVX-512) | 32 个 | 512 bits | **2048 bytes** |

如果每次上下文切换都要保存/恢复全部 FPU/SIMD 寄存器，开销将非常大。Linux 使用**惰性 FPU 切换**：

```bash
惰性 FPU 切换的原理：
1. 切换时只设置 CR0.TS（Task Switched）标志，不保存/恢复 FPU 寄存器
2. 新 task 运行时如果尝试执行浮点/SIMD 指令：
   → CPU 发现 CR0.TS=1
   → 触发 #NM (Device Not Available) 异常
   → 异常处理函数中：
      a. 保存旧 task 的 FPU 状态（如果之前有人在用）
      b. 恢复新 task 的 FPU 状态（如果有保存的副本）
      c. 清除 CR0.TS
      d. 重新执行浮点指令
3. 如果新 task 根本不执行浮点指令：
   → 永远不会触发 #NM
   → 零开销！
关键统计：
  - 大部分 task 在被调度的时间片内不使用浮点/SIMD
  - 惰性切换可省掉 256-2048 bytes 的保存/恢复
  - 只有当 task 确实用到浮点时才付出代价
```

**优化版（Linux 5.x+）**：现代内核使用 XSAVES/XRSTORS 指令，配合 XFD（eXtended Feature Disable），可以做到更细粒度的惰性切换——只在实际用到的 SIMD 子集上触发保存/恢复。

### 5.6 代价的深层分析：切换的"显性成本"和"隐性成本"

很多人只关注上下文切换本身的 CPU 指令开销（显性成本），但**隐性成本往往大得多**。

#### 显性成本（切换本身的 CPU 周期）

```bash
一次跨进程上下文切换的完整时序（x86-64, ~3GHz）：
t=0ns     schedule() 开始
t=50ns    pick_next_task() 选 task（查红黑树/运行队列）
t=100ns   switch_mm() 开始 —— 准备换 CR3
t=150ns   flush TLB（若无 PCID）或写 CR3（有 PCID）
t=200ns   switch_to() 开始
t=220ns   保存 prev 的 6 个 callee-saved 寄存器
t=250ns   mov RSP → prev->thread.sp
t=260ns   mov next->thread.sp → RSP  ← ★ 从此刻起跑 next 了
t=280ns   恢复 next 的 6 个 callee-saved 寄存器
t=300ns   ret → 回到 next 的执行流
t=350ns   可能触发 FPU 惰性恢复（若 next 上次用了浮点）
t=500ns   若有 KPTI，还要切 CR3 到用户页表
          ↓
t≈800ns  切换完成，next 在用户态继续跑
```

#### 隐性成本（切换后的"余震"）

这是真正让系统变慢的部分，切换后的一段时间内：

```bash
隐性成本时间线（跨进程切换后，新 task 开始运行）：
t+0μs:    新 task 的第一条指令
          ↓ L1-I cache miss（新 task 的代码不在 L1-I 中）
          ↓ 等待从 L2 取指令：~12 cycles
t+0.5μs:  第一条访存指令（读数据）
          ↓ L1-D cache miss（L1 里全是旧 task 的数据）
          ↓ 等待从 L2 取数据：~12 cycles
t+1μs:    继续访存
          ↓ L2 cache miss（L2 也被旧 task 污染了）
          ↓ 等待从 L3 取数据：~40 cycles
t+2μs:    TLB miss（跨进程后 TLB 基本全空）
          ↓ 4 次 page walk 访存：~100+ cycles
t+3μs:    分支预测错误
          ↓ 分支预测器记录的是旧 task 的模式
          ↓ 新 task 的分支预测正确率可能从 95% 跌到 60%
          ↓ 每次分支预测错误：~15-20 cycles 的流水线冲刷
t+5~10μs:  如果换核了（cpu-migration）：
           L1/L2 完全冷启动，所有数据都要从 L3/内存拉
           可能额外花 10-50μs 才恢复到正常性能
```

#### 不同场景的代价对比

| 场景 | 显性成本 | 隐性成本（恢复期） | 总影响 |
|------|:---:|:---:|------|
| 同核、同进程线程切换 | ~1μs | ~0.5-2μs（L1 部分失效） | 较小，L1/L2 部分命中 |
| 同核、跨进程切换 | ~2μs | ~5-15μs（TLB+cache 冷启动） | 中等，TLB 和 cache 都要重建 |
| 跨核、同进程线程 | ~2μs | ~10-30μs（L1/L2 全冷） | 较大，新核缓存全空 |
| 跨核、跨进程 + KPTI | ~5μs | ~20-50μs（一切都要重建） | 最大，几乎等于冷启动 |

> **关键认知**：一次上下文切换的"直接开销"（1-5μs）看起来不大，但**隐性的 cache/TLB 恢复期**（5-50μs）才是真正的杀手。在高并发场景下，如果每秒发生 10 万次切换，光是恢复期就可能消耗 **0.5-5 秒的 CPU 时间**——远超过切换本身的开销。

### 5.7 什么时候上下文切换会成为瓶颈？

不是所有的上下文切换都值得优化。判断标准：

```bash
切换是否成为瓶颈的判断流程：
1. 用 vmstat 1 看 cs（全系统每秒切换次数）
   → cs < 10000/核  : 正常，不需要关注
   → cs 10000-50000/核 : 需要关注，继续下一步
   → cs > 50000/核  : 很可能是瓶颈
2. 用 pidstat -wt 1 看哪些进程切换最频繁
   → 定位到具体进程
3. 分析切换类型
   → voluntary_ctxt_switches 高：
      进程在频繁等锁/等 IO/主动 sleep
      → 排查锁争用、IO 模式（是否需要异步 IO？）
      → 排查是否有不必要的 sleep/usleep 调用
   → nonvoluntary_ctxt_switches 高：
      时间片被抢占——可运行任务数 >> CPU 核数
      → 减少线程/进程数量
      → 用协程在用户态调度，减少内核参与
      → 检查是否有不合理的 nice/优先级设置
4. 检查 cpu-migrations（pidstat -w 的 migrations/s 或 perf stat）
   → migrations/s 高：task 频繁换核
      → 用 taskset/numactl 绑核
      → 检查调度域和负载均衡配置
5. 用 perf 确认
   → perf stat -e context-switches,cpu-migrations,cache-misses ./app
   → perf record -e sched:sched_switch -ag -- sleep 10
     看哪些调用栈触发了最多的切换
```

**典型瓶颈案例**：

| 症状 | 根因 | 修复 | 效果 |
|------|------|------|:---:|
| cs=15万/秒, voluntary 占 90% | 每次请求都 `usleep(100)` | 改用条件变量/事件驱动 | 切换降 95% |
| cs=8万/秒, nonvoluntary 占 80% | 1000 个线程抢 8 个核 | 减少到 16 个线程 + 协程 | 切换降 80% |
| cs=2万/秒, migrations=1万/秒 | 调度器频繁跨核迁移 | 绑核 `taskset -c 0-3` | 切换降 50%，延迟更稳 |
| cs=3万/秒, 每次切换后 cache-miss 暴增 | 两个 task 工作集完全不重叠 | 将相关 task 绑到不同核 | 切换仍在但 cache 命中率回升 |

### 5.7-A 降低切换开销的优化策略

理解了上下文切换的代价来源，优化方向就明确了——**减少切换次数**或**降低每次切换的代价**：

1. **多线程替代多进程**。同进程线程切换不走 `switch_mm`，省掉了 CR3 切换和 TLB 大面积失效。高并发场景下优先用线程池而非 fork 子进程。
2. **在用户态批量化 IO，减少陷入内核频次**。每进入内核态就有机会触发调度。在用户态设置缓冲区合并 IO 操作（如 `writev`、`sendmmsg`），降低内核切入切出频率。
3. **减少细粒度锁争用**。大量线程在用户态抢一把互斥锁 → 抢不到的线程调 `futex` 陷入内核 → 被标记为睡眠 → 触发自愿上下文切换。锁竞争激烈时，上下文切换次数会暴增。解决方案：无锁数据结构、分段锁、读写锁替代互斥锁。
4. **避免 Swap**。一旦内存页面被置换到磁盘，下次访问触发缺页异常 → 陷入内核 → 等待磁盘 IO → 进程被切走。磁盘 IO 在毫秒级，比普通上下文切换贵上万倍。保证物理内存充足。
5. **用协程/用户态调度替代内核线程调度**。协程的切换在用户态完成（换栈指针 + 寄存器），完全不走内核的 `schedule()`。一次协程切换 ~几十 ns，而一次内核线程切换 ~1-5μs，差两个数量级。
6. **绑核（CPU affinity）减少跨核迁移**。用 `taskset` 或 `sched_setaffinity` 将关键 task 绑定到固定核心，避免调度器把 task 迁到另一个核上——跨核迁移意味着新核的 L1/L2 缓存全冷，缓存预热代价远超切换本身。

| 优化手段 | 原理 | 适用场景 |
|---------|------|---------|
| 线程替代进程 | 省 CR3 切换 + TLB 保留 | 高并发服务器 |
| 用户态批量 IO | 减少进入内核态频次 → 减少调度机会 | IO 密集应用 |
| 减少锁争用 | 降低因等锁而自愿让出 CPU 的频次 | 多线程竞争热点 |
| 使用协程 | 用户态切换不走内核 schedule() | 高并发网络 IO（Go/Python asyncio） |
| 绑核 | 避免跨核迁移 → 保留缓存热度 | 延迟敏感型任务 |
| 避免 Swap | 杜绝缺页异常引发的毫秒级切换 | 内存受限场景 |

### 5.7-B 实战：一份 perf stat 数据的完整解读

下面这份数据来自一次针对单进程的 `perf stat` 采样，能非常典型地说明**CPU本身跑得挺快，但切换极其频繁**的场景。

```bash
sudo perf stat -p 58931 -- sleep 3
```

```text
Performance counter stats for process id '58931':
      4,808.30 msec task-clock                 #    1.600 CPUs utilized
           225,908      context-switches       #    0.047 M/sec
               344      cpu-migrations         #    0.072 K/sec
                 2      page-faults            #    0.000 K/sec
    13,470,172,084      cycles                 #    2.801 GHz
    45,927,687,496      instructions           #    3.41  insn per cycle
     6,325,618,743      branches               # 1315.562 M/sec
         9,926,666      branch-misses          #    0.16% of all branches
       3.004342766 seconds time elapsed
```

#### 1) 先看 `CPUs utilized = 1.600` 怎么来的

`perf stat` 末尾的 `# 1.600 CPUs utilized` 不是硬件配置，而是**派生值**：

```bash
CPUs utilized = task-clock / elapsed_time
              = 4808.30 ms / 3004.34 ms
              ≈ 1.600
```

> **task-clock** 是 CPU 花在这个进程上的**总时间**——如果 2 个核各跑了 2.4 秒，task-clock 就是 4.8 秒，跟墙上时钟是两码事。

这个值的意思是：**该进程平均占用了 1.6 个 CPU 核**（可能是 2 个线程各跑满约 80%）。

**为什么后续要按 1.6 核折算？** `context-switches = 225,908` 是整个进程 3 秒内的总计，如果直接除 3 秒 ≈ 75,200 次/秒，那隐含假设是只用 1 个核。实际上它用了 1.6 个核，可调度的机会更多，需要摊薄到每个虚拟 CPU 上看"单核负担"更合理。

#### 2) 四个关键派生指标

| 指标 | 计算方式 | 数值 | 含义 |
|------|---------|------|------|
| **每秒上下文切换** | `225,908 ÷ 3.004` | **~75,200 次/秒** | 全进程级别，每秒切 7.5 万次 |
| **每活跃 CPU 每秒切换** | `75,200 ÷ 1.600` | **~47,000 次/秒·CPU** | 按 1.6 个 CPU 折算，每核负担极重 |
| **每次切换间隔的 CPU 时间** | `4,808,300 μs ÷ 225,908` | **~21.3 μs** | 两次切换之间平均只跑 21 μs CPU 时间 |
| **迁移占比** | `344 ÷ 225,908` | **0.15%** | 跨核迁移次数相对很少，不是主要矛盾 |

#### 3) 逐项解读：什么在"好"，什么在"告警"

**✅ 好消息：CPU 执行效率本身很高**

- **IPC = 3.41**：CPU 每个时钟周期平均执行 3.41 条指令。x86 现代超标量乱序 CPU 的理论峰值 IPC 一般是 4~6，3.41 非常接近峰值，说明：
  - **流水线很满**：没有大面积的指令等待（pipeline stall），后端执行单元几乎不空转
  - **缓存命中率很高**：L1/L2 频繁 miss 会导致指令等数据而阻塞，IPC 会大幅掉到 1.0 以下。3.41 意味着几乎没有明显数据瓶颈
  - 作为对比：IO 密集型或内存密集型负载的 IPC 通常在 **0.3~1.5**
- **branch-misses = 0.16%**：所有分支指令中只有 0.16% 预测错误。CPU 遇到 `if`/`else`、循环、间接跳转时必须猜测去向，猜错就要清空流水线重新走（代价约 15~20 个周期）。0.16% 是**极低**的失败率，说明业务代码的分支模式很规律（循环次数稳定、条件判断总是同一方向），分支预测器的 BTB 和历史表几乎没压力
- **page-faults = 2**：3 秒只缺页 2 次，内存访问 locality 非常好，没有 thrash 到 swap
- **cycles = 2.801 GHz**：主频正常，CPU 在跑满标称频率，没有降频

**✅ 小结**：两项核心微架构指标（IPC + 分支预测）说明**这个进程的代码在 CPU 微架构层面执行效率近乎满分**，硬件上没有瓶颈。

**🚨 坏消息：切换频率到了"严重"级别**

- **75,200 次/秒** 远高于 §5.2 中给出的"偏高"阈值（10,000–50,000/秒）。
- 每两次切换之间平均只有 **21 μs CPU 时间**——一个 task 刚刚被切进来，可能只执行了几条 hot path，马上又被切走。
- 按 §5.1 的估算：一次跨进程切换的**显性 + 隐性成本**可达 5–50 μs。75,000 次/秒 × 5–50 μs = **375 ms–3.75 秒/秒 CPU 时间**被切换和 cache/TLB 恢复期吃掉。而该进程只占用 1.6 CPU，**这意味着大量 CPU 时间花在了切换本身和切换后的预热上**，而不是真正业务计算。

#### 4) 结合 IPC 高 + 切换高，能推断出什么？

IPC 高说明"业务代码本身很高效"，但切换极高说明"业务代码被频繁打断"。这种组合通常指向两类根因：

| 根因 | 典型场景 | 为什么会产生高切换 |
|------|---------|------------------|
| **自愿切换过高** | 多线程等锁、等 IO、等条件变量 | 线程抢不到锁就 `futex` 睡 → 被切走；IO 未就绪就阻塞 |
| **非自愿切换过高** | 可运行线程数 >> 核数 | 时间片频繁耗尽，调度器不断轮转 |

> 注：perf stat 的 `context-switches` 是**自愿 + 非自愿的总和**，要进一步拆分需要 `pidstat -w 1` 或 `cat /proc/<pid>/status | grep ctxt`。

从当前数据**倾向**判断：
- 如果业务是 IO 或 FUSE 类：每次请求触发多次阻塞/唤醒，自愿切换会很高。结合之前 [FUSE 调度分析](/concepts/vfs/fuse.md) 里的结论——一个 FUSE read 至少 2 次 schedule + 2 次切换——这类数据很容易落到 7 万+/秒的切换区间。
- 如果业务是计算型：那更可能是线程数过多、时间片被抢光，应检查线程池大小和 nice 优先级。

#### 5) 下一步排查建议

```bash
# 1. 拆分自愿 vs 非自愿
cat /proc/58931/status | grep -E "voluntary_ctxt_switches|nonvoluntary_ctxt_switches"
# 2. 看是哪个线程在切，以及切到哪去
pidstat -wt 1 -p 58931
# 3. 抓取 sched:sched_switch 事件，看调用栈
perf record -e sched:sched_switch -ag -p 58931 -- sleep 5
perf report --sort comm,dso,symbol
# 4. 如果怀疑是锁争用，看 futex
perf record -e 'syscalls:sys_enter_futex' -ag -p 58931 -- sleep 5
```

#### 6) 小结

这份 `perf stat` 数据呈现的是**"高效但切换过频"**的典型画像：CPU 微架构层面（IPC、分支、缺页）几乎找不到问题，但**宏观上 CPU 时间被上下文切换和 cache/TLB 恢复大量消耗**。优化方向不是让单条指令更快，而是**减少切换次数或降低单次切换代价**：合并 IO、减少线程数、降低锁粒度、绑核避免迁移。由于本数据中 cpu-migrations 已经很低（0.15%），迁移不是主要问题，**重点应放在减少切换触发点上**。

#### 7) 深入定位：pidstat -w 按线程拆分

`perf stat -p <PID>` 只能看到进程级别的切换总量（75,200 次/秒），但它无法回答：**是哪个线程在切换？是自愿让出还是被抢占？**

`pidstat -w` 可以直接输出每个线程的每秒自愿/非自愿切换速率：

```bash
pidstat -w 1 -p 58931
```

输出示例（`uv_runtime_daem` 进程，总计 ~75,000 次/秒切换）：

| TID | cswch/s | nvcswch/s | 占比 |
|-----|---------|-----------|------|
| 58949 | 17,603 | 0 | 23.5% |
| 58988 | 17,633 | 0 | 23.5% |
| 58989 | 17,605 | 0 | 23.5% |
| 59001 | 14,841 | 0 | 19.8% |
| 其余线程 | ~7,400 | ~0 | ~10% |

**关键发现：**

1. **切换集中在极少数线程**：4 个线程贡献了 ~67,700 次/秒，占总量 90% 以上。不是全进程的问题，而是一小撮线程在疯狂切换。其余线程"岁月静好"。
2. **几乎全是自愿切换（`cswch/s`），非自愿（`nvcswch/s`）为 0**：说明这些线程不是被 kernel 抢占，不是 CPU 不够用导致的轮转——而是它们自己**主动 `sleep`/阻塞然后被唤醒**。
3. **从 `/proc/<PID>/status` 看不到真相**：`/proc/<PID>/status` 的 `voluntary_ctxt_switches` 只显示主线程（thread group leader）的累计值，而切换大户可能是工作线程。要精确看线程级切换，只能用 `pidstat -w` 或遍历 `/proc/<PID>/task/*/status`。

> **核心结论**：不要全局优化，而是**盯着这 4 个 TID**。非自愿切换为 0 说明不要调优先级/nice/绑核，根因在这些线程的**同步原语或事件循环模式**。

#### 8) 深入定位：perf record/report 抓切换根因

知道是 4 个线程在疯狂自愿切换后，下一步是看**它们到底在什么地方切**。用 `perf record` 采样这 4 个线程的调用栈：

```bash
# 对 4 个高切换线程单独采样 cycles 事件
perf record -g -t 58949 -t 58988 -t 58989 -t 59001 -F 999 -- sleep 10
perf report
```

`perf report` 输出示例（`cycles` 事件，按 Children 占比排序）：

| 排名 | 函数 | Children | Self | 解读 |
|------|------|----------|------|------|
| 1 | `[unknown]` | 69.22% | 0.00% | 主程序入口，符号未解析 |
| 2 | `system_call_fastpath` | 65.83% | 0.00% | **大量系统调用入口** |
| 3 | `sys_nanosleep` | 43.90% | 0.79% | 在调用 `nanosleep` |
| 4 | `hrtimer_nanosleep` | 41.53% | 0.40% | 高精度定时器睡眠 |
| 5 | `do_nanosleep` | 40.59% | 1.44% | 实际执行睡眠 |
| 6 | `schedule` | 30.58% | 0.12% | 进入调度器 |
| 7 | `_schedule` | 27.00% | 1.10% | 真正调度切换 |
| 8 | `uv_runtime_sw::MultiHostNetworkIOMgr::task` | 30.58% | 1.27% | 用户态主入口 |

**perf report 四列含义速查：**

| 列 | 含义 |
|-----|------|
| **Children** | 该函数在调用栈中出现的样本比例，包含自身 + 所有被它调用的子函数。例如 `sys_nanosleep` 的 Children 包含它内部调用的 `hrtimer_nanosleep`、`do_nanosleep`、`schedule` 等 |
| **Self** | CPU 执行到该函数第一条指令的样本比例。Self 高 = 这个函数本身在烧 CPU；Children 高但 Self 低 = 它在调用别人 |
| **Command** | 进程名 |
| **Shared Object** | 函数来源文件：`[kernel.kallsyms]` 内核、`libpthread.so.0` pthread 库、主程序自身 |
| **Symbol** | 函数名，`[k]` 内核态、`[.]` 用户态、`[unknown]` 符号未解析 |

**关键发现：**

- **~65% 的 CPU 时间花在系统调用入口（`system_call_fastpath`）**，其中 43.9% 走了 `sys_nanosleep` → `do_nanosleep` → `schedule()` 这条睡眠/调度路径
- Self 值都很低（<3%），说明不是在烧 CPU 计算，而是在反复"进入睡眠 → 被唤醒 → 再进入睡眠"
- 用户态入口 `MultiHostNetworkIOMgr::task` 占 30.58% Children，确认这是主事件循环/网络 IO 管理线程
- 这条调用链就是"自愿上下文切换"的精确来源：**线程循环调用短超时 sleep，每次都走完整的内核路径**

**符号解析问题：** `[unknown]` 占 69.22% 说明二进制可能缺少调试符号。建议编译时加 `-g -O0 -fno-omit-frame-pointer`，否则调用链顶部缺失，看不清是谁发起的 `nanosleep`。

#### 9) 综合结论：三条数据相互印证

把 `perf stat`、`pidstat -w`、`perf report` 三条分析串联起来，形成完整证据链：

```bash
perf stat (宏观)        pidstat -w (线程级)        perf report (调用栈)
─────────────────       ──────────────────         ─────────────────
75,000 次/秒切换    →   4 个线程贡献 90%       →   nanosleep → schedule
IPC=3.41 (高效)         全部自愿切换(nvcswch=0)     占 43.9% CPU 样本
1.6 核占用              各 14k~17k 次/秒            MultiHostNetworkIOMgr::task
```

**最终诊断**：这不是一个"CPU 不够快"或"线程太多被抢占"的问题，而是 **4 个 event-loop 线程在以高频轮询/短超时方式工作**，它们在循环中反复：

1. 做一点事（或发现没事件）
2. 调用 `nanosleep`/`usleep` 等短超时（如 1ms）
3. → 进入内核 → `schedule()` → 自愿让出 CPU
4. 超时后醒来 → 回到步骤 1

**优化方向**：
- 把短超时轮询改成**事件驱动阻塞等待**（如 `epoll_wait` 永久阻塞直到事件来）
- 或者把超时间隔放大（1ms → 100ms），减少不必要的 wake/sleep 频率
- 如果 IO 本身就频繁到需要 1ms 响应，考虑合并多个线程为一个，减少竞争

#### 10) 代码层根因：std::this_thread::sleep_for(10μs)

最终在源码中找到这 4 个线程的热循环：

```cpp
while (running) {
    // ... 做一些事（检查队列、处理 IO）...
    std::this_thread::sleep_for(std::chrono::microseconds(10));
}
```

这行代码就是 `perf report` 里 43.9% `sys_nanosleep` 的精确来源。

**为什么 10μs 的 sleep 会产生 14k~17k 次/秒切换：**

| 参数 | 数值 | 说明 |
|------|------|------|
| 每次循环睡眠时间 | 10 μs | `sleep_for` 参数 |
| 实际每轮耗时 | ~20~60 μs | 加上 work + 内核调度开销 |
| 每秒循环次数 | ~16,000 ~ 50,000 | 1 秒 ÷ 每轮时间 |
| 实测切换速率 | 14k~17k 次/秒 | 与估算值非常吻合 |

**为什么这是典型反模式：**

1. **10 μs 远小于 Linux 调度时基**（`HZ` 通常 250/1000，即 tick 间隔 1~4 ms）。`sleep_for(10us)` 底层走 `nanosleep` → `hrtimer` → `schedule()`，每次都要完整陷入内核再切回来
2. **这不是真正的"休息"，而是用系统调用做忙等**：每个 sleep 包含一次自愿切出 + 一次被唤醒，每轮约产生 2 次上下文切换
3. 4 个线程各自这么干，加起来 = **75,000 次/秒无谓的系统调用**

**修复建议：**

```cpp
// ❌ 差：高频短睡眠轮询
while (running) {
    // do work ...
    std::this_thread::sleep_for(std::chrono::microseconds(10));  // 75,000 次/秒切换
}
// ✅ 好：事件驱动
std::unique_lock<std::mutex> lock(mtx);
while (running) {
    cv.wait_for(lock, std::chrono::milliseconds(100),
                [] { return !task_queue.empty() || !running; });
    // process tasks ...
}
// ✅ 折中：放大超时间隔（如果无法改为事件驱动）
std::this_thread::sleep_for(std::chrono::milliseconds(10));  // 10ms → ~100 次/秒切换
```

> 从 10μs → 10ms，切换从 75,000 次/秒降到 ~100 次/秒以下，且应用层的响应延迟从"微秒级"变成"毫秒级"——如果业务本身不需要微秒级响应，10ms 完全可接受。

### 5.8 跨架构对比：x86 vs ARM 的上下文切换差异

| 维度 | x86-64 (Intel/AMD) | ARM64 (AArch64) |
|------|------|------|
| **切换函数** | `switch_to()` 宏/内联汇编 | `cpu_switch_to()` 汇编函数 |
| **地址空间切换** | `mov cr3, ...` 单条指令 | `msr ttbr0_el1, ...` 写系统寄存器 |
| **TLB 失效策略** | PCID 打标签，可不全清 | ASID 打标签，可不全清 |
| **寄存器保存数量** | ~15 个（callee-saved） | ~20 个（callee-saved，更多通用寄存器） |
| **FPU/SIMD 惰性切换** | CR0.TS + #NM 异常 | CPACR_EL1 + 陷阱 |
| **典型切换开销** | ~1-2μs（同进程） | ~1-3μs（同进程） |
| **KPTI 等价物** | KPTI (Page Table Isolation) | 无（ARM 不受 Meltdown 影响） |
| **特殊优化** | XSAVES/XRSTORS 惰性 FPU | FPSIMD 状态惰性保存 |

> ARM64 没有 KPTI 的额外开销（不受 Meltdown 影响），但寄存器更多（31 个通用寄存器 vs x86 的 16 个），保存/恢复的绝对数量更大。总体上两者开销在同一量级。

## 六、和本仓库其他文档的关系

- **何时切换**：[scheduling.md](/concepts/process/scheduling.md)（调度时机、`schedule()`、抢占）——本篇讲"怎么切"，那篇讲"何时切、切给谁"。
- **被切的实体**：[task-struct.md](/concepts/process/task-struct.md)（task 的寄存器现场/内核栈）、[task-resources/mm-struct.md](/concepts/process/task-resources/mm-struct.md)（地址空间为何线程共享、进程独立）。
- **切进程为何贵**：[../cache/tlb.md](/concepts/cache/tlb.md)（换 CR3→TLB 失效→page walk）。
- **区别于模式切换**：[syscall.md](/concepts/process/syscall.md)（用户↔内核态陷入）、[interrupts.md](/concepts/process/interrupts.md)（中断完整流程：IDT/do_IRQ/上半部/下半部/IST 栈切换/不能睡眠/中断返回调度）。
- **IO 全链路中的切换**：[../io/read-write-process.md](/concepts/io/read-write-process.md)（read/write 全过程中何时触发上下文切换）。
- **中断上下文切换**：[interrupts.md](/concepts/process/interrupts.md) §2.4~§3.4（中断上下文的所有细节）；本文 §4.3 给出关键特征总览和引用。
- **观测**：[../../tools/cpu/pidstat.md](/tools/cpu/pidstat.md)（`-w` 自愿/非自愿）、[../../tools/cpu/vmstat.md](/tools/cpu/vmstat.md)（`cs` 列）。

## 七、一句话总结

> **上下文切换是"把 CPU 从一个 task 的执行状态换成另一个 task"的机制。按"切什么"分为四类——进程切换（换 CR3，TLB 大面积失效，最贵）、线程切换（同进程不换 CR3，较便宜）、中断上下文切换（无 task_struct，借壳执行，不换 CR3 不调度，但不可睡眠有严格限制）、内核抢占切换（内核态中途被抢）；按"怎么触发"也分四种场景——时间片耗尽、主动阻塞、被高优先级抢占、syscall 返回前发现需调度。Linux 里进程线程都是 task，切换函数不区分，唯一实质差别是跨进程要多做一步 `switch_mm`（换 CR3→地址空间切换→TLB 大面积失效），同进程线程共享 mm 省掉这步、便宜得多。切换全程在内核态由 `context_switch()` 完成：`switch_mm` 换地址空间（仅跨进程）+ `switch_to` 保存旧寄存器现场/加载新的（SP 一换执行流就变成新 task）。注意它不同于"用户态↔内核态"的模式切换（系统调用/中断，还是同一个 task）——上下文切换换的是 task。切换是纯开销（直接 200-1500 周期 + 间接 cache/TLB 污染），自愿多 = 阻塞在锁/IO，非自愿多 = 被抢占/CPU 过载，用 pidstat -w、vmstat cs 观测。**

## 八、实战示例：write() 全链路中的上下文切换

以大量进程同步写磁盘为例，将本文所有概念串联到一个真实场景中：

```bash
场景：100 个进程各自循环调用 write() 写本地文件
前提：page cache 脏页水位打满 dirty_ratio（默认 20% 内存）
```

**一条 write() 调用链中触发上下文切换的全过程：**

1. **进程 A 在用户态调用 `write(fd, buf, len)`** → syscall 陷入内核。这是**模式切换**（用户→内核），不是上下文切换。
2. **内核 `sys_write()` → `ext4_file_write_iter()` → `generic_perform_write()`** 执行过程中，发现脏页比例超过 `dirty_ratio` 阈值，内核决定**阻塞写进程**：将进程 A 的状态设为 `TASK_UNINTERRUPTIBLE`，从就绪队列摘除，加入等待队列。
3. **进程 A 在内核中主动调用 `schedule()`** → 触发一次**自愿上下文切换**（进程上下文切换）。A 让出 CPU，调度器选进程 B 运行。A 的 `voluntary_ctxt_switches` 计数 +1。
4. **FD 内核线程（flusher 线程 `wb_workfn`）被唤醒**，开始将脏页刷入磁盘。数据写入磁盘后，DMA 完成触发**硬件中断** → CPU 进入**中断上下文**，更新 page cache 元数据，调用 `try_to_wake_up(A)` 唤醒进程 A。
5. **中断返回前检查 `TIF_NEED_RESCHED`**。如果进程 A 优先级够高，立即抢占当前 task → 触发一次真正的上下文切换，CPU 切回进程 A。
6. **进程 A 重新运行**，从 `schedule()` 调用之后继续执行——`write()` 完成剩余工作，返回用户态。A 自己感觉不到被切走过：`write()` 只是比平时多花了几毫秒。

整个过程涉及：**模式切换 × 2**（write syscall 进入和退出）、**进程上下文切换 × 2**（自愿阻塞 + 被唤醒后调度回来）、**中断上下文 × 1**（磁盘 DMA 完成中断处理）。

> 这也是高并发磁盘写压力场景下 `vmstat` 的 `cs`（全系统上下文切换次数）指标持续走高的底层原理。

