﻿# read + write 整体过程 —— 从用户态调用到数据落盘/发网的完整链路

> 一个最简单的 `read(fd, buf, 4096)` + `write(fd, buf, 4096)`，背后到底发生了什么？系统调用怎么陷入内核、内核 VFS/页缓存/块层/驱动怎么逐层处理、数据怎么在内存和外设之间流动、什么时候会触发上下文切换、用户态和内核态的栈和特权级怎么切换——本篇把这一整条链路从头拆到尾，用完整的时序图和逐层拆解讲清楚。

## 一、全景概览：read+write 的 10 层调用链

```plantuml
@startuml
left to right direction
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<u>> #E3F2FD
  BorderColor<<u>> #1976D2
  BackgroundColor<<k>> #FFF9C4
  BorderColor<<k>> #F9A825
  BackgroundColor<<h>> #FFCDD2
  BorderColor<<h>> #C62828
}
rectangle "用户态\n─────────────────\n① 应用代码: read(fd, buf, 4096)\n② glibc 包装 → syscall 指令" <<u>> as U1
rectangle "系统调用层\n─────────────────\n③ entry_SYSCALL_64: 陷入内核\n④ sys_read() → ksys_read()" <<k>> as K1
rectangle "VFS 虚拟文件系统\n─────────────────\n⑤ vfs_read() → 文件类型分发\n⑥ 普通文件: generic_file_read_iter()\n   检查 page cache" <<k>> as K2
rectangle "页缓存层\n─────────────────\n⑦ 若命中: 直接从 page cache 拷到用户 buf\n   若未命中: 触发磁盘读取" <<k>> as K3
rectangle "块层 + 驱动\n─────────────────\n⑧ submit_bio() → 块层调度\n⑨ NVMe/SATA 驱动 → 磁盘 DMA" <<h>> as H1
rectangle "返回用户态\n─────────────────\n⑩ sysret → 用户态\n   应用拿到返回值" <<u>> as U2
U1 -down-> K1
K1 -down-> K2
K2 -down-> K3
K3 -down-> H1
H1 -down-> U2
@enduml
```

**整条链路的核心角色：**

| 层 | 做什么 | 关键数据结构 | 是否可能阻塞 |
|------|------|------|:---:|
| 用户态 | 调用 `read()`/`write()`，准备参数 | 用户栈、buf 指针 | 否（只是发起调用） |
| 系统调用入口 | 特权级切换、参数校验、分发 | `sys_call_table`、`pt_regs` | 否 |
| VFS | 统一文件操作接口，按文件类型分发 | `file`、`file_operations` | 否（VFS 本身不阻塞） |
| Page Cache | 缓存文件数据，管理页的状态 | `address_space`、`page`、`radix tree` | **是**（缺页时等 IO） |
| 块层 | 将 IO 请求排队、合并、调度 | `bio`、`request_queue` | 可能（等待调度） |
| 设备驱动 | 与硬件交互，发起 DMA | 设备寄存器、DMA 描述符 | **是**（等硬件完成） |
| 磁盘硬件 | DMA 读写物理扇区 | 磁盘控制器 | **是**（机械寻道/NAND 读取） |

## 二、read() 完整时序

以第一次读取某文件（数据不在 page cache 中）为例：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "用户态\n应用代码" as U
participant "CPU/硬件\n(syscall/sysret)" as CPU
participant "内核\n系统调用入口" as SYSCALL
participant "VFS\nvfs_read()" as VFS
participant "Page Cache\nfilemap" as PC
participant "块层\nsubmit_bio" as BLK
participant "磁盘驱动\nNVMe/SATA" as DRV
participant "磁盘\n控制器 DMA" as DISK
== 阶段一：陷入内核 ==
U    -> U    : ① 准备参数\n  rax=0(read 调用号)\n  rdi=fd, rsi=buf, rdx=4096
U    -> CPU  : ② 执行 syscall 指令
CPU  -> CPU  : ③ 硬件自动：\n  · 切特权级 ring3 到 ring0\n  · 保存用户 RIP 到 rcx, RFLAGS 到 r11\n  · 切换栈：用户栈 到 内核栈\n  · 跳转 MSR_LSTAR 入口
note right of CPU: **模式切换（不是上下文切换！）**\n还是同一个 task\n只是特权级变了、栈变了
CPU  -> SYSCALL : ④ entry_SYSCALL_64\n  保存所有用户寄存器到 pt_regs\n  (若 KPTI：切 CR3 到内核页表，刷 TLB)
== 阶段二：系统调用分发 ==
SYSCALL -> SYSCALL : ⑤ 按 rax=0 查 sys_call_table[0]\n  找到 sys_read()
SYSCALL -> VFS    : ⑥ sys_read(fd, buf, count)\n  从 current->files 拿 struct file\n  检查权限(file->f_mode)\n  调用 file->f_op->read()
== 阶段三：VFS 层 ==
VFS -> VFS : ⑦ vfs_read()\n  普通文件 generic_file_read_iter()\n  调用 filemap_read()
== 阶段四：Page Cache 查找 ==
VFS  -> PC  : ⑧ filemap_read()\n  查 address_space 的 radix tree\n  找 offset=0 对应的物理页
PC -> PC : ⑨ 页不在 cache 中！\n  page_cache_alloc() 分配新页\n  add_to_page_cache() 加入 radix tree\n  标记页为 PG_locked
note right of PC: 这是阻塞的起点\npage cache 没有数据\n必须读磁盘
== 阶段五：块层 + 磁盘 IO ==
PC  -> BLK : ⑩ readpage() 调用 submit_bio()\n  构造 bio 结构：\n    bio->bi_sector = 文件扇区号\n    bio->bi_io_vec = 目标页\n    bio->bi_end_io = 完成回调
BLK -> BLK : ⑪ 块层调度\n  IO 调度器(elv)合并/排序 bio\n  加入 request_queue
note right of BLK: 若使用 io_uring 或 AIO\n此处可能不阻塞直接返回\n但同步 read 会阻塞
BLK -> DRV : ⑫ 驱动拿到 request\n  构造 DMA 描述符\n  写设备寄存器发起命令
DRV -> DISK : ⑬ 磁盘 DMA 读取\n  磁盘控制器从 NAND 或盘片读数据\n  DMA 写入 page cache 的物理页
note right of DISK: DMA 期间 CPU 可以做别的事\n这就是为什么阻塞 IO 要切走
== 阶段六：阻塞与上下文切换 ==
PC -> PC : ⑭ 当前 task 阻塞\n  设置 task 状态 = TASK_UNINTERRUPTIBLE\n  加入等待队列(wait_queue)\n  调用 schedule()
note right of PC: **这里触发上下文切换！**\nA 切到 B，A 在等磁盘 IO\n这是 voluntary 上下文切换
... 磁盘 IO 完成（若干毫秒后）...
== 阶段七：磁盘中断 + 唤醒 ==
DISK -> DRV : ⑮ 磁盘中断到达
DRV -> PC   : ⑯ 中断处理 bio->bi_end_io()\n  解锁 PG_locked\n  唤醒等待队列上的 task A\n  A 重新入就绪队列
... A 被调度器选中 ...
== 阶段八：拷贝数据到用户空间 ==
PC -> PC : ⑰ filemap_read() 继续执行\n  page cache 页现在有数据了\n  copy_to_user(buf, page, 4096)
note right of PC: **CPU 拷贝！**\n内核态 page cache 到用户态 buf\n这是 1 次 CPU memcpy
== 阶段九：返回用户态 ==
PC   -> SYSCALL : ⑱ 返回已读字节数
SYSCALL -> SYSCALL : ⑲ 返回值放 rax\n  恢复用户寄存器(从 pt_regs)\n  (若 KPTI：切回用户页表，刷 TLB)
SYSCALL -> CPU : ⑳ 执行 sysret 指令
CPU -> CPU : ㉑ 硬件自动：\n  · 切特权级 ring0 到 ring3\n  · 恢复用户 RIP 和 RFLAGS\n  · 切回用户栈
CPU -> U : ㉒ read() 返回\n  rax = 实际读取的字节数
note over U, DISK
  **read() 完整过程总结**
  2 次模式切换(进/出内核)
  可能 1 次上下文切换(等磁盘 IO 时)
  1 次 CPU 拷贝(page cache 到 用户 buf)
  1 次 DMA 拷贝(磁盘 到 page cache)
  路径: 用户 syscall VFS page cache(miss) 块层 驱动 磁盘 DMA 中断 唤醒 拷贝 返回
