对网络 I/O 模型的简单总结
网络I/O模型是操作系统提供给应用程序用于处理网络通信的机制。不同的I/O模型会影响程序的性能和资源使用效率,尤其是在高并发场景下。以下是几种常见的网络I/O模型及其简单总结~
1. I/O 操作与操作系统的关系
首先,我想先简单聊聊程序在进行 I/O 操作的时候,跟操作系统之间的交互是怎么样的。
I/O 根据是否发生了网络传输区分为本地 I/O 和网络 I/O ;本地 I/O 就是程序在本机进行文件读取和生成,网络 I/O 则是程序通过机器的网络设备(例如网卡)进行文件流传输;无论是
本地 I/O 还是网络 I/O 程序都需要借助操作系统的力量,需要操作系统调度切换到内核态才能执行相应的一些命令,例如:从磁盘中读取指定文件字节流/往磁盘文件写入字节流;将字节流通过网卡发送到网络中。这些都只有内核态才能执行的能力;所以我们程序进行 I/O 操作,一定会涉及到用户态/内核态的切换。
2. 三种网络 I/O 模型
2.1 阻塞 I/O 模型
程序要进行 I/O 操作时,我们上面说到,需要操作系统切换内核态才能对文件进行读/写的操作,操作系统提供了特殊的 API,程序通过调用特殊的 API 就能获得文件读写的能力。这特殊 API 有一个专业名词 系统调用
。
例如:程序需要对文件进行读取,需要调用操作系统的 read()
方法,此时操作系统会切换到内核态,调用硬件能力,将文件内容从磁盘读取到内核缓冲区,然后通过 CPU 将内核缓冲区的数据拷贝到用户态;然后再从内核态切回用户态。
这样程序就完成了对文件的读取操作。这里涉及到几个步骤:
- 线程调用
read()
通知操作系统需要对文件进行读取 - 操作系统从用户态转化成内核态
- 操作系统在内核态操作调用硬件能力,将文件拷贝到内核缓冲区
- 然后将内核缓冲区的数据拷贝到用户态
- 线程成功读取到文件内容
在这个流程中,在调用 read()
之后,线程则一直阻塞等待操作系统返回数据,线程是阻塞等待的,线程不能去执行其他操作;
会产生两次态的转变,1. 调用的时候用户态转变成内核态,2. 就绪的时候内核态转化成用户态。 会有两次阻塞:1. 进行系统调用的时候 2. 等待就绪的过程,线程也是阻塞的
优点
- 实现简单、使用简单易懂
缺点
- 无法处理大量 I/O 请求的场景。一个 I/O 请求就会占用一个线程,并且在进行
系统调用
的时候,线程是阻塞的,无法执行其他操作。如果一瞬间有比较多的 I/O 请求,就需要创建对应数量的线程,线程的创建与维护都是非常消耗内存和CPU资源的。
2.2 非阻塞 I/O 模型
阻塞 I/O 的阻塞是指线程在进行系统调用之后,阻塞等待系统调用(例:read()
)的结果,期间线程不能做其他操作;非阻塞 I/O 则是指线程在进行系统调用之后,不需要阻塞等待 I/O 操作的处理完成。流程如下:
- 线程进行系统调用,通知操作系统需要对文件进行读取
- 操作系统立即返回此次系统调用的结果:文件已经准备好了或者还在处理中
- 因为线程是马上获取到系统调用的响应,线程是非阻塞状态的;
- 但是因为线程无法知道操作系统什么时候能把文件准备好,所以需要一直询问操作系统:你准备好了吗?你准备好了吗?(即一直进行系统调用)
- 直到操作系统把文件准备好了,这个时候系统调用就会返回程序:文件已经准备好了
- 此时,线程就能够成功读取到文件内容
同样会产生两次态的转变,但是阻塞从两次变成了一次(进行系统调用的时候)
优点
- 线程在进行系统调用之后,不用进入阻塞状态
缺点
- todo 是否同样不适配大量 I/O 请求的场景?待确认
- 因为线程无法知道操作系统什么时候才能把文件准备好,只能一直轮询,cpu 资源都消耗在系统调用询问结果上了,一直在空转。
2.3 I/O 多路复用模型
上述阻塞 I/O 模型、非阻塞 I/O 模型对于大量 I/O 请求的场景都不适用,原因在于需要为每一个 I/O 请求分配一个线程去处理。当面对大量请求时,线程的创建就成为瓶颈。I/O 多路复用则能做到一个线程对多个 I/O 请求的管理,做到复用线程的效果。
流程如下:
- 每一个 I/O 请求都统一注册到复用器(select/poll/epoll)上,这个复用器能够做到一次返回多个就绪状态的 I/O
- 大量 I/O 请求只需要将注册到复用器上,然后用一个线程来调用复用器,就能做到同时监听处理多个 I/O 事件
通过复用器,就能做到线程与 I/O 请求的处理关系是 1:N (N 取决于复用器支持注册的 I/O 请求上限)
复用器
我了解到linux中常见的复用器实现有三种: select、poll、epoll
select
底层数据结构使用的是数组,元素对应与每一个 I/O 请求,当调用select
的时候,需要传入全部注册的文件描述符(一个文件描述符fd就对应我们一个 I/O 请求),然后扫描看哪一个文件描述符已经就绪(即哪一个 I/O 请求已经就绪)。
poll
底层数据结构使用的是链表,不存在上限值,工作机制与 select
类似。同样需要传入全部注册的文件描述符,存在扫描性能问题。
epoll
是 select
和 poll
的增强实现,不需要像 select
和 poll
一样轮询就绪的文件描述符,是基于事件驱动获取到就绪的文件描述符。对于注册进来的 I/O 请求,epoll
底层是使用红黑树来存储的,当进行调用的时候
不需要传入全部注册的文件描述符;并且 epoll
使用队列来存储就绪的文件描述符,当文件描述符就绪之后,会将就绪的文件描述符放在队列中,这样当要获取就绪的文件描述的时候,就不用遍历整个红黑树了,只需要从队列中获取。
除此之外,我还学习到 epoll
有两种工作模式:边缘触发和水平触发。简单来理解就是:边缘触发对于已经就绪过一次的文件描述符,后续不会再返回,而水平触发则是只要文件描述就绪,没有进行处理,就会一直返回。
两者对比:边缘触发会产生较少的事件通知,但是一次要将所有就绪的文件描述符处理完;水平触发相对来说会产生较多的事件通知,但是不需要一次处理完所有就绪的文件描述符。
2.4 总结
对三个网络 I/O 模型进行了简单的梳理,阻塞 I/O、非阻塞 I/O、I/O 多路复用,三个模型各有特点,其中 I/O 多路复用模型经常出现在中间件网络通信模块中,例如:Redis、Netty 等。I/O 多路复用思想也常与事件驱动的思想绑定在一起,
比较经典的设计思想就是 Reactor 模式,Reactor 模式将检测和具体处理解耦,借助于 I/O 多路复用同时对多个文件描述符进行监听,当文件描述符就绪之后,Reactor 将其分发给到相应的事件处理器进行处理。
暂时想到那么多,后续我继续学习之后继续补充。