说说你知道的几种 I/O 模型
说说你知道的几种 I/O 模型
回答重点
1)同步阻塞 I/O(Blocking I/O,BIO)
线程调用 read 时,如果数据还未到来,线程会一直阻塞等待;数据从网卡到内核,再从内核拷贝到用户空间,这两个拷贝过程都为阻塞操作。
- 优点:实现简单,逻辑直观;调用后直接等待数据就绪。
- 缺点:每个连接都需要一个线程,即使没有数据到达,线程也会被占用,导致资源浪费,不适合高并发场景。
2)同步非阻塞 I/O(Non-blocking I/O,NIO)
在非阻塞模式下,read 调用如果没有数据就绪会立即返回错误(或特定状态),不会阻塞线程;应用程序需要不断轮询判断数据是否就绪,但当数据拷贝到用户空间时依然是阻塞的。
- 优点:线程不会长时间阻塞,可以在无数据时执行其他任务;适用于部分实时性要求较高的场景。
- 缺点:轮询方式会频繁进行系统调用,上下文切换开销较大,CPU 占用率较高,不适合大规模连接。
3)I/O 多路复用
通过一个线程(或少量线程)使用 select、poll、epoll 等系统调用,监控多个连接的状态;只有当某个连接的数据就绪时,系统才通知应用程序,再由应用程序调用 read 进行数据读取(读取时仍为阻塞操作)。
- 优点:大大减少了线程数量和上下文切换,能高效处理大量并发连接;资源利用率高。
- 缺点:依赖系统内核的支持,不同的多路复用实现(如 select vs epoll)有各自局限。
4)信号驱动 I/O
由内核在数据就绪时发出信号通知应用程序,应用程序收到信号后再调用 read(依然阻塞)。
- 优点:理论上可以避免轮询,数据就绪时由内核主动通知。
- 缺点:对于 TCP 协议,由于同一个信号可能对应多种事件,难以精确区分(所以实际应用中使用较少)。
5)异步 I/O(Asynchronous I/O,AIO)
调用 aio_read 后,内核负责将数据从网卡拷贝到用户空间,拷贝完成后通过回调通知应用程序;整个过程用户线程没有阻塞。
- 优点:真正实现了非阻塞,充分利用内核能力,适合高并发场景。
- 缺点:编程模型复杂,错误处理和状态管理较难;在 Linux 下支持不完善,多数实际场景仍采用 I/O 多路复用来模拟异步效果,而 Windows 则支持真正的 AIO。
扩展知识
从 read 调用,即读取网络数据为例子从演进的角度来展开 I/O 模型:
同步阻塞,BIO(Blocking I/O)
当用户程序的线程调用 read 获取网络数据的时候,首先这个数据得有,也就是网卡得先收到客户端的数据,然后这个数据有了之后需要拷贝到内核中,然后再被拷贝到用户空间内,这整一个过程用户线程都是被阻塞的。
假设没有客户端发数据过来,那么这个用户线程就会一直阻塞等着,直到有数据。即使有数据,那么两次拷贝的过程也得阻塞等着。
所以这称为同步阻塞 I/O 模型。
它的优点很明显,简单。调用 read 之后就不管了,直到数据来了且准备好了进行处理即可。
缺点也很明显,一个线程对应一个连接,一直被霸占着,即使网卡没有数据到来,也同步阻塞等着。
我们都知道线程是属于比较重资源,这就有点浪费了。
所以我们不想让它这样傻等着。
于是就有了同步非阻塞 I/O。
同步非阻塞I/O,NIO(No Blocking I/O)
在没数据的时候,用户程序可以不再阻塞等着,而是直接返回错误,告知暂无准备就绪的数据,用户程序会通过轮询操作,不断发起 read 调用,直到内核中的数据拷贝就绪,才会停止发起 read 调用,不过在数据从内核拷贝到用户空间的时候,这段时间内用户程序是出于阻塞状态的。
这个模型相比于同步阻塞 I/O 而言比较灵活,比如调用 read 如果暂无数据,则线程可以先去干干别的事情,然后再来继续调用 read 看看有没有数据。
但是如果你的线程就是取数据然后处理数据,不干别的逻辑,那这个模型又有点问题了。
等于你不断地进行系统调用,如果你的服务器需要处理海量的连接,那么就需要有海量的线程不断调用,上下文切换频繁,CPU 也会忙死,做无用功而忙死。
那怎么办?
于是就有了I/O 多路复用。
I/O 多路复用
从图上来看,看似和同步非阻塞 I/O 一样,但实际上线程模型不同。
同步非阻塞 I/O 频繁调用一直轮询的话是比较消耗 CPU 资源的。为了解决这个问题,操作系统使用了 IO 多路复用模型,只用一个线程查看多个连接是否有数据已准备就绪。
仅需往 select 注册需要被监听的连接,由 select 来监控它所管理的连接是否有数据已就绪,如果有则可以通知别的线程来 read 读取数据,这个 read 和之前的一样,还是会阻塞用户线程。
这样一来就可以用少量的线程去监控多条连接,减少了线程的数量,降低了内存的消耗且减少了上下文切换的次数。
所谓的多路指的是多条连接,复用指的是用一个线程就可以监控这么多条连接。
信号驱动式I/O
I/O 多路复用的 select 虽然不阻塞了,但是它得时刻去查询看看是否有数据已经准备就绪,那是不是可以让内核告诉我们数据到了而不是我们去轮询呢?
信号驱动 I/O 就能实现这个功能,由内核告知数据已准备就绪,然后用户线程再去 read(还是会阻塞)。
听起来是不是比 I/O 多路复用好呀?那为什么好像很少听到信号驱动 I/O?
因为我们的应用通常用的都是 TCP 协议,而 TCP 协议的 socket 可以产生信号事件有七种。
也就是说不仅仅只有数据准备就绪才会发信号,其他事件也会发信号,而这个信号又是同一个信号,所以我们的应用程序无从区分到底是什么事件产生的这个信号。
所以我们的应用基本上用不了信号驱动 I/O,但如果你的应用程序用的是 UDP 协议,那是可以的,因为 UDP 没这么多事件。
所以信号驱动 I/O 也不太行。
异步 I/O,AIO(Asynchronous I/O)
信号驱动 I/O 虽然对 TCP 不太友好,但是这个思路对的:往异步发展,但是它并没有完全异步,因为其后面那段 read 还是会阻塞用户线程,所以它算是半异步。
因此,我们得想下如何弄成全异步的,也就是把 read 那步阻塞也省了。
其实思路很清晰:让内核直接把数据拷贝到用户空间之后再告知用户线程,来实现真正的非阻塞I/O!
所以异步 I/O 其实就是用户线程调用 aio_read ,然后包括将数据从内核拷贝到用户空间那步,所有操作都由内核完成,当内核操作完毕之后,再调用之前设置的回调,此时用户线程就拿着已经拷贝到用户控件的数据可以继续执行后续操作。
在整个过程中,用户线程没有任何阻塞点,这才是真正的非阻塞I/O。
那为什么常用的还是I/O多路复用,而不是异步I/O?
因为 Linux 对异步 I/O 的支持不足,你可以认为还未完全实现,所以用不了异步 I/O。
这里可能有人会说不对呀,像 Tomcat 都实现了 AIO 的实现类,其实像这些组件或者你使用的一些类库看起来支持了 AIO(异步I/O),实际上底层实现是用 epoll 模拟实现的。
而 Windows 是实现了真正的 AIO,不过我们的服务器一般都是部署在 Linux 上的,所以主流还是 I/O 多路复用。