跳转至

I/O 模型与设备驱动

I/O 设备速度和完成时机与 CPU 不同。操作系统必须让线程在等待时不浪费 CPU,同时处理成千上万个并发连接、设备错误和取消。I/O 模型描述应用怎样提交请求、等待就绪和获知完成;设备驱动则把通用内核请求翻译成具体硬件操作。

两个容易混淆的维度

阻塞与非阻塞

描述一次接口调用在当前条件不能完成时,调用线程是否睡眠等待。

  • 阻塞:调用可能挂起,条件满足后返回。
  • 非阻塞:立即返回“暂不可用”,调用者稍后重试。

同步与异步

描述完成通知和数据传输责任。

  • 同步 I/O:调用者在某个调用中等待或主动检查,并在返回时得到结果。
  • 异步 I/O:提交请求后继续工作,系统稍后通过完成队列、回调或事件报告结果。

非阻塞不自动等于异步。一个循环反复调用非阻塞 read,仍是调用者主动执行读取。

阻塞 I/O

read
  -> 数据未就绪
  -> 当前线程睡眠
  -> 设备/网络事件到达
  -> 内核唤醒线程
  -> read 复制数据并返回

优点是控制流直观,适合每连接一个线程或并发量有限的程序。代价是大量线程带来栈内存、调度和同步开销。

阻塞不是低性能同义词。成熟线程池配合合理并发度,在许多工作负载上简单且高效。

非阻塞与忙轮询

非阻塞 fd 在当前无数据时立即返回。若应用无间隔重试:

for (;;) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0) consume(buf, n);
}

会占满 CPU。忙轮询只有在极低延迟、CPU 可专用且事件很快到达时可能合理。一般应配合就绪通知或退避。

I/O 多路复用

一个线程等待多个 fd 的就绪事件:

注册一组兴趣事件
  -> 内核等待任一对象就绪
  -> 返回就绪集合
  -> 应用对就绪对象执行非阻塞操作

select / poll

接口直观,但每次调用可能要传入并扫描整组 fd。对象数量大而活跃比例低时,重复扫描成本明显。

epoll / kqueue 类机制

把兴趣集合保留在内核,返回发生变化或已经就绪的对象,减少每轮传输和全量扫描。具体触发语义不同,应仔细理解水平触发、边缘触发和一次性通知。

边缘触发通常要求把非阻塞 fd 读到“暂不可用”为止,否则不会因仍有旧数据再次得到新边缘通知。

就绪与完成

就绪模型告诉应用“现在执行某操作大概率不会阻塞”,实际数据操作仍由应用调用。

完成模型则提交具体请求,系统返回“这次请求已经完成”:

readiness: fd 可读 -> 应用调用 read
completion: 提交 read -> 收到 read 完成及结果

两者都可实现高并发事件循环,但资源生命周期、取消和背压方式不同。

异步 I/O

典型流程:

  1. 分配在请求期间保持有效的缓冲区和上下文。
  2. 提交一个或一批 I/O。
  3. 执行其他工作。
  4. 从完成队列取得结果。
  5. 处理短操作、错误、取消和重试。

异步减少等待线程数量,并能批量提交;代价是控制流分离、对象生命周期复杂,错误不再在原调用栈上直接出现。

语言的 async/await 改善代码表达,但底层可能是就绪事件循环、内核异步接口或线程池代理。语法异步不等于设备路径一定原生异步。

缓冲 I/O 与直接 I/O

缓冲 I/O

经过页缓存,重复读取快,写入可合并,内核可预读和回写。代价是缓存占用、复制和持久化时机分离。

直接 I/O

尝试绕过普通页缓存,常用于数据库自行管理缓存。它可能要求地址、长度和偏移满足对齐,并仍经过块层、驱动和 DMA。

直接 I/O 不保证一定更快:小随机请求、重复读取或不成熟应用缓存可能表现更差。混合缓冲与直接访问同一文件还需注意一致性。

零拷贝的动机

传统数据转发可能从内核缓存复制到用户缓冲,再复制回内核网络缓冲。sendfile、splice 类接口或内存映射可以减少 CPU 复制和上下文往返。

“零拷贝”通常表示减少某些软件数据复制,不表示:

  • 完全没有 DMA。
  • 没有描述符和元数据操作。
  • 数据从未经过缓存。
  • 所有平台路径都相同。

设备驱动的角色

驱动连接通用内核接口和具体设备协议:

通用子系统请求
  -> 驱动验证并构造描述符
  -> 写设备寄存器或提交队列
  -> 设备执行
  -> 中断或轮询完成
  -> 驱动回收描述符并通知上层

驱动通常负责:

  • 设备探测、初始化和电源状态。
  • 配置寄存器与 DMA 队列。
  • 中断处理和完成回收。
  • 超时、重置与错误恢复。
  • 与内核设备模型和用户接口集成。

字符设备、块设备与网络设备

  • 字符设备提供字节流或设备特定操作,不强调随机块寻址。
  • 块设备支持按块随机访问,可承载文件系统和请求调度。
  • 网络设备按数据包收发,接入网络协议栈而非普通文件偏移模型。

统一为 fd 让应用可用 read/write 等接口访问许多对象,但设备语义仍不同,控制操作和错误码不能完全统一。

内存映射设备寄存器

CPU 可通过特定物理地址访问设备寄存器。驱动映射后必须使用体系结构和内核规定的 I/O 访问原语,处理:

  • 访问宽度与端序。
  • 禁止不当缓存。
  • 内存屏障和寄存器顺序。
  • 设备移除时的并发访问。

普通 volatile 指针不足以表达所有设备 I/O 顺序和架构要求。

错误与取消

I/O 请求可能短读、短写、超时、被信号中断、设备断开或部分完成。可靠代码不能假设一次调用处理全部缓冲。

while (done < length) {
    ssize_t n = write(fd, buffer + done, length - done);
    if (n > 0) done += (size_t)n;
    else handle_error_or_retry();
}

重试必须区分暂时错误与永久错误,并避免无界快速重试。异步取消还要处理竞态:取消请求发出时,I/O 可能已经完成但完成通知尚未消费。

背压

事件循环可以接收大量连接,却不能让下游无限积压。应为队列、每连接缓冲和未完成请求设置上限,并在压力高时:

  • 暂停读取。
  • 降低接受新工作速率。
  • 丢弃可丢数据或返回明确过载。
  • 将压力传给上游。

高并发能力不等于无限容量。

选择建议

场景 可优先考虑
少量连接、逻辑复杂 阻塞 I/O + 有界线程池
大量网络连接、每次工作短 非阻塞 + 多路复用
高队列深度存储请求 完成式异步 I/O
数据库自有缓存 经验证的直接 I/O
极低延迟专用核心 有控制的轮询/混合模式

最终选择应以目标平台、工作负载和尾延迟测量为依据。

自测

  1. 非阻塞 I/O 与异步 I/O 有何区别?
  2. 边缘触发模式为什么常要求读到暂不可用?
  3. 直接 I/O 绕过了什么,没有绕过什么?
  4. 驱动为何不能只用普通内存读写替代 I/O 原语?
  5. 高并发服务器为什么仍需要背压?
参考思路

非阻塞描述单次调用立即返回,异步描述提交后由系统报告完成。边缘只在状态变化时通知。直接 I/O 主要绕过页缓存,仍经过块层、驱动和设备。寄存器有特殊缓存与顺序语义。下游服务速率有限,无界队列只会耗尽内存并放大延迟。