你点下“保存”以后,编辑器很快就把按钮变成了已保存。可这短短一瞬间里,数据可能先从用户空间复制到内核缓冲区,再进入文件系统和块设备队列,随后由驱动把命令交给存储控制器。设备完成传输后,还要通知内核,内核再唤醒正在等待的线程。屏幕、键盘、网卡、声卡也在走各自的路径,而且速度、传输单位和出错方式完全不同。
I/O 系统真正难的地方,不是“把数据搬过去”这一个动作,而是让几类矛盾同时得到控制:CPU 很快,外设有快有慢;应用希望接口简单,硬件协议却各不相同;缓存和批处理追求吞吐量,交互程序又在乎眼前这一次请求的延迟;设备需要直接访问内存才能高效传输,系统又不能放弃隔离。操作系统的办法是一层层拆开问题,让每一层只暴露稳定的约定,并把等待、排队、保护和错误恢复集中起来处理。

I/O 分层用统一接口屏蔽设备差异,请求逐层下行,完成通知逐层返回。
“I/O 已返回”并不总等于“数据已经永久落到设备”。普通写操作可能只把数据交给内核缓存;如果程序要求越过断电仍能保留,就还要使用相应的同步或持久化语义。讨论 I/O 时,必须先说清楚完成的是哪一层。
假设播放器需要读取下一段视频。它拿着文件描述符发起读取,处理器从用户态进入内核态。内核先检查参数、访问权限和文件当前位置,再问页缓存里有没有目标数据。命中时,事情可能只剩一次内存复制;未命中时,文件系统要把文件偏移换算成存储位置,把请求交给块 I/O 层。块层可以合并、拆分或排队,驱动再把通用请求翻译成控制器认识的命令。
控制器获得描述符和缓冲区信息后,设备开始工作。大块数据通常不由 CPU 一字节一字节搬运,而是由设备的 DMA 能力在设备和内存之间传输。设备完成后产生中断,或者被内核在轮询过程中发现。内核确认状态、记录错误、结束请求,等待中的线程才有机会重新进入就绪队列。线程真正再次运行,还要等调度器分配 CPU。
这条链路里有三个容易混淆的“完成”:设备完成了命令,内核完成了请求,应用的系统调用返回。三者常常前后相接,却不是同一个时刻。异步接口甚至会让“提交请求”和“取得完成结果”成为两次独立动作。
应用层希望面对的是 read、write、内存映射或套接字,而不是某块网卡的寄存器布局。设备驱动希望获得内核统一的队列、内存和中断服务,而不必自己实现一套进程调度。控制器只执行协议定义的命令,不需要理解文件名。分层使新设备可以通过新驱动接入,也使同一种应用接口能够落到不同硬件上。
代价也很实际:请求要经过更多函数和数据结构,层与层之间可能复制数据、转换格式、排队和加锁。高性能 I/O 的许多优化,本质上都是在不破坏隔离与通用性的前提下,缩短这条路径。
内核必须记住设备是否可用、有哪些请求正在等待、哪个请求已经交给硬件、完成后应该唤醒谁。真实系统不会只维护一张简单表,而是用设备对象、队列、请求描述符、完成记录和错误状态共同表示这些信息。对应用来说只是“一次读取”,对内核来说却是一个有明确生命周期的对象。
外设不会直接理解高级系统调用。它通过端口接入总线,再由设备控制器处理具体协议。USB 控制器要管理多个端点,存储控制器要维护命令队列,网卡可能拥有多组收发队列。控制器本身常带有处理逻辑和片上缓冲区,它不是一根被动的数据线。
总线提供寻址和传输规则。PCIe 适合连接高性能设备,USB 强调通用连接和热插拔,片上系统还会有直接连接处理器互连的外设。不同总线的细节不同,但操作系统看到的核心资源通常可以归为几类:设备寄存器地址、中断资源、DMA 能力和设备标识。
主机和控制器通常围绕几类寄存器协作:

