IO模型
I/O交互流程
一次完整I/O交互流程分为两个阶段
读操作:
- 阶段1(等待):内核从硬件设备(磁盘、网卡等)拷贝到内核缓冲区。
- 阶段2(拷贝):数据从内核缓冲区拷贝至用户缓冲区。
写操作:
- 阶段1(拷贝):数据从用户缓冲区拷贝至内核缓冲区。
- 阶段2(等待):从内核缓冲区将数据拷贝到硬件设备(磁盘、网卡等)。
在这个IO交互流程基础上,根据两个阶段是否阻塞(阶段一是否阻塞:阻塞IO和非阻塞IO。 阶段二是否阻塞:同步IO和异步IO)划分出了三种IO模型。
- BIO:阻塞同步IO
- NIO:非阻塞同步IO
- AIO:非阻塞异步IO
下面以读操作为例为分析三种IO模型特点。
BIO模型
应用程序发起系统调用后就开始全程阻塞等待,直到数据拷贝至用户缓冲区。
NIO模型
应用程序调用内核非阻塞read方法,如果内核数据没有准备好就立即返回未就绪状态,用户进程无须等待,可以同时执行其他的任务。应用程序不断轮询直到数据就绪,然后进程阻塞等待数据从内核空间拷贝到用户空间。
AIO模型
应用程序发起read调用时向系统内核注册一个回调函数,read立即返回;内核准备数据,然后拷贝数据到用户空间后,再回调这个函数让应用进程处理数据,在IO的两个阶段,应用进程都没有阻塞。
Windows通过IOCP实现了AIO。Linux目前AIO底层实现仍然基于epoll,性能上并没有明显优势。所以像Netty这样的高性能网络框架也是基于NIO的实现。
多路复用IO模型
无论是BIO还是NIO,每个客户端连接都会创建一个线程。当客户端连接很多时,服务器需要创建大量的线程,这很容易耗尽系统线程资源。
如果是NIO模式,每个线程还要去轮询I/O事件是否就绪,并且每次轮询都通过系统调用read方法,会导致频繁的CPU上下文切换,CPU资源很容易耗尽。
那么有没有办法解决这些问题了?
IO多路复用模型应运而生,何为IO多路复用?简单点说,用一个线程处理所有IO事件。那他是怎么做到的了?来看看主要的几种实现方式。
select/poll
select是系统函数,其实现逻辑如下:
- 应用程序将监听到的所有客户端连接FD,通过系统函数select拷贝至内核。应用程序阻塞在select函数。
- 内核通过遍历的FD集合的方式来检查是否有IO事件产生,如果检测到IO事件,则将相应的FD打上标记(可读或可写),再把FD集合返回(拷贝至用户缓冲区)给应用程序。
- 应用程序接收到select函数返回,遍历FD集合并通过系统调用(read/write)处理打标的FD。
应用程序在调用read函数时,阶段一已经完成,数据已经从硬件拷贝至内核,系统只需将内核数据拷贝到用户缓冲区。而在阶段一用户也只需要一个线程轮询select函数即可,select函数本身为系统函数,其内部操作均为内核态不涉及上下文切换。
poll也是系统函数,实现逻辑和select类似,但是去掉了select只能监听1024个FD的限件。(select函数FD数组限定大小1024,修改系统FD上限也没用,除非改源码)
epoll
select/poll存在以下明显缺陷:
- 用户态至内核态,内核态到用户态,两次FD集合全量拷贝。
- 内核通过遍历FD集合检测IO事件,时间复杂度为O(N)。
- 内核返回所有FD,应用程序需要再次遍历。
epoll很好的解决了上述问题:
- 在内核缓冲区动态维护FD数据,使用红黑树结构,支持新增,修改,删除操作,时间复杂度为O(logn)。
- 使用事件驱动的机制维护就绪FD链表,当某个FD有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中
- 应用程序获取FD时,内核仅返回产生IO事件的FD.
总结
多路复用IO解决的核心问题是避免应用程序产生大量线程和大量的系统调用(频繁上下文切换),减少线程和CPU资源消耗。其本质上还是一种BIO实现,阶段一阻塞在select函数,但其用一个线程阻塞的代价,避免了大量应用程序线程被阻塞。
高性能网络模式
Reactor模式
Reactor模式核心:IO多路复用+事件分发。
监听事件分连接事件和读写事件两大类,连接事件由Acceptor组件处理,读写事件由Handler组件处理。
这个模式中涉及到四次系统调用,分别是:select、accept、read、write
Reactor模式核心流程如下:
- Reactor调用系统函数监听IO事件。
- 监听到连接事件时,将事件分发给Acceptor组件,Acceptor组件调用系统函数,建立连接。
- 监听到读写事件时,将事件分发给Handler组件,Handler组件调用系统函数读取Client端发送的请求数据,进行业务处理,然后再调用系统函数将响应结果发送给Client端。
以上是单Reactor单线程模式,也是Redis早期版本(6.0以前)的线程模型。根据Reactor和处理线程的关系,可以分为四大类模式:
- 单Reactor单线程模式
- 单Reactor多线程模式
- 多Reactor单线程模式
- 多Reactor多线程模式
单Reactor多线程模式:相比单Reactor单线程模式,单Reactor多线程模式的变化在于Handler组件不再关心业务处理,而新增了Processor组件来处理业务,Processor组件在Reactor的子线程中运行,多线程处理。
多Reactor多线程模式:
- MainReactor负责监听连接事件,并交由Acceptor组件处理。连接建立成功后,将连接分配给某个子线程的SubReactor.
- SubReactor负责监听由主线程分配的连接的read/write事件,监听到事件后分发给Handler处理。
多Reactor单线程模式:
- MainReactor负责监听连接事件,并交由Acceptor组件处理。连接建立成功后,将连接分配给某个子线程的SubReactor.
- SubReactor负责监听由主线程分配的连接的read/write事件,监听到事件后分发给Handler处理。Handler只负责读写事件,业务处理仍然交回给主线程Processor组件单线程处理。
redis 6.0开始采用类似多Reactor单线程的模式,使用多线程处理网络IO读写请求,提高网络IO读写性能, redis命令读写数据仍然是单线程执行。
众所周知,redis本身不是计算密集型应用,它没有复杂的计算逻辑,它执行的命令主要是为网络客户端提供内存数据读写服务,所以redis是一个网络IO密集型应用,它的性能瓶颈最容易出现在网络IO读写上,这就是为什么redis6.0在网络IO读写上要采用多线程的原因。
proactor模式
参考
核心功能分析如下:
- Proactive Initiator:在Asynchronous Operation Processor上注册Proactor和Completion Handler
- Completion Handler:IO事件完成后的业务处理
- Asynchronous Operation Processor:监听并处理IO事件,处理完成后,将事件写入队列
- Completion Dispatcher(Proactor):从事件队列中获取事件,分发给对应的Handler处理。
- Completion Event Queue:IO完成事件队列
可以看到Proactor模式中,IO事件不需要应用程序调用系统函数来处理,而是直接在内核中操作完成。IO事件完成后,再回调对应的Handler处理业务。
Proactor模式是AIO实现,应用程序关注IO完成事件。而Reactor是NIO实现,应用程序关注IO就绪事件。