IO 模型是网络编程的一个基本话题, 在互联网发展的早期, 终端设备并不多, 基本的阻塞式 IO 即可满足要求, TCP/IP 的诞生在全球范围内建立起了统一的通信体系, 尤其是上世纪 90 年代初, Tim Berners-Lee 成功利用 Internet 实现了第一次 HTTP 的传输, 互联网进入了一个新的里程碑, 在此之后, 终端设备呈现几何级数式增长, 到了九十年代末便提出了著名的 The C10K Problem, 即在单机 Web Server 上实现 10000 并发, 在相同的硬件条件下, 如何提高并发连接数、实现高性能服务器是一个值得探索的问题, 从最初的单进程顺序处理到 fork 子进程处理再到多线程处理, 后来出现了诸如 select/poll/epoll/kqueue 等多种 IO 复用机制, 在这些 IO 多路复用机制上催生了如 Nginxlibeventlibev 等诸多优秀的项目, 从广义上讲, IO 模型并不仅仅限于网络 IO, 对于磁盘 IO 来说原理都是相同的, 本文我们以 UDP 通信为例(之所以选用 UDP 是因为协议简单, 可以省去论述协议本身的诸多细节)来分析 Unix 的五种 IO 模型, 这些 IO 模型对 Linux/Darwin/FreeBSD 等都是通用的

8.1 Blocking IO (阻塞式 IO)

与 TCP 不同, UDP 提供面向 Packet 的无连接、不可靠的服务, 一个典型的 UDP 通信过程是这样的

  1. 服务端调用 socket() 创建套接字, 调用 bind() 绑定套接字相关的配置信息(如 IP、Port 等)
  2. 服务端调用 recvfrom() 等待客户端发来的 Packet, 若没有 Packet 到来, 则 recvfrom() 将会持续阻塞
  3. 客户端调用 socket() 创建客户端套接字
  4. 客户端调用 sendto() 将数据以 Packet 的形式发送出去, 然后调用 recvfrom() 等待服务端响应, 若没有 Packet 到来, 则 recvfrom 将会持续阻塞
  5. 服务端收到客户端发来的 Packet, 执行一定的逻辑, 调用 sendto() 将回复内容发回给客户端

上述的 4 ~ 5 可能会持续多轮, 当客户端无数据要再发往服务端后, 便调用 close() 关闭套接字

我们着重要关注 recvfrom() 函数, 无论是对于客户端还是服务端来说, 当调用 recvfrom() 函数后都会进入阻塞状态, 直到有新的数据准备就绪才可以读取, 这种方式便是最简单的 IO 模型, 称之为 Blocking IO, 默认情况下, 所有 socket 读写都是阻塞的, Blocking IO 的流程图如下所示:

8.2 Non-Blocking IO (非阻塞式 IO)

非阻塞 IO 并非整个过程都不阻塞应用进程, 当内核数据准备就绪, 从内核拷贝数据到应用程序空间时, 用户进程仍然会被阻塞, 直到数据被完全拷贝到用户空间之后, 进程才会从阻塞态转为就绪态, Non-Blocking IO 的非阻塞体现在 recvfrom() 调用上, 若内核当前未准备好数据则 recvfrom() 将会立即返回 EWOULDBLOCK 错误, 这是在 errno.h 中定义的一个宏, 即当内核数据未就绪时, 应用程序不会因 recvfrom() 调用而阻塞, 应用程序可以根据 recvfrom() 的执行结果判断是否继续轮询, 直到内核的数据准备就绪后, 应用程序再次调用 recvfrom() 将会阻塞, 待数据从内核空间拷贝到用户空间后重新转换为就绪态, Non-Blocking IO 的流程图如下所示:

8.3 IO Multiplexing (IO 复用)

IO Multiplexing 主要用在当一个进程同时监听多个套接字对象时(典型的例子便是 Web Server, 它们在同一时刻可能处理大量的客户端请求), 所监听的套接字集合中至少有一个准备就绪, 便可以获知这一事件, 进而调用 recvfrom() 进行数据读取, IO Multiplexing 有多个实现, 如 select/poll/epoll/kqueue 都是 IO 复用的实现, 以 select 为例, 它的函数原型为

int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);

