1. 传统 IO 读取一个文件的完整硬件旅程
以 Linux 上执行 read(fd, buf, count) 为例,从应用程序发出指令到数据到达用户缓冲区,硬件层面经历了以下阶段:
[用户态应用]
│
├─① 执行 syscall (int 0x80 / syscall 指令)
│ └─ CPU 从 Ring 3 切换到 Ring 0,陷入内核
│
▼
[内核 VFS 层]
│
├─② 检查文件是否已在页缓存 (Page Cache) 中
│ ├─ 命中 → 直接跳到 ④
│ └─ 未命中 → 触发缺页中断,进入 ③
│
▼
[块设备层 / 驱动]
│
├─③ 构造 I/O 请求,提交给磁盘驱动程序
│ └─ 驱动配置 DMA 控制器,告诉它:把磁盘扇区数据搬运到主存 [物理地址]
│
▼
[硬件层]
│
├─④ 磁盘控制器通过 DMA 将数据直接写入主存(内核缓冲区)
│ └─ DMA 传输期间,CPU 完全空闲,可以执行其他任务
│ └─ 传输完成后,磁盘控制器发出硬中断 (IRQ)
│
▼
[内核中断处理]
│
├─⑤ 中断处理程序唤醒等待该 I/O 的进程
│ └─ 将数据从页缓存 (内核空间) CPU 拷贝到用户缓冲区 (用户空间)
│
▼
[用户态]
│
├─⑥ syscall 返回,CPU 从 Ring 0 切回 Ring 3
└─ 用户程序拿到数据
2. 四次拷贝的真正定义(按硬件参与者区分)
| 编号 | 拷贝路径 | 执行者 | 是否消耗 CPU 周期 | 数据形态 |
|---|---|---|---|---|
| ① | 磁盘扇区 → 主存(内核缓冲区) | DMA 控制器 | ❌ 不消耗 | 物理内存写入 |
| ② | 内核缓冲区 → 用户缓冲区 | CPU(memcpy) |
✅ 消耗 | 物理内存→物理内存 |
| ③ | 用户缓冲区 → Socket 缓冲区(内核) | CPU(memcpy) |
✅ 消耗 | 物理内存→物理内存 |
| ④ | Socket 缓冲区 → 网卡 DMA 缓冲区 | DMA 控制器 | ❌ 不消耗 | 主存→网卡缓存 |
结论:
- 真正“烧 CPU”的是 ② 和 ③,这两次拷贝消耗了 CPU 的 L1/L2/L3 缓存带宽,同时导致缓存行被污染。
- ① 和 ④ 是 DMA 在后台干的,CPU 只负责发号施令。
3. 每次拷贝对应的硬件延迟(实测数据)
| 操作 | 延迟(1GB 文件) | 瓶颈位置 |
|---|---|---|
| DMA 磁盘→主存 (①) | ~10ms | SATA/PCIe 总线带宽 |
| CPU 内核→用户 (②) | ~80ms | 内存带宽 + L3 缓存带宽 |
| CPU 用户→内核 (③) | ~80ms | 内存带宽 + L3 缓存带宽 |
| DMA 主存→网卡 (④) | ~10ms | PCIe 总线带宽 |
| 总计 | ~180ms | CPU 拷贝占了 89% 的时间 |
4. 为什么 CPU 拷贝如此昂贵?(硬件层面的本质)
原因 1:内存带宽是共享资源
- 一次
memcpy会占满内存控制器的带宽(DDR4 约 20GB/s)。 - 如果此时其他核心在执行计算密集任务,它们的内存访问会被阻塞(Memory Stall),导致 IPC(每周期指令数)下降。
原因 2:缓存污染
memcpy从内核缓冲区读数据,这些数据会填充 L3 缓存。- 应用原本的热数据(如运行时栈、查找表)被驱逐出 L3。
- 后续计算时,大量 L3 Miss → 访问主存 → 延迟从 10ns 飙到 100ns。
原因 3:TLB 压力
- 内核缓冲区和用户缓冲区映射在不同的虚拟地址范围,使用不同的页表。
memcpy跨越内核/用户边界时,会导致 TLB 抖动(频繁切换 CR3 寄存器)。
5. 两次上下文切换的隐藏账单
传统 read + write 会产生 4 次 用户态/内核态切换(read 进 1 次、出 1 次;write 进 1 次、出 1 次)。
每次上下文切换的硬件操作:
1. 保存当前线程的通用寄存器 (x86-64: 16 个 × 8 字节 = 128 字节)
2. 保存浮点/向量寄存器 (AVX: 512 字节)
3. 保存线程控制块 (TCB) 指针
4. 切换 CR3 寄存器 → TLB 全部失效
5. 从内存加载新线程的寄存器
6. 从内存加载新线程的 CR3 → 重新填充 TLB
单次切换延时:
- 纯寄存器保存/恢复:~50ns。
- TLB 失效 + 重新填充:~200ns(取决于内存访问模式)。
- 总计:~250ns × 4 次 = 1μs。
虽然 1μs 比 180ms 小很多,但如果是高并发场景(每秒 100 万个文件操作),上下文切换的开销就会变成 100% CPU 占用。
6. 传统 IO 的"双重打击"总结
第一重打击(显式):CPU 拷贝数据 → 消耗计算带宽,延迟高。
第二重打击(隐式):上下文切换 → 污染 TLB,增加内存延迟。
这就是为什么零拷贝技术(sendfile/splice)在现代高性能 IO 中成为标配 —— 它们把 ② ③ 两步 CPU 拷贝从数据路径上移除,并减少系统调用次数。
7. 笔记存档:传统 IO 开销清单(面试/复习速查)
| 开销项 | 触发次数(1 次 read + 1 次 write) | 硬件单元 | 能否消除? |
|---|---|---|---|
| DMA 磁盘→主存 | 1 次 | 磁盘控制器 | 必须,无法消除 |
| CPU 内核→用户 | 1 次 | CPU + 内存控制器 | 可消除 (零拷贝) |
| CPU 用户→内核 | 1 次 | CPU + 内存控制器 | 可消除 (零拷贝) |
| DMA 主存→网卡 | 1 次 | 网卡控制器 | 必须,无法消除 |
| 用户态↔内核态切换 | 4 次 | CPU + MMU | 可减少(合并系统调用) |
| TLB 刷新 | 4 次 | MMU | 可减少(使用大页) |