end note
@enduml
```

### 2.1 read() 关键决策点

**page cache 命中 vs 未命中：**

| | Page Cache 命中 | Page Cache 未命中 |
|------|:---:|:---:|
| 磁盘 IO | 无 | 有（DMA 读取） |
| 是否阻塞 | 否 | **是**（同步 read 必须等） |
| 上下文切换 | 无 | **1 次**（voluntary，等 IO） |
| 延迟 | ~1-5 μs | **~0.1-10 ms**（取决于存储介质） |
| CPU 拷贝 | 1 次（page cache 到 用户 buf） | 1 次（同上） |

**为什么 page cache 未命中时一定要上下文切换？**

数据在磁盘上（不在内存），机械盘寻道 ~5-10ms，NVMe ~0.1ms——这段时间 CPU 如果空等（busy-wait），一个核就废了。内核的做法是：**把当前 task 标记为睡眠、切到另一个 task 去跑、等磁盘中断到达再唤醒**。这就是阻塞 IO 的本质——"等"的这段时间 CPU 去干别的。

## 三、write() 完整时序

写操作通常比读更复杂——涉及"写回"策略、脏页管理、可能触发回写：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "用户态\n应用代码" as U
participant "CPU/硬件" as CPU
participant "系统调用\nsys_write()" as SYSCALL
participant "VFS\nvfs_write()" as VFS
participant "Page Cache\nfilemap" as PC
participant "块层+驱动\n+磁盘" as DISK
== 阶段一：陷入内核（与 read 相同） ==
U    -> CPU : ① syscall(rax=1, write 调用号)
CPU  -> SYSCALL : ② entry_SYSCALL_64\n  特权级切换 ring3 到 ring0\n  保存寄存器、切内核栈
== 阶段二：系统调用 + VFS ==
SYSCALL -> VFS : ③ sys_write() 调用 vfs_write()\n  权限检查(file->f_mode)\n  文件偏移更新(file->f_pos)
== 阶段三：数据拷贝到 Page Cache ==
VFS -> PC : ④ generic_file_write_iter()\n  若写入范围跨页，先读未对齐的部分
PC  -> PC : ⑤ copy_from_user(page, buf, 4096)
note right of PC: **CPU 拷贝！**\n用户态 buf 到 page cache 页\n这是 1 次 CPU memcpy
PC  -> PC : ⑥ 标记页为脏(PG_dirty)\n  加入 address_space 的脏页链表\n  更新 inode 的 i_dirty_pages 计数
note right of PC: 数据**还没到磁盘**\n只在内存的 page cache 里\n这就是"写回(writeback)"策略
== 阶段四：可能触发回写 ==
PC -> PC : ⑦ 检查脏页比例\n  若超过 dirty_ratio / dirty_background_ratio\n  触发后台回写或同步回写
note right of PC: 若触发同步回写\n当前 write() 会阻塞\n等磁盘 IO 完成
PC -> PC : 正常情况下 write() 在这里就返回了\n  不需要上下文切换
== 阶段五：返回用户态 ==
PC   -> SYSCALL : ⑧ 返回写入字节数
SYSCALL -> CPU  : ⑨ sysret
CPU  -> U      : ⑩ write() 返回用户态
note right of CPU: write() 返回 不等于 数据已落盘\n只保证数据在 page cache 中\n（除非 O_SYNC/O_DIRECT）
note over U, DISK
  **write() 正常路径总结**
  2 次模式切换(进/出内核)
  0 次上下文切换(正常情况不阻塞)
  1 次 CPU 拷贝(用户 buf 到 page cache)
  0 次 DMA 拷贝(数据还在内存)
  **数据只写到了 page cache，未落盘**
end note
@enduml
```

### 3.1 write() 的写回策略

#### 3.1.1 什么是"写回"？

Linux 默认使用**写回（writeback）**策略——`write()` 调用只把数据拷到 page cache 并标记为脏页（PG_dirty），**不等待磁盘写入完成就立刻返回**。真正的物理落盘由内核后台线程异步完成。