内存映射 I/O 把设备寄存器纳入地址空间,使 CPU 能用读写内存的方式控制设备。
有些体系结构提供专门的 I/O 端口和读写指令;更常见的做法是把设备寄存器映射进处理器地址空间,也就是内存映射 I/O。驱动看起来是在读写地址,但这些访问落到控制器,而不是普通内存。
“像访问内存一样”只是在指令形式上相似。设备寄存器可能有读清零、写一清零等副作用,也不一定接受缓存、乱序或合并。驱动需要使用内核提供的设备访问函数和必要的屏障,确保“先写描述符,再敲门通知设备”这样的顺序真的被硬件观察到。为了追求几条指令的速度而绕过这些规则,可能得到一个平时正常、压力一大就偶发丢请求的驱动。
不要把普通指针读写直接套到设备寄存器上。编译器、CPU、互连和设备都可能改变观察顺序;驱动必须按平台规定访问 I/O 空间。这里解决的不是代码风格问题,而是硬件可见性的正确性。
把设备只分成“输入”和“输出”太粗了。内核更关心它一次传一个字节还是一批块,能否随机定位,能否被多个进程共享,是按请求响应还是持续产生数据,错误能否重试,以及延迟和吞吐量处在什么量级。
CPU 向设备下达命令以后,最直接的问题是:什么时候回来收结果?这不是一道只有一个标准答案的选择题。设备速度、请求频率、延迟目标和 CPU 是否有别的工作,都会改变答案。

