| 本篇位置 | 持久化的第一篇。先讲"数据怎么在 CPU 和设备之间来回",第 25 篇起讲文件系统 |
| 运行环境 | 容器里的 Linux,Python 3 |
一、CPU 和外设不在一个时间尺度上
一条指令:不到 1 纳秒。 一次内存访问:几十到一百纳秒。 一次 SSD 读:几十微秒。 一次机械硬盘寻道:几毫秒。
⭐ 从 CPU 到机械硬盘,差了七个数量级。
打个直观的比方:如果一条指令是 1 秒,那一次磁盘寻道就是四个月。
所以"CPU 怎么等设备"这件事,本身就是一个值得认真设计的问题——等的方式不对,CPU 的绝大部分能力就浪费掉了。
打个比方
你点了个外卖,然后要接着写代码。
做法一:每隔十秒放下手里的活,打电话问"到了吗?"——轮询。 做法二:接着写代码,骑手到了按门铃——中断。 做法三:骑手直接把餐放进门口的取餐柜,柜子满了才提醒你——DMA。
⭐ 这三步就是 I/O 设计史的三次减负,而且每一步减掉的东西不一样:中断减掉的是"一直问",DMA 减掉的是"亲自搬"。
二、第一次减负:从轮询到中断
轮询(polling):CPU 在一个循环里反复读设备的状态寄存器,直到它说"我好了"。
while (设备状态 == 忙) ; /* 什么也不干,就是转 */
写起来极简单,而且延迟最低——设备一好你立刻知道。
问题也明显:这段时间 CPU 什么都干不了。 一次磁盘 I/O 几毫秒,够执行几百万条指令——全烧在这个循环里。
⭐ 这和第 17 篇的自旋锁、第 19 篇的忙等是同一个形状:等待的时候要不要占着 CPU。第 19 篇实测过,在单核上这个选择的代价是 440 倍。
中断(interrupt)的做法是:
- CPU 向设备发出请求,然后把当前进程置为阻塞态(第 2 篇那条边),去跑别的进程。
- 设备干完活,发一个中断信号。
- 硬件强行把 CPU 拉进内核的中断处理程序(和第 4 篇的时钟中断走同一条路)。
- 内核把那个进程标记为就绪,等调度器安排。
⚠️ 但中断不是永远更好。如果设备非常快,中断反而是负担——处理一次中断要保存现场、进内核、恢复现场,而设备可能在你还没处理完的时候就又好了。
所以现代高速设备用的是混合策略:先轮询一小会儿(也许马上就好了),还不好再挂起。和第 17 篇 pthread_mutex 的"先自旋一会儿再挂起"是同一个折中。
⭐ 极端情况下甚至转回纯轮询:高速网卡在大流量下会关掉中断,改用 NAPI 主动去收包;NVMe 盘的轮询模式也是同理。因为中断处理本身的开销,已经超过了等待的时间。
三、第二次减负:DMA
中断解决了"不用一直问",但还有一件事:数据谁来搬?
早期是 CPU 亲自搬——编程式 I/O(PIO):CPU 执行一条条指令,把数据从设备寄存器一个字一个字读进内存。搬 4 KB 就是上千条指令。CPU 变成了搬运工。
DMA(直接内存访问)加了一个专门的硬件搬运工:
- CPU 告诉 DMA 控制器:“把设备的数据搬到内存的这个地址,搬这么多。”
- CPU 走人,去干别的。
- DMA 控制器自己完成搬运,搬完了才发一个中断。
⭐ 这就是"取餐柜":骑手直接放进柜子,你完全不用出现在这个过程里,柜子满了才叫你一次。
⚠️ 这里有个和第二部分呼应的细节:DMA 控制器用的是物理地址。它不经过 MMU,不认识虚拟地址。所以内核给设备的缓冲区必须"钉住"(pin,不许被换出、不许被移动),还得把虚拟地址翻译成物理地址交给它。
第 12 篇说分页"放弃了物理连续性",这就是要还的账之一。 现代系统用 IOMMU 给设备也加了一层地址翻译,顺便还能限制设备只能访问指定的内存——否则一块恶意的外设可以直接读写整个物理内存。
四、跑一遍看看:每次操作的固定成本
上面三次减负,减的都是"每次操作的固定开销"。这个开销到底多大?
同一个 64 MB 的文件,只改每次 read 的块大小:
def syscalls_and_time(bs):
fd = os.open(PATH, os.O_RDONLY)
n, t0 = 0, time.time()
while os.read(fd, bs):
n += 1
dt = time.time() - t0
os.close(fd)
return n, dt
同一个 64 MB 的文件,只改每次 read 的块大小:
块大小 系统调用次数 用时 吞吐 每次调用
1 B 67,108,864 17.23s 4 MB/s 257 ns
64 B 1,048,576 0.28s 228 MB/s 268 ns
512 B 131,072 0.04s 1716 MB/s 285 ns
4 KB 16,384 0.01s 9497 MB/s 411 ns
64 KB 1,024 0.00s 21650 MB/s 2887 ns
1024 KB 64 0.00s 42766 ns
读同样的 64 MB,1 字节一次要 17.23 秒,1 MB 一次快到测不出来。
看最后一列——每次系统调用的成本:
- 1 字节:257 纳秒
- 64 字节:268 纳秒
- 512 字节:285 纳秒
读的数据多了 512 倍,每次调用的成本几乎没变。 这 260 纳秒和你读多少字节无关,它是过门的固定收费(第 4 篇讲的那条隧道)。
到 4 KB 以后成本才开始随大小上涨——这时候真正的数据拷贝才成了主要开销。
⭐ 这张表是一条通用规律的照片:只要一个操作有固定开销,批量就一定划算。 这门课里已经见过好几次同样的形状:
- 第 18 篇的近似计数器——攒够 1024 次才更新一次全局值。
- 第 22 篇的 io_uring——一次系统调用提交几百个请求。
- 本篇的 DMA——搬完一整块才中断一次。
⚠️ 顺带说明一件事:上面的数字全部来自页缓存(第 15 篇),根本没碰磁盘。所以这里量的是软件路径的开销,不是磁盘的速度。磁盘本身的账在下一篇。
五、设备怎么接进操作系统
一个操作系统要支持成千上万种设备,不可能每种都单独写一遍内核。所以有分层:
应用程序
↓ open / read / write / ioctl ← 统一的接口
文件系统 / 块层 / 网络协议栈 ← 通用逻辑
↓
设备驱动 ← 每种设备一份
↓ 读写寄存器 / 收中断 / 配置 DMA
硬件
⭐ 关键在中间那层通用逻辑:它对上提供统一接口,对下只要求驱动实现几个固定的函数(读一个块、写一个块……)。换一块盘,上面所有代码一行不用改。
这是"加一层中间人"在这门课里的第 N 次出现。而这一层的代价很具体:
⚠️ 驱动跑在内核态,和内核共享地址空间。 一个有 bug 的驱动能搞崩整个系统。而驱动占了 Linux 内核代码的一半以上,还是由设备厂商写的、质量参差不齐的那一半。很长一段时间里,Windows 蓝屏的主要来源就是第三方驱动。
应对办法有两条:把驱动挪到用户态(微内核、FUSE、DPDK/SPDK),代价是每次 I/O 多几次上下文切换;或者用 IOMMU 把设备本身也关进笼子里。
六、代价与取舍
这三次减负换来了什么: CPU 从"守着设备"里彻底解放。今天一台服务器可以同时跑几百个 I/O 密集的进程,CPU 使用率还很低——这在轮询加 PIO 的年代不可想象。
它花了什么:
- 每一次 I/O 的固定开销:系统调用、中断处理、DMA 设置。上面实测约 260 纳秒起步。所以小 I/O 极其不划算。
- 复杂度:中断处理程序不能睡、不能太慢,于是 Linux 把它拆成上半部(快速响应)和下半部(延后处理);DMA 要处理缓存一致性(设备写了内存,CPU 缓存里的旧数据得作废)。
它放弃了什么: 延迟的确定性。 中断什么时候来、调度器什么时候让你跑,都不确定。⭐ 所以硬实时系统里反而常常回到轮询——它慢,但它可预测。
七、小结
- CPU 和外设差七个数量级(一条指令 vs 一次寻道 ≈ 1 秒 vs 4 个月)。怎么等,本身就是设计。
- 轮询延迟最低、写法最简单,但烧 CPU;中断让 CPU 去干别的,代价是每次要保存现场进内核。
- ⭐ 设备快到一定程度,中断反而是负担——高速网卡和 NVMe 在高负载下会转回轮询。实践中普遍用"先轮询一会儿再挂起",和第 17 篇互斥量的折中一模一样。
- DMA 让专门的硬件搬数据,CPU 只管下命令和收结果。⚠️ 它用物理地址,所以缓冲区要钉住、要翻译——第 12 篇"放弃物理连续性"要还的账之一。IOMMU 给设备也加了一层翻译和限制。
- ⭐ 实测:同一个 64 MB 文件,1 字节一次读要 17.23 秒,1 MB 一次读快到测不出。而每次系统调用的固定成本约 260 纳秒,和读多少字节无关。只要有固定开销,批量就一定划算——近似计数器、io_uring、DMA 都是这条。
- 设备通过驱动 + 通用块层接进系统,换一块盘上层不用改。⚠️ 代价是驱动跑在内核态,一个 bug 搞崩全系统,而驱动占内核代码一半以上。
- ⭐ 放弃的是延迟的确定性——所以硬实时系统反而回到轮询。
思考题
- 第四节那张表里,从 4 KB 开始每次调用的成本才明显上涨。为什么恰好是 4 KB?(提示:第 12 篇。)
- 中断处理程序不能睡也不能太慢。那么,如果一次中断需要做一件很耗时的事(比如解析一个网络包并唤醒进程),Linux 怎么办?
- DMA 完成后 CPU 缓存里可能还留着那块内存的旧数据。硬件和软件各有什么办法解决?哪种更常见?
- 外卖那个比方里,“中断合并”(interrupt coalescing,攒几个包再发一次中断)对应什么?它改善了什么,牺牲了什么?