```plantuml
@startuml
skinparam shadowing false
start
:write() 调用;
:copy_from_user()\n用户 buf 到 page cache;
:标记页为脏(PG_dirty);
if (脏页比例 > dirty_background_ratio?) then (是)
  :唤醒后台回写线程\n(pdflush/flush 内核线程)\n异步写脏页到磁盘;
  note right: 不阻塞 write()
else (否)
endif
if (脏页比例 > dirty_ratio?) then (是)
  :write() 本身被阻塞\n等待脏页被写回\n腾出空间;
  note right: 这时 write() 会触发\n上下文切换！
else (否)
endif
:write() 返回(数据在 page cache);
note right: 若干秒后...
:后台回写线程;
:submit_bio() 到 磁盘 DMA\n脏页写入磁盘;
:清除 PG_dirty 标记;
stop
@enduml
```

#### 3.1.2 为什么叫"写回"？

这个命名来源于缓存架构中经典的分类术语，核心思想是：**写操作的数据流向是"写回到缓存，延迟再写回到磁盘"**，因此叫"写回"。

来理解一下这个词的构词逻辑：

| 成分 | 含义 |
|------|------|
| **写** | 写操作，用户调用 `write()` |
| **回** | 回到 page cache（写的数据"回到"内存缓存中，而不是直接"穿过"缓存到磁盘） |

**更本质的理解**：从 CPU 指令的角度看，"写回"描述了 L1/L2 cache 的写入策略——CPU 执行 store 指令时只写入 cache，不立刻写入主存，修改过的 cache line 标记为 dirty，等到该 cache line 被驱逐时才"写回"主存。Page cache 是同一思想在 OS 层的延伸——`write()` 只写入 page cache，标记 PG_dirty，后台回写线程再"写回"磁盘。

#### 3.1.3 三种写策略对比

在操作系统和缓存设计中，对"写操作如何处理"有三种经典策略：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "应用\nwrite()" as U
participant "Page Cache\n(内存缓存)" as PC
participant "磁盘\n(物理存储)" as D
== 策略一：写回（writeback）—— Linux 默认 ==
U  -> PC : ① copy_from_user()\n数据写入 page cache
U  <- PC : ② write() **立即返回**
note right of U: write() 耗时 ~1-5μs\n不等待磁盘
PC -> D  : ③ 后台线程**稍后**\n将脏页写回磁盘
note right of D: 异步发生\n可能几秒后才真正落盘
== 策略二：写穿（write-through） ==
U  -> PC : ① copy_from_user()\n数据写入 page cache
PC -> D  : ② **同步**写磁盘
note right of D: 每次 write() 都要等\n磁盘确认后才返回
U  <- PC : ③ write() 返回\n数据已在磁盘
note right of U: write() 耗时 ~0.1-10ms\n每写必等
== 策略三：不缓存（no-cache / O_DIRECT） ==
U  -> D  : ① DMA 直接写磁盘\n绕过 page cache
D  -> U  : ② write() 返回
note right of U: 无缓存加速\n但应用可自管理缓存
@enduml
```

**三种策略的全面对比：**

| 维度 | 写回（writeback） | 写穿（write-through） | 不缓存（O_DIRECT） |
|------|:---:|:---:|:---:|
| **write() 延迟** | **极低**（~1-5μs） | 高（~0.1-10ms） | 高（~0.1-10ms） |
| **write() 是否阻塞** | 几乎不阻塞 | **每次阻塞** | **每次阻塞** |
| **数据何时落盘** | 异步（最多 30s） | 同步（write 返回时） | 同步（write 返回时） |
| **数据安全性** | **弱**（崩溃可能丢数据） | 强（每写必落盘） | 强 |
| **读加速** | 有（page cache 命中） | 有 | **无** |
| **内存占用** | 占用 page cache | 占用 page cache | 不占用 |
| **CPU 拷贝** | 1 次（用户到 page cache） | 1 次 | 0 次（DMA 直传） |
| **适用场景** | 通用文件 IO、日志 | 数据库 WAL、关键元数据 | 数据库自管理缓存 |
| **Linux 如何启用** | 默认 | 无原生支持，用 O_SYNC 近似 | `open(O_DIRECT)` |
| **崩溃一致性** | 需 fsync 保证 | 天然保证 | 需应用层保证 |

#### 3.1.4 写回 vs 写穿的深层对比

**为什么 Linux 默认选择写回而不是写穿？**

**写回（writeback）的优势：**

1. **写延迟极低**：write() 只写内存，不等待慢速磁盘，延迟从毫秒级降到微秒级。对于 IOPS 密集的应用（如 Web 服务器写日志），吞吐量差距可达 10-100 倍。
2. **写合并（write coalescing）**：同一个 page cache 页上的多次小写入可以在内存中合并，最终只产生一次磁盘 IO。写穿策略下，每次小写都要触发磁盘操作。

```bash

   写回：4次 write() → 4次 page cache 修改 → 1次磁盘 IO（合并后）

   写穿：4次 write() → 4次 page cache 修改 + 4次磁盘 IO

   ```

3. **吸收突发写入**：应用可能瞬间产生大量写入，page cache 像"蓄水池"一样先接住，后台慢慢排到磁盘。写穿策略下突发写入直接冲击磁盘，IO 队列满后应用被阻塞。

**写穿（write-through）的适用场景：**

1. **数据库 WAL（Write-Ahead Log）**：redo log 必须保证"写穿"语义——日志记录必须先于数据页落盘，崩溃恢复才能重放。这就是为什么数据库通常用 O_SYNC 或 O_DIRECT 写 WAL。
2. **文件系统元数据**：inode、目录项等元数据通常需要更强的持久性保证。这就是为什么即使数据用写回，元数据日志（journal）往往用 barrier/FLUSH 强制有序。
3. **关键配置/状态文件**：不能容忍丢失的场景，直接用 O_SYNC 或每次写后 fsync。

**折中方案：定期 fsync**

实际应用中常见做法是：使用默认的写回策略享受高性能，同时定期调用 `fsync()` 保证关键数据落盘：

```c
// 写回 + 定期 fsync：兼顾性能和安全
write(fd, buf, size);   // 快速返回（写回）
// ... 可以继续写更多 ...
fsync(fd);              // 强制所有脏页落盘，阻塞直到完成
```

这就是大多数数据库的 WAL 策略——日志写用 O_SYNC/O_DIRECT（写穿语义），数据页写用默认写回 + 定期 fsync（checkpoint）。

#### 3.1.5 写回的风险与缓解

**风险：数据丢失窗口**

写回策略下，write() 返回后数据仅在 page cache 中。如果系统崩溃（掉电、内核 panic），尚未回写的脏页数据会丢失。丢失窗口最大为 `dirty_expire_centisecs`（默认 30 秒）+ `dirty_writeback_centisecs`（默认 5 秒）= **最多 35 秒的数据**。

**缓解手段：**

| 手段 | 效果 | 代价 |
|------|------|------|
| `fsync()` / `fdatasync()` | 应用主动要求落盘 | 阻塞等待磁盘 |
| 减小 `dirty_expire_centisecs` | 缩小丢失窗口 | 增加磁盘 IO 频率 |
| 减小 `dirty_writeback_centisecs` | 回写线程更频繁检查 | 略微增加 CPU 开销 |
| 使用 O_SYNC | 每次 write 等价于 write+fsync | 性能骤降 |
| 使用带电池的 RAID 卡 | 控制器缓存不怕掉电 | 硬件成本 |

#### 3.1.6 关键参数

| 参数 | 含义 | 默认值 |
|------|------|:---:|
| `dirty_background_ratio` | 脏页超过此比例时**后台**开始回写 | 10% |
| `dirty_ratio` | 脏页超过此比例时**同步**等待回写（write() 会阻塞） | 20% |
| `dirty_expire_centisecs` | 脏页超过此时长强制回写 | 3000（30秒） |
| `dirty_writeback_centisecs` | 回写线程唤醒间隔 | 500（5秒） |

> 查看和调整：`sysctl -a | grep dirty` 或 `cat /proc/sys/vm/dirty_*`

### 3.2 O_SYNC / O_DIRECT / fsync —— 强制落盘

| 方式 | 行为 | 何时阻塞 | 性能 |
|------|------|------|:---:|
| **默认 write()** | 写到 page cache 即返回 | 仅脏页超比例时 | 最快 |
| **fsync(fd)** | 强制该文件所有脏页落盘 | 等磁盘 IO 完成 | 慢 |
| **fdatasync(fd)** | 强制该文件数据（不含元数据）落盘 | 等磁盘 IO 完成 | 比 fsync 稍快 |
| **open(O_SYNC)** | 每次 write() 等价于 write+fsync | **每次 write 都阻塞** | 最慢 |
| **open(O_DIRECT)** | 绕过 page cache，直接读写磁盘 | 每次 IO 都阻塞 | 对应用自管理缓存有利 |

```c
// O_SYNC：每次 write 都等磁盘确认——最安全也最慢
int fd = open("file", O_WRONLY | O_SYNC);
write(fd, buf, 4096);  // 阻塞直到数据确实写入磁盘
// O_DIRECT：绕过 page cache，DMA 直接在用户 buf 和磁盘间传输
int fd = open("file", O_RDONLY | O_DIRECT);
// buf 必须对齐到 512 字节边界
read(fd, aligned_buf, 4096);  // 磁盘 DMA 直接写入 aligned_buf
```

## 四、read + write 组合：一次完整的数据搬运

把 read（从文件读）和 write（写入 socket/另一个文件）串起来看：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "用户态\n应用" as U
participant "内核\nPage Cache\n(读端)" as PC_R
participant "用户 buf\n(数据中转)" as BUF
participant "内核\nSocket 缓冲\n(写端)" as SOCK
participant "磁盘" as DISK
participant "网卡" as NIC
== read(file_fd, buf, 4096) ==
U    -> PC_R : ① syscall read
PC_R -> PC_R : ② page cache 命中？
note right of PC_R: 未命中则等磁盘 DMA\n可能触发上下文切换
DISK -> PC_R : ③ DMA: 磁盘 到 page cache
PC_R -> BUF  : ④ CPU 拷贝\npage cache 到 用户 buf
U    <- PC_R : ⑤ read 返回 4096
note right of BUF: 至此：1 次 DMA + 1 次 CPU 拷贝\n数据在用户 buf 中
== write(sock_fd, buf, 4096) ==
U    -> SOCK : ⑥ syscall write
BUF  -> SOCK : ⑦ CPU 拷贝\n用户 buf 到 socket 缓冲区
note right of SOCK: 又 1 次 CPU 拷贝！
U    <- SOCK : ⑧ write 返回 4096
SOCK -> NIC  : ⑨ DMA: socket 缓冲区 到 网卡
note over U, NIC
  **read+write 搬运数据的总代价**
  2 次 DMA(磁盘 到 page cache, socket 到 网卡)
  **2 次 CPU 拷贝**(page cache 到 用户 buf, 用户 buf 到 socket)
  4 次模式切换(2 进 2 出)
  可能 1 次上下文切换(read 等磁盘时)
  **数据"路过"了用户空间，白白让 CPU 拷了两遍**
  这就是零拷贝要消灭的！见 zero-copy.md
end note
@enduml
```

