IO模型与高性能网络模式

110 阅读7分钟

IO模型

I/O交互流程

一次完整I/O交互流程分为两个阶段

读操作:

  • 阶段1(等待):内核从硬件设备(磁盘、网卡等)拷贝到内核缓冲区。
  • 阶段2(拷贝):数据从内核缓冲区拷贝至用户缓冲区。

image.png

写操作:

  • 阶段1(拷贝):数据从用户缓冲区拷贝至内核缓冲区。
  • 阶段2(等待):从内核缓冲区将数据拷贝到硬件设备(磁盘、网卡等)。

image.png

在这个IO交互流程基础上,根据两个阶段是否阻塞(阶段一是否阻塞:阻塞IO和非阻塞IO。 阶段二是否阻塞:同步IO和异步IO)划分出了三种IO模型。

  • BIO:阻塞同步IO
  • NIO:非阻塞同步IO
  • AIO:非阻塞异步IO

下面以读操作为例为分析三种IO模型特点。

BIO模型

应用程序发起系统调用后就开始全程阻塞等待,直到数据拷贝至用户缓冲区。

image.png

NIO模型

应用程序调用内核非阻塞read方法,如果内核数据没有准备好就立即返回未就绪状态,用户进程无须等待,可以同时执行其他的任务。应用程序不断轮询直到数据就绪,然后进程阻塞等待数据从内核空间拷贝到用户空间。

image.png

AIO模型

应用程序发起read调用时向系统内核注册一个回调函数,read立即返回;内核准备数据,然后拷贝数据到用户空间后,再回调这个函数让应用进程处理数据,在IO的两个阶段,应用进程都没有阻塞。

image.png

Windows通过IOCP实现了AIO。Linux目前AIO底层实现仍然基于epoll,性能上并没有明显优势。所以像Netty这样的高性能网络框架也是基于NIO的实现。

多路复用IO模型

无论是BIO还是NIO,每个客户端连接都会创建一个线程。当客户端连接很多时,服务器需要创建大量的线程,这很容易耗尽系统线程资源。

如果是NIO模式,每个线程还要去轮询I/O事件是否就绪,并且每次轮询都通过系统调用read方法,会导致频繁的CPU上下文切换,CPU资源很容易耗尽。

image.png

那么有没有办法解决这些问题了?

IO多路复用模型应运而生,何为IO多路复用?简单点说,用一个线程处理所有IO事件。那他是怎么做到的了?来看看主要的几种实现方式。

select/poll

select是系统函数,其实现逻辑如下:

  • 应用程序将监听到的所有客户端连接FD,通过系统函数select拷贝至内核。应用程序阻塞在select函数。
  • 内核通过遍历的FD集合的方式来检查是否有IO事件产生,如果检测到IO事件,则将相应的FD打上标记(可读或可写),再把FD集合返回(拷贝至用户缓冲区)给应用程序。
  • 应用程序接收到select函数返回,遍历FD集合并通过系统调用(read/write)处理打标的FD。

image.png

应用程序在调用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

image.png Reactor模式核心流程如下:

  • Reactor调用系统函数监听IO事件。
  • 监听到连接事件时,将事件分发给Acceptor组件,Acceptor组件调用系统函数,建立连接。
  • 监听到读写事件时,将事件分发给Handler组件,Handler组件调用系统函数读取Client端发送的请求数据,进行业务处理,然后再调用系统函数将响应结果发送给Client端。 image.png

以上是单Reactor单线程模式,也是Redis早期版本(6.0以前)的线程模型。根据Reactor和处理线程的关系,可以分为四大类模式:

  • 单Reactor单线程模式
  • 单Reactor多线程模式
  • 多Reactor单线程模式
  • 多Reactor多线程模式

单Reactor多线程模式:相比单Reactor单线程模式,单Reactor多线程模式的变化在于Handler组件不再关心业务处理,而新增了Processor组件来处理业务,Processor组件在Reactor的子线程中运行,多线程处理。

image.png

多Reactor多线程模式

  • MainReactor负责监听连接事件,并交由Acceptor组件处理。连接建立成功后,将连接分配给某个子线程的SubReactor.
  • SubReactor负责监听由主线程分配的连接的read/write事件,监听到事件后分发给Handler处理。

image.png

多Reactor单线程模式

  • MainReactor负责监听连接事件,并交由Acceptor组件处理。连接建立成功后,将连接分配给某个子线程的SubReactor.
  • SubReactor负责监听由主线程分配的连接的read/write事件,监听到事件后分发给Handler处理。Handler只负责读写事件,业务处理仍然交回给主线程Processor组件单线程处理。

image.png

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完成事件队列

image.png

可以看到Proactor模式中,IO事件不需要应用程序调用系统函数来处理,而是直接在内核中操作完成。IO事件完成后,再回调对应的Handler处理业务。

Proactor模式是AIO实现,应用程序关注IO完成事件。而Reactor是NIO实现,应用程序关注IO就绪事件。