轮询消耗 CPU 等待,中断改由设备通知,DMA 则让大块数据绕过 CPU 搬运直接进出内存。
轮询就是处理器反复读取状态,直到设备就绪。它的好处是路径短、控制直观,不需要等待中断分发。在等待时间极短、事件持续高速到来,或者系统处于启动早期还没有完整中断环境时,轮询很实用。
它的代价是 CPU 周期会花在“还没好”的检查上。对一个可能几百毫秒才产生输入的键盘不停轮询,显然不合算。轮询还会带来总线和缓存压力,因此不是频率越高越好。
中断把等待方向反过来。CPU 先去运行别的任务,设备需要服务时向中断控制器发出请求。处理器进入对应的处理入口,内核保存必要状态,识别来源,处理最紧急的部分,再把较长工作延后到可调度的上下文中完成。
中断避免了长时间空等,却不是免费的。每次中断都有进入、确认、状态处理和退出成本。高速网卡如果每收到一个小包就打断一次 CPU,处理器可能忙于进出中断而没有时间处理真正的数据。因此设备和内核会合并通知:一次中断后成批处理多个完成项,负载高时甚至暂时屏蔽中断并进入限额轮询,队列变空后再恢复中断。
中断还要处理优先级、屏蔽、共享和并发。严重硬件事件可能走不可屏蔽的路径,普通设备中断则可以暂缓。处理程序必须尽快确认设备,避免同一事件不断触发;共享中断场景还要判断这次请求是否真的来自自己管理的设备。
中断解决“何时通知”,DMA 解决“谁来搬大块数据”。CPU 或驱动准备缓冲区和描述符,把设备可用的 DMA 地址、方向和长度交给控制器。设备随后在总线上直接读写内存,CPU 可以执行别的代码。传输完成后,设备通常再通过中断或完成队列通知内核。
DMA 地址不一定就是 CPU 看到的物理地址。系统可能通过 IOMMU 为设备建立单独的地址映射,让设备只能访问被授权的页。这样既能适配设备寻址能力,也能限制故障或恶意设备越界访问。驱动还要考虑页是否可用于 DMA、缓存是否一致、传输方向以及设备何时不再使用这块内存。
DMA 并不意味着 CPU 完全零成本。CPU 仍要建立请求、维护描述符、处理完成和错误,还可能进行缓存同步。它省掉的是大批数据逐字节经过 CPU 的过程。
调整设备耗时和请求数量,再切换轮询、中断、DMA。这个实验不是硬件性能测量,而是帮助你观察三种机制把成本放在了哪里。
硬件千差万别,应用接口却应该尽量稳定。类 UNIX 系统常把已打开的对象表示成文件描述符。普通文件、终端、管道和套接字都可以使用读写接口,但相同函数名背后会分派到不同实现。统一接口减少了应用代码里的硬件分支,也给权限检查和资源回收提供了共同入口。
块设备面向可寻址的数据块,存储设备是典型例子。文件系统通常建立在块设备之上,数据库等软件有时也会使用直接 I/O 来减少页缓存带来的重复管理。直接 I/O 不等于完全绕过内核,它仍要经过权限、对齐、映射和驱动路径,而且常对缓冲区地址、长度和文件偏移有更严格的要求。
字符设备更像连续字节流,例如终端或串口。它通常没有“跳到第 5000 个字符”这样的随机定位语义。网络报文又有所不同:套接字接口既要表达连接或地址,也可能保留数据报边界。设备类别提供共同约定,但不能抹掉所有语义差异。
ioctl:统一接口留出的专用通道read 和 write 适合传数据,却无法自然表达“设置串口波特率”“查询显示模式”这类设备控制操作。ioctl 提供了可扩展的控制通道:调用者给出描述符、命令号以及与命令匹配的数据。它让新功能不必为每个设备增加一种系统调用,代价是接口更依赖设备和驱动,结构布局、长度校验与兼容性都必须格外谨慎。
内存映射把文件的一段内容关联到进程虚拟地址区间。程序像访问数组一样读写,页面第一次被触及时,缺页处理再把相应内容调入。映射可以减少显式系统调用,也方便多个进程共享文件页,但它并没有让磁盘延迟消失:缺页可能突然阻塞当前指令,写回时机和可见性也要按映射类型与同步操作判断。
协议头、正文和校验信息常在不同内存缓冲区里。如果逐段调用 write,程序会多次进入内核。矢量 I/O 允许一次提交多个缓冲区:读取时把输入分散到多个区域,写入时把多个区域按顺序聚集输出。它主要减少系统调用和不必要的拼接复制,但成功返回的字节数仍可能小于请求总量,程序必须处理短读和短写。
这四个概念经常被揉成一团,我们把它们拆开:
select、poll 或同类机制,等待其中一部分变得可读或可写。就绪表示相应操作可以取得进展,不保证任意大小的请求都一次完成。
异步 I/O 让应用计算与设备传输重叠,完成事件进入队列后再唤醒线程或触发回调。
点击不同模式,再按“推进一步”。观察线程是在系统调用里等待、拿到未就绪结果、等待就绪集合,还是把请求交给完成队列。
有些 I/O 路径会把转换、过滤和协议处理拆成可插拔模块。传统 UNIX STREAMS 用流头、若干处理模块和驱动端组成双向通路,每层都有队列并能向上游施加流量控制。现代系统未必继续使用这套具体框架,但“把流处理拆成模块,并在队列边界控制背压”的设计仍然常见。
网络可能以不均匀的节奏到达,磁盘偏好成批写入,程序又希望写调用尽快返回。如果让每一端严格同步,快的一方会大量空等。内核用内存和队列把时间上的耦合拆开,让生产者与消费者不必在每个字节上步调一致。

