彻底弄懂IO这件事情 - 深入理解网络IO

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

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

网络 IO 概念准备

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

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

bloom

  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。
      1. 拷贝 同步: 从内核拷贝到用户空间的这个阶段,如果直到从开始拷贝直到拷贝结束,read 函数才返回,则称为同步 IO。如果在调用 read 的时候就直接返回了,等到数据拷贝结束,才通过某种方式 (例如回调) 通知到用户,这种被称为异步 IO。
  • 所谓异步,实际上就是非同步非阻塞。

同步阻塞 IO

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

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

同步非阻塞 IO

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

  • 总结来说,原因有二:

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