select 的第一个参数 nfds 指定了所要监视的文件描述符的范围, 它的值应是所期望监视的最大文件描述符加一, 而第二、三、四个参数分别是指向所要监视读事件、写事件以及异常事件的文件描述符集合(fd_set)的指针, 举例来说, 比如我们关心文件描述符集合 {2, 4, 5} 中是否有可读的对象, 则我们将这个集合的指针作为第二个参数传入, 当文件描述符为 2, 4, 5 中有任意一个对象可读时, select 将返回一个大于 0 的值, fd_set 是以类似于 bitmap 的结构来存储文件描述符的, 在 POSIX 标准中, fd_set 接口定义了如下几个宏,

int FD_ZERO(int fd, fd_set *fdset);
int FD_CLR(int fd, fd_set *fdset);
int FD_SET(int fd, fd_set *fd_set);
int FD_ISSET(int fd, fd_set *fdset);

分别用来清除所有位、清空指定位、设置指定位、测试指定位是否已设置, 举例来说, 为了问题简化, 我们假设 fd_set 占一个字节, 则它共有 8 位, 如果我们希望跟踪文件名描述符为 1, 3, 5 的对象, 那么我们可以调用 3 次 FD_SET 分别将 fd_set 字节的下标为 1, 3, 5 的比特位置为 1, 代码如下所示:

fd_set rset;
FD_ZERO(&rset);
FD_SET(1, &rset);
FD_SET(3, &rset);
FD_SET(5, &rset);

如果对某类变化不感兴趣, 则只需传递一个空指针即可, 而 timeout 参数则指定超时时间, 共有 3 种选择, 不设置超时时间(即将实参传入空指针), 则 select 将一直对设置的文件描述符进行监听, 设置一个有限超时时间, 当超过该时间后相应的文件描述符仍没有所期望的事件, 则 select 函数返回 0, 除此之外也可以将值设为 0, 此时 select 将会立即返回, 所有写入的文件描述符都会被测试并返回符合要求的文件描述符的个数

对于 select/poll/epoll 的用法, 我将在以后的博客中单独讨论, 这里我们给出 IO Multiplexing 的流程图:

观察上面的图示可以看到, 对于 IO Multiplexing 来说, 两次系统调用都是阻塞的, 与 Blocking-IO 相比, IO Multiplexing 在整个 IO 过程中发起了两次系统调用, 所以如果只操作一个套接字对象的话, IO Multiplexing 的效率甚至比不上 Blocking IO, 而 IO Multiplexing 的优势在于同时处理多个套接字对象

8.4 Signal Driven IO (信号驱动 IO)

在 POSIX 标准中, 信号(Signal) 是用来通知进程发生了某事件, Signal 其实与硬件中断的概念很类似, 因此它也被称为软件中断, 简称软中断, Signal 可以由另外一个进程发给本进程, 也可以由内核直接发给本进程, POSIX 定义了诸多 Signal, 这些 Signal 都以宏的形式定义在 signal.h 下, 与 IO 相关的信号是 SIGIO, 与硬件的中断处理函数类似, Signal 也有中断处理函数, POSIX 定义了 Signal 中断处理函数 sigaction(), 具体用法可以参看 Man Page, 这里我们给出 Signal Driven IO 的流程图

8.5 Async IO (异步 IO)

分析上面的四种 IO 模型, 我们可以发现, 无论是哪一种, IO 操作中都会有阻塞产生, 尤其对于数据从内核空间和用户空间拷贝的时候, 所有上述 IO 模型都会发生阻塞, 上面的四种 IO 模型都统称为同步IO, 在本节我们给出的是异步 IO 模型, 异步 IO 模型同样属于 POSIX 标准, 对于异步 IO 来说, 应用程序只要通知内核要读取的套接字对象, 以及数据的接收地址, 则整个过程都是由内核独立来完成, 包括数据从内核空间向用户空间的拷贝, 因此异步 IO 的流程图如下所示:

可以看到异步 IO 同样使用了信号机制, 但与 Signal Driven IO 不同, 前者属于同步 IO, 从内核空间拷贝数据到用户空间需要应用进程调用 recvfrom() 实现, 而在异步 IO 中所有的一切都是由内核独立完成