**这就是传统 read+write 的问题**——数据只是"路过"用户空间（应用没改它），CPU 却做了两次全量 memcpy。零拷贝（见 [zero-copy.md](/concepts/network/zero-copy.md)）就是消除这中间的②③号 CPU 拷贝。

## 五、有没有线程切换？——完整分析

### 5.1 read+write 过程中的切换全景

```plantuml
@startuml
skinparam shadowing false
start
:用户态执行;
note right: 应用在 ring3，用用户栈
:syscall read() 陷入内核;
note right: **模式切换 #1**\nring3 到 ring0\n换内核栈\n不换 task！
:VFS 到 Page Cache 查找;
if (数据在 page cache?) then (命中)
  :copy_to_user()\npage cache 到 用户 buf;
else (未命中)
  :发起磁盘 IO;
  :设置 TASK_UNINTERRUPTIBLE;
  :schedule();
  note right: **上下文切换！**\n换到另一个 task\nvoluntary 切换
  :磁盘中断 唤醒 重新被调度;
  note right: **又上下文切换回来**
endif
:sysret 返回用户态;
note right: **模式切换 #2**\nring0 到 ring3\n换回用户栈
:用户态拿到数据;
note right: 此时可以做任何处理\n修改/加密/压缩数据...
:syscall write() 陷入内核;
note right: **模式切换 #3**\nring3 到 ring0
:copy_from_user()\n用户 buf 到 socket/page cache;
:sysret 返回用户态;
note right: **模式切换 #4**\nring0 到 ring3
stop
@enduml
```

**总结：一次完整的 read+write，至少发生：**

