从 NIO 多路复用到 Reactor(下)

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 12 天,点击查看活动详情

但使龙城飞将在,不教胡马度阴山。

1 前言

在前文中讲述了 NIO 和阻塞的理解,在本文中继续理解多路复用和 Reactor 的概念。

2 多路复用器

即便是 nio 解决了阻塞的问题,但是无效的轮询会造成 cpu 空转浪费资源,使用 IO 多路复用技术,当内核将数据准备好之后,通知应用进程来获取数据,就解决了这个问题。在 linux 操作系统中根据其操作的方式不同,分为 select/poll/epoll 三种多路复用器。由内核 kernel 监控所有的 socket 当数据准备好之后,发起系统调用,即 system call 将数据从内核拷贝到用户进程。

I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。能够同时处理多个连接请求,这就是其最大的优势所在。

2.1 select 函数操作

select 是操作系统提供的系统调用函数,通过它,可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理,操作如下图所示:

select方法 select 是操作系统提供的调用函数,通过这个函数可以把一组 fd 传给操作系统,操作系统遍历 fd,将完成准备的文件描述符个数返回给用户线程,用户线程再去逐个遍历 fd 查看哪个 fd 已经处于就绪的状态,然后再去处理。

select 的特点如下:

  • 1 用户需要将监听的 fd list 传入到操作系统内核中,内核来完成遍历操作并将解决返回,这样在高并发场景数组的复制操作下会过多的消耗资源。select 的这一操作仅解决了系统的上下文切换的开销,遍历数组是依旧存在的。select 返回结果是就绪的 fd 个数,用户线程还需要判断哪个 fd 处于就绪状态。

  • 2 select 可以传入一组 socket 然后等待内核的处理结果,但是其 list 大小只有 1024 个,每次调用 select 都需要将 fd 数组从用户态复制到内核态,其开销比较大。调用 select 后返回的是就绪 fd 数量,还需要用户再次遍历。

2.2 epoll 操作

针对 select 的缺点,poll 为了增加单次监听 socket 的个数,采用了链表的结构,放弃了数组的结构,但是其核心需要遍历的缺点依然没有解决。针对 select 和 poll 的缺点,epoll 应运而生,其核心主要包括三个方法:

# 在内核开辟一个区域用来存放需要监听的fd
epoll_create
# 向内核中添加、修改、删除需要监控的fd
epoll_ctl
# 返回已经就绪的fd
epoll_wait
复制代码

epoll 示意图如下所示: epoll方法

总结核心内容如下所述:

  • 1.内核中存储了一份文件描述符 fd 的集合,无需用户每次都从用户态传入,只需要告诉内核修改的部分就可以。
  • 2.内核中不再通过轮询的方式找到就绪的文件描述符 fd,而是通过异步 IO 事件进行唤醒,这里涉及到数据的操作模式。
  • 3.内核会将有 IO 事件发生的文件描述符 fd 返回给用户,用户不需要自己进行遍历。
2.2.1 epoll 内部的数据结构

epoll 创建了一个空间用来存储监听的 fd,这些监听的 fd 是按照红黑树的数据结构进行组织,eventpoll 中是 rbr 指向该红黑树,而已经准备好的数据则使用双向链表的结构进行存储,存放在 eventpoll 通过 rdllist 指向该链表。

2.2.2 epoll 的数据操作模式

epoll 的数据操作有两种模式:水平模式 LT(level trigger)和边缘模式 ET(edge trigger)。LT 是 epoll 的默认操作模式,两者的区别在于当触发 epoll_wait 函数时,是否会清空 rdllist。

  • 1 LT 模式: epoll_wait 函数检测到有事件发生时需要通知应用程序,但是应用程序不一定及时进行处理,当 epoll_wait 函数再次检测到该事件的时还会通知应用程序,直到事件被处理。可以理解为 mq 发送消息的 at least once 模型。

  • 2 ET 模式:epoll_wait 函数检测到事件发生只会通知应用程序一次,后续 epoll_wait 函数将不再监控该事件。因此 ET 模式降低了同一个事件被 epoll 触发的次数,效率比 LT 模式高。可以理解为 mq 发送消息的 exactly once 模型。

3 IO 线程模型

前文是从内核的角度来分析数据收发的模型,在本节中将从用户空间的角度来看数据的收发情况。通过 IO 多路复用就可以监听 IO 事件,通过 dispatch 不断分发事件就像一个反应堆一样,看起来像不断的产生 IO 事件,因此我们称这种模式为 Reactor 模型。Reactor 就是利用 NIO 对 IO 线程进行不同的分工:

  • 1 前文中提到的 IO 多路复用器,进行 IO 事件的注册和监听。
  • 2 将监听到的就绪 IO 事件通过 dispatch 分发到具体的处理事件 handler 进行 IO 事件处理。

单 Reactor 意味着只有一个 epoll 对象来监听所有的事件,包括连接事件、读写事件。只有一个线程来执行 epoll_wait 获取已经就绪的 Socket,对 Socket 的后续操作,也是由该线程进行处理。当有新的连接进来后,触发连接事件,交由 Acceptor 来处理,当有 IO 事件时则交由 Handler 来处理。Acceptor 的主要任务就是构建 Handler ,在获取到和 client 相关的 SocketChannel 之后,将会绑定到相应的 Handler 上,对应的 SocketChannel 有读写事件后,就可以进行处理,handler 的一些列流程包括 decode、compute、encode 操作。单 Reactor 单线程模型如下图所示:

由于单线程不能充分的发挥多核 cpu 的优势,所以使用并不多,所以就有了单 Reactor 多线程模型,在处理业务逻辑时即 IO 读写还是单线程,其中的 decode、compute、encode 操作交由 线程池来处理,其模型如下图所示:

主从多线程 Reactor 进一步对任务做了划分, 主 Reactor 主要负责监听 socket 事件,用来处理连接,建立好连接后将 socket channel 注册到从 subReactor 上。subReactor 维护自己的 selector ,完成数据的读写操作后,将数据处理交由 worker 线程来处理。 在该模型下,每个模块的工作更加专一,耦合度更低,性能和稳定性也大量的提升,支持的可并发客户端数量可达到上百万级别。 在 netty 中,具体的模型实现如下所示:

// 配置单Reactor单线程
EventLoopGroup eventGroup = new NioEventLoopGroup(1);
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(eventGroup);
//配置单Reactor多线程
EventLoopGroup eventGroup = new NioEventLoopGroup();
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(eventGroup);
//配置主从Reactor多线程
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(bossGroup, workerGroup);
复制代码

4 总结

在本文中,主要讲述了 IO 相关的操作,主要涉及了 NIO 非阻塞概念的理解,以及基于内核的多路复用的几种模型理解,最后讲述了 IO 线程模型的内容。

猜你喜欢

转载自juejin.im/post/7085540586379280392
今日推荐