IO 模型

阻塞 I/O

考虑到应用进程与内核的区别, 我们将函数 recvfrom 视为系统调用. 不管函数 recvfrom 如何实现, 一般都有一个从应用进程中运行到内核中运行的切换, 一段时间后再跟着一个返回到应用进程的切换.

如图, 进程调用 recvfrom, 此时系统调用直到数据报到达且拷贝到应用缓冲区, 或者出错才返回. 我们所说的进程阻塞的整段时间是指从调用 recvfrom 开始到它返回的这段时间, 当进程返回成功指示时, 应用进程开始处理数据报.

非阻塞 I/O

当我们把一个套接字设置成非阻塞方式时, 即通知内核: 当请求的 I/O 操作必须让进程睡眠, 不能完成, 这是不要让进程睡眠, 而应返回一个错误. 如下图所示, 前三次调用 recvfrom 时都没有数据返回, 因此内核会立刻返回一个 EWOULDBLOCK 错误. 第四次调用 recvfrom 时, 数据报已准备好, 被拷贝到应用缓冲区, recvfrom 返回成功指示, 接着我们就可以处理数据了.

当一个应用进程像这个样对一个非阻塞描述符循环调用 recvfrom 时, 我们称此过程为轮询 (polling). 应用进程连续不断的查询内核, 看看某个操作是否准备好, 这对 CPU 时间来说是极大的浪费. 这种模型只是偶尔才遇到, 一般是在专门提供某种苟能的系统中才有.

I/O 复用

假设一个 TCP 客户端同时在处理两个输入: 标准书输入和 TCP 套接字. 我们遇到了这么一个问题, 客户端正阻塞在标准输入上, 等待用户输入, 同时服务器进程又被杀死. 服务器 TCP 正确地给客户发了一个 FIN, 但客户端进程正阻塞于从标准输入读入. 它只能等待从标准输入读入完成之后才能从套接读数据时发现文件结束符 (这可能已经过了非常长的时间). 我们需要这样的功能: 如果一个或多个 I/O 条件满足 (比如, 输入已准备好被读, 或者描述符可以接受更多的输出) 时, 我们会得到通知, 这个能力被称为 I/O 复用.

I/O 复用的典型应用场景: 1. 当客户端处理多个描述符时 (一般是交互式输入和网络套接字), 必须使用 I/O 复用. 2. 一个客户端处理多个套接字是可能的, 但很少出现. 3. 如果一个 TCP 服务器纪要处理监听套接字, 又要处理已连接的套接字, 一般也要用到 I/O 复用. 4. 如果一个服务器既要处理 TCP 又要处理 UDP, 一般也要使用 I/O 复用. 5. 如果一个服务器要处理多个服务, 或者多个协议, 一般要使用 I/O 复用.

I/O 复用并非只限于网络编程, 许多其他的应用程序也需要使用这项技术.

有了 I/O 复用, 我们就可以调用 select 或 poll, 在这两个系统调用中的某一个上阻塞, 而不是阻塞于真正的 I/O 系统调用. 如下图, 我们阻塞于 select 调用, 等待数据报套接字可读, 当 select 返回套接字可读条件时, 我们调用 recvfrom 将数据拷贝到应用缓冲区中.

将上图与阻塞 I/O 模型的图进行比较, 似乎没有显示出优越性, 并且由于使用了系统调用 select, 要求两次系统调用而不是一次, 好像变得还有点差. 但是, 使用 select 的好处在于我们可以等待多个描述符准备好.

信号驱动 I/O (SIGIO)

我们也可以用信号, 让内核在描述符准备好时用信号 SIGIO 通知我们, 我们将此方法成为信号驱动 I/O, 如下图.

首先我们允许套接字进行信号驱动I/O, 并通过系统调用 sigaction 安装一个信号处理程序. 此系统调用立即返回, 进程继续工作, 它是非阻塞的. 当数据报准备好被读时, 就为该进程生成一个 SIGIO 信号. 我们随即可以在信号处理程序中调用 recvfrom 来读数据报, 并通知主循环数据已经准备好被处理, 也可以通知主循环来让它读数据报.

无论我们如何处理 SIGIO 信号, 这种模型的好处是当等待数据报到达时, 可以不阻塞. 主循环可以继续执行, 只是等待信号处理程序的通知, 或者数据已准备好被处理, 或者数据报已准备好被读.

异步 I/O (Posix.1 的 aio_* 系类函数)

异步 I/O 是 posix.1 的1993版本中的新内容 (“实时”扩展). 我们让内核启动操作, 并在整个操作完成后 (包括将数据从内核拷贝到我们自己的缓冲区) 通知我们. 这种模型与前一种型号驱动模型的主要区别在于: 信号驱动 I/O 是由内核通知我们何时可以启动一个 I/O 操作, 而异步 I/O 模型是由内核通知我们 I/O 操作何时完成.

我们调用函数 aio_read (posix 异步 I/O函数以 aio 或 lion 开头), 给内核传递描述符, 缓冲区指针, 缓冲区大小, 文件偏移, 并告诉内核当整个操作完成时如何通知我们. 此系统调用立即返回, 我们的进程不阻塞于等待 I/O 操作的完成. 在这个例子中, 我们假设要求内核在操作完成时生成一个信号, 此信号直到数据已拷贝到应用缓冲区才产生, 这一点是与信号驱动 I/O 模型不同的.

同步 I/O 与异步 I/O

posix.1 定义的两个术语如下: 1. 同步 I/O 操作引起请求进程阻塞, 直到 I/O 操作完成. 2. 异步 I/O 操作不引起请求进程阻塞.

根据上述定义, 我们的前四个模型——阻塞 I/O 模型, 非阻塞 I/O 模型, I/O 复用模型和信号驱动 I/O 模型都是同步 I/O 模型, 因为真正的 I/O 操作 (recvfrom) 阻塞进程, 只有异步 I/O 模型与 posix.1 中定义的异步 I/O 概念相符.

各种 I/O 模型的比较

下面是五种不同的 I/O 模型的比较, 可以看出, 前四种模型的主要区别都在于第一阶段, 他们的第二阶段基本相同, 在数据从内核拷贝到调用者缓冲区时, 进程阻塞于 recvfrom 调用. 然后, 异步 I/O 模型处理的两个阶段都不同于前四个模型.

在 windows 系统中, select 与 unix 中的类似, 属于 I/O 复用, WSAAsyncSelect 与 WSAEventSelect 和信号驱动模型类似, 而 重叠 I/O, 以及建立在重叠 I/O 之上的完成端口都属于异步 I/O 模型.

linux 上的 epoll 以及 freebsd 的 kqueue 理论上还是属于 I/O 复用模型, 但它们没有轮询, 而是使用了 callback 机制, 从而使得效率大大的提升. Linux Kernel 2.6 提供了对 AIO 的有限支持 —— 仅支持文件系统. libc 也能通过来线程来模拟 socket 的 AIO, 不过这对性能没意义. 总的来说 Linux 的 aio 还不成熟.

参考

文中使用的图做完之后就后悔了… 先这样吧, 下次一定要用矢量图, SVG 什么的… 现在完全看不清啊,还有不和谐的纯白底色, 魂淡… (╯‵□′)╯︵┻━┻

实在是无法忍受看不清的图片, 全都换成 svg 了, 这下整个人都舒服了. O(∩_∩)O 哈!

IO

Comments