| 切换类型 | 最少次数 | 最多次数 | 说明 |
|------|:---:|:---:|------|
| **模式切换**（用户↔内核） | 4 | 4 | read 进/出 + write 进/出，每次都切 |
| **上下文切换**（task A 到 B） | 0 | 2+ | read 等磁盘时可能切走再切回 |
| **栈切换**（用户栈↔内核栈） | 4 | 4 | 每次模式切换都换栈 |
| **特权级切换**（ring3↔ring0） | 4 | 4 | 硬件自动，每次模式切换都做 |
| **CR3 切换**（开 KPTI 时） | 4 | 4+ | KPTI 下用户/内核用不同页表，每次进出都换 |

### 5.2 栈的变化详解

```bash
时间线 →  read() 调用前    read() 内核态     read() 返回后    write() 内核态    write() 返回后
          ─────────────    ─────────────     ─────────────    ──────────────    ──────────────
栈指针 →  用户栈顶         内核栈顶           用户栈顶          内核栈顶           用户栈顶
          │              │                 │               │                │
          │ main()       │ sys_read()      │ main()        │ sys_write()    │ main()
          │ handler()    │ vfs_read()      │ handler()     │ vfs_write()    │ handler()
          │ read() ←─┐   │ filemap_read()  │ ...           │ copy_from_user │ ...
          │          │   │                 │               │                │
          │          │   └─ 用户 RIP/RSP   │               │                │
          │          │      保存在内核栈顶  │               │                │
          ▼          │                     ▼               │                ▼
特权级    ring3          ring0             ring3           ring0            ring3
页表      用户页表       内核页表            用户页表         内核页表           用户页表
         (KPTI:切到     (KPTI:用户+内核     (KPTI:切回      (KPTI:切到        (KPTI:切回
          内核页表)      都映射)             用户页表)        内核页表)          用户页表)
```

**关键点：**

- 每个 task 有**两个栈**：用户栈（ring3 用）和内核栈（ring0 用）
- 内核栈通常在 `task_struct` 所在的页（`thread_info` 也在那里），大小 8KB-16KB
- `syscall` 指令自动切换栈：CPU 从 MSR 寄存器读取内核栈地址
- 用户态的 RIP/RSP/RFLAGS 被硬件自动保存在内核栈顶（作为 `pt_regs`）

### 5.3 特权级切换的硬件细节

`syscall` 指令执行时 CPU 硬件的原子操作（不可中断）：

```bash
1. RCX ← RIP          // 保存返回地址（syscall 的下一条指令）
2. R11 ← RFLAGS       // 保存标志寄存器
3. RIP ← MSR_LSTAR    // 跳到内核入口（开机时设定，固定地址）
4. CS ← MSR_STAR[47:32]  // 切换代码段为内核段（ring0）
5. SS ← MSR_STAR[47:32]+8 // 切换栈段
6. RFLAGS ← RFLAGS & ~MSR_FMASK  // 清除中断使能等标志
   // 注意：栈指针切换不是硬件自动的！
   // 内核入口第一条指令通常就是 swapgs + mov rsp, ...
```

`sysret` 返回时：

```bash
1. RIP ← RCX          // 恢复用户态返回地址
2. RFLAGS ← R11       // 恢复标志寄存器
3. CS ← MSR_STAR[63:48] // 切回用户代码段（ring3）
4. SS ← MSR_STAR[63:48]+8 // 切回用户栈段
   // 内核代码负责恢复用户栈指针 RSP
```

> 这就是为什么 syscall 使用 `rcx` 和 `r11` 保存返回信息——普通函数调用约定中 `rcx` 是第四个参数，`r11` 是临时寄存器，syscall 征用了它们。

### 5.4 为什么阻塞 IO 一定要切走？—— wait_queue 内部机制

当 page cache 未命中时，`filemap_read()` 调用 `wait_on_page_locked()` 等待磁盘 IO 完成。这不是简单的 `while` 循环——背后是等待队列（wait_queue）和调度器的精密配合。

#### 5.4.1 等待队列的数据结构

```c
// include/linux/wait.h (简化)
struct wait_queue_entry {
    unsigned int        flags;
    void               *private;          // 指向 task_struct
    wait_queue_func_t   func;             // 唤醒回调函数
    struct list_head    entry;            // 挂在 wait_queue_head 上
};
struct wait_queue_head {
    spinlock_t          lock;             // 保护链表的自旋锁
    struct list_head    head;             // wait_queue_entry 链表
};
```

**每个"可能阻塞的点"都有一个 wait_queue_head**：

- `page->wait` — 等待该页 IO 完成
- `inode->i_wait` — 等待 inode 锁释放
- `bdev->bd_wait` — 等待块设备就绪
- `file->f_wait` — 等待文件状态变化（如 O_NONBLOCK 的 FIFO）

#### 5.4.2 完整的"睡下去"过程

```bash
page cache miss 时的阻塞流程（逐行对应内核代码）：
步骤 1: 准备等待
  wait_queue_entry_t wait;
  init_wait_entry(&wait, current);   // current = 当前 task
  // wait.private = current
  // wait.func    = autoremove_wake_function  (默认唤醒回调)
步骤 2: 把自己加入等待队列
  spin_lock_irq(&page->wait.lock);
  __add_wait_queue(&page->wait, &wait);  // 挂到 page->wait.head 链表
  spin_unlock_irq(&page->wait.lock);
步骤 3: 设置 task 状态并调度
  set_current_state(TASK_UNINTERRUPTIBLE);  // 不可中断睡眠
  // 此时 task 的状态标志已改，但 CPU 还在跑这个 task
  // ★ 关键：这是"设置意图"，真正的切换在 schedule() 中发生
步骤 4: 主动让出 CPU
  schedule();  // ← 真正的上下文切换发生在这里！
  // ... 若干毫秒后，被唤醒，从这里继续执行 ...
步骤 5: 被唤醒后
  set_current_state(TASK_RUNNING);     // 恢复为可运行状态
  // 检查自己是否真的被唤醒了（而非虚假唤醒）
  if (!page->flags & PG_locked)
      break;  // IO 已完成，页已解锁
```

**为什么在 `schedule()` 之前先 `set_current_state(TASK_UNINTERRUPTIBLE)`？**

这是一个**先声明意图、再让出 CPU** 的两步协议：