缓冲协调速度差异,缓存保存可复用副本,假脱机把独占设备请求排成队列。
缓冲区暂存正在传递的数据,主要解决三类问题。第一,生产和消费速率短时间不一致时,缓冲吸收波动。第二,两端偏好的块大小不同,缓冲可以聚合小块或拆分大块。第三,系统调用返回后,应用可能马上修改原缓冲区;内核复制一份可以固定这次操作应看到的内容。
双缓冲允许一块被设备使用时,另一块由应用或另一设备处理。环形缓冲则让生产者和消费者沿固定空间循环推进。缓冲不是无限仓库:长期平均输入速率大于输出速率时,再大的缓冲也只会把溢出推迟。系统最终必须阻塞生产者、丢弃数据、扩容或向上游施加背压。
缓存保存已有数据的副本,用更快的介质换取后续命中时的低延迟。文件页缓存就是典型例子:重复读取可能直接从内存返回,写入也可能先在内存形成脏页,稍后再写回设备。
缓冲强调“传递过程中的临时落脚点”,缓存强调“为了复用而留下副本”。同一片内存可以同时承担两种角色,所以两者不能只靠物理位置区分。缓存的代价包括占用内存、失效判断和一致性管理。延迟写回提高吞吐量,却扩大了突然断电时尚未持久化的数据窗口。
打印机一次只能稳定处理一个作业。如果每个程序都直接占用打印机,慢程序可能长期抓住设备,多个作业也容易交错。假脱机先把完整作业写入磁盘队列,再由后台服务按策略逐个发送。应用面对的是一个可排队的逻辑服务,物理设备仍然保持串行使用。
假脱机解决的是设备共享和作业边界,不只是“多放一个缓冲区”。它需要磁盘空间、队列管理、权限检查以及取消和重试机制。类似思想也用于批处理和离线任务。
调节生产速率、消费速率和容量,连续推进几步。你会看到短时波动可以被吸收,但只要生产长期快于消费,队列迟早满;容量增大会降低短时溢出,却也可能让排队延迟更高。
同一设备常同时收到许多请求。内核需要决定先服务谁:可以按到达顺序维持简单公平,也可以合并相邻请求、优先处理交互任务,或按截止时间限制最坏等待。机械磁盘曾强烈依赖减少磁头移动;固态设备没有机械寻道,却仍受内部并行度、写放大、队列深度和服务质量影响。调度目标随硬件变化,不能把某一种算法当成永远最佳。
排队能让设备保持繁忙并提高吞吐量,但队列越深,请求等待的时间通常越长。对数据库批处理,较深队列可能有利;对交互操作,排队延迟可能比峰值带宽更重要。这就是 I/O 优化里反复出现的吞吐量与延迟矛盾。
I/O 会遇到临时拥塞、校验失败、介质错误、设备断开和控制器失去响应。驱动要把硬件状态转换成内核能理解的结果,再由系统调用以返回值和错误码告诉应用。短暂错误可以重试,但重试必须有次数与时间上限;否则一个消失的设备会让请求永久挂住。
取消也比看起来难。请求可能还在软件队列里,也可能已经交给硬件,甚至数据已经写入但完成通知尚未处理。取消接口通常只能承诺“尽力停止”或等待正在执行的部分结束。应用不能把发出取消等同于设备从未做过这件事。
某些设备或操作需要独占,内核会通过打开规则、租约、锁或专用队列仲裁。与此同时,空闲设备可以进入低功耗状态。省电会带来唤醒延迟,频繁休眠和唤醒还可能得不偿失,所以系统根据负载和延迟要求选择状态。热插拔则要求驱动在请求仍可能存在时安全地停止新流量、回收旧请求并解除设备绑定。
普通进程如果能任意写设备控制寄存器,就可能改写磁盘、读取别人的网络包,甚至让设备 DMA 到内核代码区。因此,设备寄存器和 I/O 端口通常只映射给内核或经过授权的运行环境,应用通过系统调用请求服务。内核在入口处检查描述符、地址范围、长度、访问方向和权限。
DMA 把风险扩大到“设备能够访问哪些内存”。IOMMU 可以为设备建立受限映射,驱动只在请求期间开放必要页,结束后撤销。虚拟机把设备直通给客户系统时,更要同时处理设备隔离、中断重映射和 DMA 边界。没有这些约束,一次错误描述符就可能破坏完全无关的进程。
在用户缓冲区和内核缓冲区之间复制,成本不小,却提供了清晰的所有权:系统调用返回后,内核持有自己的数据,应用可以自由修改原内存。想减少复制,就必须用别的方式固定所有权。例如固定用户页、共享映射,或者让数据直接在页缓存与设备之间流动。
所谓零拷贝通常是“减少某一段 CPU 内存复制”,不代表整个系统没有数据移动。它会增加页固定时间、引用计数、映射和完成同步的复杂度。小数据或低频操作里,复杂管理成本可能比一次复制还高。
多个线程共享文件描述符、套接字或映射区时,必须区分数据本身是否并发安全、文件偏移是否共享、一次写入是否保证原子,以及完成通知会落到哪个线程。驱动内部也要防止提交路径、中断路径和取消路径同时修改同一个请求。
内存屏障、锁、原子变量和队列所有权解决的是不同问题。锁能保护数据结构,却不自动保证设备已经观察到寄存器写入;设备访问屏障能约束硬件顺序,却不替代线程之间的互斥。把这些机制混用或漏用,往往会制造极难复现的并发故障。
现在把前面的机制放回一次缓存未命中的读取中。下面的步骤不是某个操作系统的唯一实现,而是一条典型路径。
应用提交文件描述符、目标缓冲区和长度。内核验证描述符对应的对象、访问权限,以及用户地址是否能够安全写入。
文件层根据当前位置和请求长度确定数据范围。页缓存未命中时,文件系统把逻辑文件范围转换为底层存储请求。
I/O 层建立请求对象,可能拆分过大请求、合并相邻请求,并把它放入设备队列。同步调用的线程此时可以睡眠;异步调用则保留完成目标后返回。
驱动准备设备描述符和 DMA 映射,按规定顺序写入队列信息,再通知控制器有新命令。设备开始把数据传到获准访问的内存页。
这条链路解释了为什么一次“偶尔很慢”的读取不一定是磁盘本身突然变慢:它可能在软件队列里等了很久,可能碰到缺页,可能等待设备从省电状态唤醒,也可能因临时错误重试。只看平均带宽,很难判断尾延迟来自哪一层。
I/O 性能至少要同时看延迟、吞吐量、每秒操作数、CPU 开销和尾延迟。带宽适合描述连续大块传输;每秒操作数更能反映大量小请求;平均延迟会掩盖少数特别慢的请求,因此交互系统还会关注较高百分位的完成时间。

