I/O 模型

Overview

I/O 模型是 操作系统 抽象出来的概念, 方便操作系统统一的管理. 各种各样的模型之前到底有什么区别呢? 阻塞 (blocking) 和 同步 (synchronous) 是什么关系?

I/O 过程

简单来讲, I/O 的过程分为两个部分 (以读取为例):

  1. 等待数据就绪
  2. 把数据从内核拷贝到用户进程空间

用 socket 来举例, 第一个步骤就是等待网卡接收到数据, 当接收到数据, 处理器把数据拷贝到内核的缓冲区. 第二个步骤就是把数据从内核的缓冲区拷贝到用户程序的缓冲区.

第一个步骤还是很直观的, 但是为什么要有第二个步骤呢? 数据拷来拷去不是很没效率么? 这牵扯到处理器的特权级别了. 现代的处理器在运行时是分为不同的级别的. 低级别程序会受到一定的限制, 比如只能访问一部分内存, 只能执行特定的指令. 所以需要将内核的数据拷贝到用户进程能访问的空间去.

五种 I/O 模型

下面的讨论主要基于 UNIX 操作系统.

Blocking I/O

最常见的 I/O 模型就是阻塞型的. 以 socket 来举例, 当我们调用 recv(), send(), connect(), accept() 这四个方法时, 线程都会阻塞住. 也就是说从等待数据就绪到数据拷贝到用户进程都是阻塞的.

Nonblocking I/O

当我们把 socket 设置成 nonblocking 的, 这时候调用 recv(), send(), connect(), accept(), 如果没有数据会返回 EWOULDBLOCK 错误而不是阻塞住, 如果有数据那么就会把数据从内核拷贝到用户进程. 虽然叫 nonblocking 但是在把数据从内核拷贝到用户进程 (写数据是从用户进程到内核) 时还是会阻塞住.

I/O multiplexing

在这种模型下, 我们一个线程能 同时 处理多个 I/O. 还是以 socket 为例, 我们将一个 socket 的集合传给 select() 函数, 调用 select() 后线程会阻塞住, 当传入的集合中有一个 socket 有数据了, select() 就会返回, 然后通过相应的方法将数据读取, 同样的, 在读取数据时线程会被阻塞住. 传给 select() 的 socket 可以是 blocking 的. 因为阻塞与否影响的只是 recv(), send(), connect(), accept() 这四个函数. 但是最好是 nonblocking 的, 因为即使 select() 返回了, socket 也可能在调用那四个函数时被阻塞. (见 select with blocking and non-blocking socket)

select, poll, epoll

这三个函数是 I/O 多路复用模型常用的函数, 各自实现的功能差不多.

select 传入的文件描述符的集合的大小是有限制的, 通常是 1024. poll 和 epoll 没有限制 (主要的限制是你的硬件资源了). select 和 poll 都会随着传入的文件描述符的数量增多而效率变低, epoll 的效率只和活跃的文件描述符数量有关. epoll 使用消息驱动来通知用户进程数据就绪.

epoll 怎么实现的我还蛮好奇的, 为什么 epoll 通常情况下效率要高呢? 下面是我的猜测:

对于 处理器 来说, 当网卡有数据时会触发中断, 处理器执行操作系统预先设置的中断处理程序. 在中断处理程序中肯定是需要确定这个数据对应的文件描述符是被哪些线程关心的 (这个地方应该是可以优化的, 比如哈希一下啊, 搞个树啊, 肯定不是遍历一遍), 从而可以唤醒相应的阻塞住的线程. 这个过程对于这几个函数都是一样的, 所以影响这几个函数效率的地方在于它们是怎么找到数据就绪的文件描述符的, select 和 poll 是通过遍历一遍, 所以会比较慢. 而 epoll 不同的地方在于, 操作系统中断处理中如果发现这个文件描述符是被 epoll 关心的, 会通过链表之类的保存下来, 这样, epoll_wait (epoll 的使用分成几个函数, 这是其中一个) 只需要检查链表里有没有数据.

说白了还是因为 I/O 是操作系统抽象出来的概念, 底层在处理器上的处理方式是一样的 (中断), 所以 I/O 处理的效率就是看操作系统是怎么处理的. 当然了, 是没有在任何情景下都最优的模型的, 每种模型肯定都有它适合的场景的. 比如在有大规模活跃的文件描述符要处理的情况下, epoll 反而没 poll/select 效率高了.

Signal-Driven I/O

在这个模型里, 我们向操作系统注册 handler (通过 sigaction 系统调用), 当数据就绪时, handler 会被操作系统调用, 这时候我们再去调用相应的方法获取数据 (会阻塞住).

Asynchronous I/O

和 Signal-Driven 类似, 我们需要向操作系统注册一个 handler. 这个模型和 Signal-Driven 最大的不同是, 当操作系统调用我们的 handler 时, 数据已经从内核态拷贝到用户进程了 (显然我们在注册 handler 的时候需要让操作系统知道把数据拷贝到哪里).

Synchronous I/O versus Asynchronous I/O

POSIX (Portable Operating System Interface) 其实只定义了两种类型的 I/O:

所以, 上面提到的五种 I/O 模型, 只有 Asynchronous I/O 是属于异步的, 其他的都属于同步的, 因为他们在把数据从内核拷贝到用户进程时都会阻塞住.

举个例子

找资料过程中发现用钓鱼来类比上面的几个模型是很合适的. 因为钓鱼成两个步骤, 等鱼儿上钩 (数据就绪), 把鱼儿放进桶里 (把数据从内核拷贝到用户进程)

6.2 I/O Models @ UNIX Network Programming

poll vs select vs event-based

Linux IO模式及 select、poll、epoll详解