```bash
如果反过来（先 schedule 再改状态）会怎样？
  错误顺序：
  ① schedule() — CPU 切走了
  ② （在别的 CPU 上，磁盘中断到达，唤醒等待队列）
  ③ 唤醒函数检查等待队列上每个 task 的状态
     → task 状态还是 TASK_RUNNING！
     → 唤醒函数跳过它（以为它不需要唤醒）
  ④ task 永远不会被唤醒 → 死锁！
  正确顺序：
  ① set_current_state(TASK_UNINTERRUPTIBLE) — 先声明"我要睡了"
  ② schedule() — 再让出 CPU
  ③ 中断到达时，唤醒函数看到状态 = TASK_UNINTERRUPTIBLE
     → 将其状态改回 TASK_RUNNING 并放入就绪队列
  ✓ 正确唤醒
```

#### 5.4.3 完整的"醒过来"过程

```c
// 磁盘中断 → bio_endio() → 最终调用 unlock_page()
void unlock_page(struct page *page)
{
    // 1. 清除 PG_locked 标志
    //    并使用内存屏障确保其他 CPU 可见
    clear_bit_unlock(PG_locked, &page->flags);
    smp_mb__after_atomic();  // 保证唤醒之前所有 CPU 都看到页已解锁
    // 2. 唤醒等待队列上的所有 task
    wake_up_page(page, PG_locked);
}
// wake_up_page() → __wake_up_common()
static void __wake_up_common(struct wait_queue_head *wq_head, ...)
{
    struct wait_queue_entry *entry;
    list_for_each_entry(entry, &wq_head->head, entry) {
        // 对队列上每个等待者，调用其唤醒函数
        // 默认是 autoremove_wake_function
        entry->func(entry, ...);
    }
}
// autoremove_wake_function() 做三件事：
int autoremove_wake_function(wait_queue_entry_t *entry, ...)
{
    // 1. 把 entry 从等待队列上摘下来
    list_del_init(&entry->entry);
    // 2. 把 task 状态改回 TASK_RUNNING
    // 3. 把 task 放回就绪队列 (runqueue)
    return try_to_wake_up(entry->private, TASK_NORMAL, ...);
    //                                          ↑
    //                              entry->private 就是 task_struct
}
```

**唤醒后的关键动作 `try_to_wake_up()`：**

```bash
try_to_wake_up(p) 做了什么：
1. 检查 p 当前状态
   → 是 TASK_UNINTERRUPTIBLE / TASK_INTERRUPTIBLE？
   → 是，将其改为 TASK_RUNNING
2. 选择目标 CPU
   → 优先选 p 上次运行的 CPU（缓存还是热的）
   → 若该 CPU 被隔离(isolcpus)或离线，选空闲的 CPU
   → 见调度器的 select_task_rq()
3. 将 p 放入目标 CPU 的就绪队列 (runqueue)
   → enqueue_task(rq, p, ENQUEUE_WAKEUP)
   → 若 p 的优先级高于当前 task，设置 TIF_NEED_RESCHED 标志
4. 返回
   → p 现在是 TASK_RUNNING 状态，在就绪队列中等待调度
   → 被唤醒不等于立刻执行，需要等调度器选中它
```

### 5.5 从 schedule() 到 switch_to() 的完整时间线