I/O 优化是多目标平衡,没有所有指标同时越大越好的方案,必须结合工作负载取舍。
一个新 I/O 功能放在哪里,取决于变化速度、性能要求和故障半径。用户空间实现容易调试和更新,出错通常不会拖垮整个内核,但要付出边界切换和数据传递成本。放入内核能直接使用调度、缓存和驱动设施,路径更短,错误后果却更严重。固化进控制器速度可能最高,但升级和调整策略最困难。
高性能用户空间 I/O 框架会把部分队列映射给进程,让应用直接批量提交和收割完成,同时保留 IOMMU、内存注册和权限边界。这不是简单地“绕过操作系统”,而是重新划分控制面与数据面的责任。
先问请求是在等待还是在传输,再看等待发生在应用队列、内核队列还是设备内部。接着区分数据块大小和访问模式,检查缓存命中、队列深度、中断率、CPU 利用和错误重试。最后才决定要不要增加缓冲、合并请求或减少复制。只看设备标称速度就改参数,常常会把瓶颈从一层推到另一层。
优化 I/O 没有免费的单向按钮。更深的队列、更大的缓冲和更积极的批处理都可能提高吞吐量,同时放大延迟;更频繁的轮询可能降低延迟,同时消耗一个完整 CPU 核心。先明确目标,再选择代价。
某程序异步读取 64 个数据块。设备标称带宽很高,但用户仍偶尔感到明显卡顿。监控发现平均完成时间很低,高百分位延迟却很高;卡顿发生时队列很深,设备刚从低功耗状态唤醒,还出现过一次可恢复错误。
请按“排队、设备状态、错误恢复、完成通知”四个环节解释这次卡顿,并提出两项有明确代价的调整。
走到这里,再看“保存文件”这件小事,你应该能把它拆成提交、排队、传输和完成四段。I/O 系统没有让硬件差异消失,它做的是把差异关进合适的层次,再用缓冲、通知、保护和错误恢复让这些层次能够稳定协作。每一次看起来更快的优化,也都对应一笔需要明确承担的成本。
设备完成后产生中断,或被轮询发现。内核确认完成状态、解除 DMA 映射,处理校验错误与重试,并把请求标为完成。
数据进入页缓存或目标缓冲区。阻塞线程被唤醒,异步接口得到完成项。系统调用最终返回实际读取字节数,调用者继续处理短读、文件尾或错误。