深入理解网络 IO 模型

在进行 Linux 网络编程开发的时候,免不了会涉及到 IO 模型的讨论。《Unix 网络编程》一书中提到的几种 IO 模型,我们在开发过程中,讨论最多的应该就是三种: 阻塞 IO非阻塞 IO 以及 异步 IO

本文试图理清楚几种 IO 模型的根本性区别,同时分析了为什么在 Linux 网络编程中最好要用非阻塞式 IO。

网络 IO 概念准备

在讨论网络 IO 之前,一定要有一个概念上的准备前提: 不要用操作磁盘文件的经验去看待网络 IO。 具体的原因我们在下文中会介绍到。

相比于传统的网络 IO 来说,一个普通的文件描述符的操作可以分为两部分。以 read 为例,我们利用 read 函数从 socket 中同步阻塞的读取数据,整个流程如下所示:

read 示意图

  1. 调用 read 后,该调用会转入内核调用
  2. 内核会等待该 socket 的可读事件,直到远程向 socket 发送了数据。可读事件成立 (这里还需要满足 TCP 的低水位条件,但是不做太详细的讨论)
  3. 数据包到达内核,接着内核将数据拷贝到用户进程中,也就是 read 函数指定的 buffer 参数中。至此,read 调用结束。

可以看到除了转入内核调用,与传统的磁盘 IO 不同的是,网络 IO 的读写大致可以分为两个阶段:

  1. 等待阶段:等待 socket 的可读或者可写事件成立
  2. 拷贝数据阶段:将数据从内核拷贝到用户进程,或者从用户进程拷贝到内核中,

三种 IO 模型的区别

我们日常开发遇到最多的三种 IO 模型分别是:同步阻塞 IO、同步非阻塞 IO、异步 IO。

这些名词非常容易混淆,为什么一个 IO 会有两个限定词:同步和阻塞?同步和阻塞分别代表什么意思?
简单来说:

  1. 等待 阻塞: 在 socket 操作的第一个阶段,也就是用户等待 socket 可读可写事件成立的这个阶段。如果一直等待下去,直到成立后,才进行下个阶段,则称为阻塞式 IO;如果发现 socket 非可读可写状态,则直接返回,不等待,也不进行下个阶段,则称为非阻塞式 IO。
  2. 拷贝 同步: 从内核拷贝到用户空间的这个阶段,如果直到从开始拷贝直到拷贝结束,read 函数才返回,则称为同步 IO。如果在调用 read 的时候就直接返回了,等到数据拷贝结束,才通过某种方式 (例如回调) 通知到用户,这种被称为异步 IO。

所谓异步,实际上就是非同步非阻塞。

同步阻塞 IO

read(fd, buffer, count)

Linux 下面如果直接不对 fd 进行特殊处理,直接调用 read,就是同步阻塞 IO。同步阻塞 IO 的两个阶段都需要等待完成后,read 才会返回。

也就是说,如果远程一直没有发送数据,则 read 一直就不会返回,整个线程就会阻塞到这里了。

同步非阻塞 IO

对于同步非阻塞 IO 来说,如果没有可读可写事件,则直接返回;如果有,则进行第二个阶段,复制数据。
在 linux 下面,需要使用 fcntl 将 fd 变为非阻塞的。

int flags = fcntl(socket, F_GETFL, 0);
fcntl(socket, F_SETFL, flags | O_NONBLOCK);

同时,如果 read 的时候,fd 不可读,则 read 调用会触发一个 EWOULDBLOCK 错误 (或者 EAGAIN,EWOULDBLOCK 和 EAGAIN 是一样的)。用户只要检查下 errno == EWOULDBLOCK, 即可判断 read 是否返回正常。

基本在 Linux 下进行网络编程,非阻塞 IO 都是不二之选。

异步 IO

Linux 开发者应该很少使用纯粹的异步 IO。因为目前来说,Linux 并没有一个完美的异步 IO 的解决方案。pthread 虽然提供了 aio 的接口,但是这里不做太具体的讨论了。

我们平常接触到的异步 IO 库或者框架都是在代码层面把操作封装成了异步。但是在具体调用 read 或者 write 的时候,一般还是用的非阻塞式 IO。

不能用操作磁盘 IO 的经验看待网络 IO

为什么不能用操作磁盘 IO 的经验看待网络 IO。实际上在磁盘 IO 中,等待阶段是不存在的,因为磁盘文件并不像网络 IO 那样,需要等待远程传输数据。

所以有的时候,习惯了操作磁盘 IO 的开发者会无法理解同步阻塞 IO 的工作过程,无法理解为什么 read 函数不会返回。

关于磁盘 IO 与同步非阻塞的讨论,在知乎上有一篇帖子 为什么书上说同步非阻塞 io 在对磁盘 io 上不起作用? 讨论了这个问题。

为什么在 Linux 网络编程中最好要用非阻塞式 IO?

上文说到,在 linux 网络编程中,如果使用阻塞式的 IO,假如某个 fd 长期不可读,那么一个线程相应将会被长期阻塞,那么线程资源就会被白白浪费。

那么,如果我们用了 epoll,还必须要使用非阻塞 IO 吗? 因为如果使用 epoll 监听了 fd 的可读事件,在 epoll_wait 之后调用 read,此时 fd 一定是可读的, 那么此时非阻塞 IO 相比于阻塞 IO 的优势不就没了吗?

实际上,并不是这样的。epoll 也必须要搭配非阻塞 IO 使用。
这个帖子 为什么 IO 多路复用要搭配非阻塞 IO? 详细讨论了这个问题?

总结来说,原因有二:

  1. fd 在 read 之前有可能会重新进入不可读的状态。要么被其他方式读走了 (参考惊群问题), 还有可能被内核抛弃了,总的来说,fd 因为在 read 之前,数据被其他方式读走,fd 重新变为不可读。此时,用阻塞式 IO 的 read 函数就会阻塞整个线程。
  2. epoll 只是返回了可读事件,但是并没有返回可以读多少数据量。因此,非阻塞 IO 的做法是读多次,直到不能读。而阻塞 io 却只能读一次,因为万一一次就读完了缓冲区所有数据,第二次读的时候,read 就会又阻塞了。但是对于 epoll 的 ET 模式来说,缓冲区的数据只会在改变的通知一次,如果此次没有消费完,在下次数据到来之前,可读事件再也不会通知了。那么对于只能调用一次 read 的阻塞式 IO 来说,未读完的数据就有可能永远读不到了。

因此,在 Linux 网络编程中最好使用非阻塞式 IO。

参考