当 page cache miss 的 task 调用 `schedule()` 后，到底发生了什么？下面是把"切走"和"切回"拼起来的完整视角：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "Task A\n(在等磁盘 IO)" as A
participant "schedule()\n调度器" as SCHED
participant "switch_to()\n汇编级切换" as SW
participant "Task B\n(在就绪队列)" as B
participant "磁盘中断\n硬件" as IRQ
participant "wake_up\n唤醒路径" as WAKE
== 阶段一：A 主动让出 CPU ==
A    -> A    : ① page cache miss\n  调用 wait_on_page_locked()
A    -> A    : ② set_current_state(TASK_UNINTERRUPTIBLE)\n  task->state = TASK_UNINTERRUPTIBLE
A    -> SCHED : ③ schedule() 被调用
note right of A: A 的内核栈上\n有完整的调用链：\nfilemap_read() → ... → schedule()
== 阶段二：调度器选人 ==
SCHED -> SCHED : ④ __schedule() 开始\n  关内核抢占 preempt_disable()
SCHED -> SCHED : ⑤ pick_next_task()\n  从就绪队列中选下一个 task\n  假设选中 Task B
== 阶段三：上下文切换 ==
SCHED -> SW : ⑥ context_switch()\n  switch_mm(A->mm, B->mm)\n    → 若 A 和 B 不同进程：mov cr3, B->pgd\n  switch_to(A, B)
note right of SW: **上下文切换发生**\nSP 换到 B 的内核栈\n从此执行流变成 B
SW -> B : ⑦ B 从自己上次\n  schedule() 的返回点\n  继续执行
note right of B: B 的内核栈上有它自己的\n调用链，B 执行它该做的事
... B 在运行（可能是另一个 read()、或是计算任务）...
== 阶段四：磁盘 IO 完成（异步发生）==
IRQ -> WAKE : ⑧ 磁盘中断到达\n  (可能在 B 运行时\n   或另一个 CPU 上)
WAKE -> WAKE : ⑨ bio_end_io()\n  → unlock_page()\n  → wake_up_page()
WAKE -> WAKE : ⑩ try_to_wake_up(A)\n  A->state = TASK_RUNNING\n  A 入就绪队列
note right of WAKE: A 现在是 TASK_RUNNING\n但 B 还在跑\nA 需要等调度
... A 在就绪队列中等待 ...
== 阶段五：A 被重新调度 ==
B    -> SCHED : ⑪ B 用完时间片\n  或被抢占\n  或主动 schedule()
SCHED -> SCHED : ⑫ pick_next_task()\n  这次选中 A
SCHED -> SW : ⑬ context_switch(B, A)\n  switch_mm + switch_to
SW -> A : ⑭ A 从 schedule() 返回！
note right of A: A 的内核栈完好\n就像 schedule() 只是\n"睡了一觉"
A -> A : ⑮ set_current_state(TASK_RUNNING)\n  检查 page 不再 locked\n  继续 filemap_read()
@enduml
```

**这个时间线揭示的关键事实：**

| 阶段 | 谁在跑 | A 的状态 | page cache 页的状态 |
|------|------|------|------|
| ①-② | A | TASK_RUNNING | PG_locked |
| ③-⑥ | A（正在切走） | TASK_UNINTERRUPTIBLE | PG_locked |
| ⑦ | B | TASK_RUNNING | PG_locked |
| ⑧-⑩ | B（中断可能在其他 CPU） | TASK_UNINTERRUPTIBLE → TASK_RUNNING | 解锁了！ |
| ⑪-⑬ | B（正在切走） | TASK_RUNNING（在就绪队列） | 已解锁 |
| ⑭-⑮ | A（回来了！） | TASK_RUNNING | 已解锁，数据就绪 |

**A 的视角**：`schedule()` 就像一个很慢的函数调用——调用时传入"我要等 IO"，返回时 IO 已经完成了。A 的内核栈原封不动，所有局部变量都在，就像时间被冻结了。

### 5.6 为什么 page cache miss 不直接用 TASK_INTERRUPTIBLE？

```c
// 为什么内核用 TASK_UNINTERRUPTIBLE 而不是 TASK_INTERRUPTIBLE？
// 这是故意的设计选择：
// TASK_INTERRUPTIBLE：
//   → 可以被信号(signal)唤醒
//   → 如果被信号唤醒，read() 返回 -EINTR
//   → 用户程序需要处理 EINTR 并重新调用 read()
//   → 对磁盘 IO 不合适——数据没读完，返回 -EINTR 破坏了语义
// TASK_UNINTERRUPTIBLE：
//   → 信号不能唤醒，只能由等待的事件（磁盘 IO 完成）唤醒
//   → 保证 read() 要么返回实际读到的字节，要么返回错误
//   → 不会出现"读到一半被信号打断"的情况
```

**但这有一个著名的副作用——D 状态进程**：

```bash
# 如果一个磁盘 IO 永远不完成（NFS 服务端挂了、SAN 断连）
# 等待该 IO 的进程会永远卡在 TASK_UNINTERRUPTIBLE
# 这就是 top 中看到的 D 状态（不可中断睡眠）
$ ps aux | grep ' D'
# root  12345  0.0  0.0  0  0 ?  D   Jan01  0:00 [nfs_io]
# ↑ 状态 D = TASK_UNINTERRUPTIBLE
# 这个进程 kill -9 都杀不掉——它不响应信号！
# 只能等 IO 恢复或重启机器
```

### 5.7 并发 IO 场景下的线程切换交互

当多个线程同时对不同文件（或同一文件不同偏移）调用 read() 时，上下文切换的行为会更复杂：

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #C62828
  LifeLineBorderColor        #90A4AE
}
participant "CPU 核 0" as CPU0
participant "CPU 核 1" as CPU1
participant "磁盘控制器" as DISK
== 场景：4 个线程在 2 个核上同时读 4 个不同文件 ==
note over CPU0, CPU1: 初始状态：Thread-1 在 CPU0，Thread-2 在 CPU1\nThread-3、Thread-4 在就绪队列
CPU0 -> CPU0 : T1: read(fd1) 缺页\n  → TASK_UNINTERRUPTIBLE\n  → schedule()
CPU0 -> CPU0 : 调度器选 T3\n  T3: read(fd3) 缺页\n  → TASK_UNINTERRUPTIBLE\n  → schedule()
CPU0 -> CPU0 : 调度器选 T4\n  T4 是计算任务\n  开始跑
CPU1 -> CPU1 : T2: read(fd2) 缺页\n  → TASK_UNINTERRUPTIBLE\n  → schedule()
CPU1 -> CPU1 : 就绪队列为空！\n  CPU1 跑 idle 任务
note right of CPU1: idle 任务：执行 HLT 指令\nCPU 进入低功耗等待中断
... 磁盘控制器处理 4 个 IO 请求 ...
DISK -> CPU0 : fd1 IO 完成中断
CPU0 -> CPU0 : 唤醒 T1 → 入就绪队列\n  (T4 还在跑，T1 等着)
DISK -> CPU1 : fd2 IO 完成中断
CPU1 -> CPU1 : 唤醒 T2 → 入就绪队列\n  CPU1 从 idle 醒来\n  调度 T2 继续
DISK -> CPU0 : fd3 IO 完成中断
CPU0 -> CPU0 : 唤醒 T3 → 入就绪队列
CPU0 -> CPU0 : T4 时间片到\n  调度器选 T1 或 T3
... 最终所有线程都完成了 read() ...
@enduml
```

**关键观察：**

| 现象 | 原因 | 性能影响 |
|------|------|------|
| 多个线程同时 read 不同文件 | 每个缺页都触发一次上下文切换 | 切换次数 = 线程数 × 2（切走+切回） |
| IO 密集型 CPU 利用率低 | 大量线程在 TASK_UNINTERRUPTIBLE，CPU 跑 idle | 瓶颈在磁盘而非 CPU |
| 线程数 > 磁盘 IOPS 时 | 更多线程不会提升吞吐，只会增加切换开销 | 需要限制并发 IO 数（线程池/io_uring） |
| 中断可能在不同 CPU 上处理 | 唤醒和 IO 完成解耦，利用多核 | IRQ affinity 可优化 |

**`iowait` 的含义（见 [parameter/io/iowait.md](/parameter/io/iowait.md)）：**

当 CPU 空闲且有至少一个 task 在等该 CPU 发起的 IO 时，这段空闲时间被计入 iowait。但注意——如果 IO 是其他 CPU 发起的，或者该 CPU 在跑 idle 但没有 task 在等 IO，就不算 iowait。

### 5.8 read+write 中上下文切换的全景汇总

把上下文切换、模式切换、栈切换放在同一张表里对比：

| 切换类型 | 触发时机 | 切换了什么 | 开销 | 是否必然发生 |
|------|------|------|:---:|:---:|
| **模式切换** | syscall/sysret 指令 | CS（代码段）、特权级 ring3↔ring0、栈指针 | ~100-200 cycles | **是**（每次 read/write 各 2 次） |
| **上下文切换-切走** | schedule() 被调用 | 寄存器现场、SP（换内核栈）、可能 CR3 | ~1-5μs 显性 + 5-50μs 隐性 | **否**（仅 page cache miss 或脏页超限时） |
| **上下文切换-切回** | 被调度器选中 | 寄存器现场、SP、可能 CR3 | 同上 | **否**（同上） |
| **KPTI CR3 切换** | 每次模式切换时（若开启 KPTI） | CR3 寄存器 → TLB 刷新 | ~300-500 cycles（无 PCID）<br>~100 cycles（有 PCID） | **是**（开启 KPTI 时） |
| **FPU 惰性切换** | 切换后新 task 首次用浮点 | x87/SSE/AVX 寄存器组 | 0（若不用）<br>~256B-2KB 的保存/恢复 | **否**（仅新 task 用浮点时） |

**read+write 中切换的三种典型场景：**

```bash
场景一：page cache 命中（最快路径）
  read()  2 次模式切换（进/出）
  write() 2 次模式切换（进/出）
  上下文切换：0 次
  KPTI CR3 切换：4 次
  总开销：~1-2μs（模式切换）+ ~1μs（KPTI 若开启）
  ★ 这是最快的路径——数据已经在内存里
场景二：page cache 未命中（磁盘 IO）
  read()  2 次模式切换 + 1 次上下文切换（切走）+ 1 次上下文切换（切回）
  write() 2 次模式切换
  上下文切换：2 次
  总开销：~1-2μs（模式切换）+ ~2-10μs（上下文切换显性）+ ~5-50μs（缓存恢复）
  实际延迟：~0.1-10ms（磁盘 IO 占主导）
  ★ 上下文切换本身开销不大，真正慢的是磁盘
场景三：write 触发脏页回写
  read()  2 次模式切换（假设命中）
  write() 2 次模式切换 + 可能 1-2 次上下文切换（等脏页回写）
  上下文切换：0-2 次
  ★ write() 通常不阻塞，但脏页超限时会
```

> **一句话**：read+write 中"有没有线程切换"取决于 page cache 是否命中。命中了就 0 次上下文切换（只有模式切换），没命中就 1-2 次（等磁盘 IO）。上下文切换本身的显性开销（1-5μs）远小于磁盘 IO 延迟（0.1-10ms），但切换后的缓存恢复期（5-50μs）在并发场景下不可忽视。详见 [context-switch.md](/concepts/process/context-switch.md) §5.6。

## 六、数据流的走向：内存在哪、谁在搬

```plantuml
@startuml
left to right direction
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<disk>> #FFCDD2
  BorderColor<<disk>> #C62828
  BackgroundColor<<mem>> #C8E6C9
  BorderColor<<mem>> #388E3C
  BackgroundColor<<cpu>> #FFF9C4
  BorderColor<<cpu>> #F9A825
}
rectangle "磁盘\n(持久存储)" <<disk>> as D
rectangle "Page Cache\n(内核态内存\n物理页)" <<mem>> as PC
rectangle "用户 buf\n(用户态内存\n虚拟地址)" <<mem>> as U
rectangle "Socket 缓冲区\n(sk_buff\n内核态内存)" <<mem>> as S
rectangle "网卡 FIFO\n(硬件缓冲)" <<disk>> as N
D  -down-> PC : read 路径\n① DMA(硬件搬)\n磁盘 到 page cache
PC -down-> U  : ② CPU 拷贝\npage cache 到 用户 buf
U  -down-> S  : write 路径\n③ CPU 拷贝\n用户 buf 到 socket 缓冲
S  -down-> N  : ④ DMA(硬件搬)\nsocket 到 网卡
@enduml
```

| 数据搬运动作 | 谁在搬 | 经过什么总线 | 内存带宽消耗 |
|------|------|------|:---:|
| ① 磁盘 到 page cache | **DMA 控制器** | PCIe 到 内存总线 | 1x 数据量 |
| ② page cache 到 用户 buf | **CPU**（memcpy） | 内存总线（读+写） | 2x 数据量（读出再写入） |
| ③ 用户 buf 到 socket 缓冲 | **CPU**（memcpy） | 内存总线（读+写） | 2x 数据量 |
| ④ socket 到 网卡 | **DMA 控制器** | 内存总线 到 PCIe | 1x 数据量 |
| **总计** | | | **6x 数据量的内存带宽** |

> 1MB 数据搬运 → 消耗 6MB 内存带宽。高并发下这很快成为瓶颈。零拷贝（sendfile+SG-DMA）把②③都省掉 → 只用 2x 带宽。

## 七、观测与调试

```bash
# 跟踪 read/write 系统调用
strace -e trace=read,write -T ./app     # -T 显示每次耗时
strace -c ./app                          # 统计次数和总耗时
# 查看 page cache 命中率
cat /proc/vmstat | grep pg
# pgpgin  - 从磁盘读入的页数（page-in）
# pgpgout - 写出到磁盘的页数（page-out）
# pgfault - 缺页次数（含 page cache miss）
# 查看 IO 统计
iostat -x 1                              # 磁盘 await/svctm/%util
cat /proc/<pid>/io                       # 进程级 IO 统计
# read_bytes, write_bytes, cancelled_write_bytes
# 查看上下文切换
pidstat -w 1                             # 自愿/非自愿切换
vmstat 1                                 # cs 列
# 火焰图看内核态耗时
perf record -g -e syscalls:sys_enter_read ./app
perf script | stackcollapse-perf.pl | flamegraph.pl > read.svg
```

## 八、与仓库其他文档的关系

- **系统调用机制**：[../process/syscall.md](/concepts/process/syscall.md)（陷入/返回的细节、开销、vDSO）
- **上下文切换**：[../process/context-switch.md](/concepts/process/context-switch.md)（四大触发场景、switch_mm/switch_to、切进程 vs 切线程）
- **DMA 原理**：[dma.md](/concepts/io/dma.md)（磁盘 DMA 如何读写 page cache、SG-DMA）
- **Page Cache**：[../elf/mmap.md](/concepts/elf/mmap.md)（mmap 四象限、page cache 映射机制）
- **零拷贝**：[../network/zero-copy.md](/concepts/network/zero-copy.md)（sendfile/splice 如何省掉 read+write 的 CPU 拷贝）
- **中断处理**：[../process/interrupts.md](/concepts/process/interrupts.md)（磁盘 IO 完成中断如何唤醒阻塞的 task）
- **块层调度**：[../../tools/disk/iostat.md](/tools/disk/iostat.md)（iostat 观测磁盘 IO）

## 九、一句话总结

> **一次最简单的 read+write 搬运数据，至少经历 4 次特权级模式切换（用户↔内核）、2 次 CPU memcpy（page cache↔用户 buf↔socket 缓冲）、可能 1-2 次上下文切换（等磁盘 IO 时），数据在内存里被 CPU 来回搬了两趟、每次搬动都消耗 2 倍内存带宽（读+写）。栈从用户栈切到内核栈再切回来、CR3 在 KPTI 下也要切——所有这些都是纯开销。理解这条链路是理解"为什么要零拷贝"、"为什么要用 io_uring"、"为什么 page cache 未命中时系统变慢"的基础。**

