【死磕 NIO】— Reactor 模式就一定意味着高性能吗?

大家好,我是大明哥,我又来了。

为什么是 Reactor

一般所有的网络服务,一般分为如下几个步骤:

  • 读请求(read request)

  • 读解析(read decode)

  • 处理程序(process service)

  • 应答编码 (encode reply)

  • 发送应答(send reply)

接下来,大明哥就来分析解决这个问题的最佳实践。

单线程模式

对于很多小伙伴来说,最简单,最传统的方式就是一个方法来处理所有的请求,这种实现方式最简单,也是最保险的方式。

这种方式实现起来虽然简单,但是性能不行,如果其中有一个请求因为某种原因阻塞了,则他后面的所有请求都会阻塞在那里,同时他也没法利用多 CPU 的性能,性能严重不足。

多线程模式

单线程的性能肯定不行,那就调整为多线程方式。

每来一个请求就会创建一个线程来处理,这种方式虽然不会像 单线程模式 一样,一个线程会阻塞所有的请求,但是他依然很大的问题:

  • 当客户端多,并发大的时候,需要创建大量线程来处理,线程的创建和销毁也很消耗资源,会导致整个系统的的资源占用较大

  • 同样无法应对高性能和高并发

线程池模式

既然多线程模式需要创建这么多线程,那么我们控制创建线程的个数,采用资源复用 线程池 的方式,也就是我们不需要再为每一个连接创建一个线程,而是创建一个线程池,将连接分配给线程,然后一个线程可以处理多个链接。

这种线程池的方式虽然解决了系统资源占用的问题,但是他依然带了了一个新的问题,每一个线程如何高效地处理请求呢?在上篇文章中 【死磕NIO】— 阻塞IO,非阻塞IO,IO复用,信号驱动IO,异步IO,这你真的分的清楚吗?我们提到过在单个线程中如果当前连接在进行read操作时,如果没有数据可读,则会发生阻塞,那么线程就没有办法继续处理其他连接的业务了。那么怎么解决?将 read 操作改为非阻塞的方式,既然改为了非阻塞方式,那线程如何知道read 操作有数据可读了呢?

  • 第一种方式,则是不断的去轮询,但是轮询要消耗 CPU的,而且随着轮询的线程多了,轮询的效率会越来越低

  • 第二种方式,事件驱动。当线程关心的事件发生了,比如read 有数据可读了,则通知相对应的线程进行处理

Reactor 模式

第二种方式就是 I/O多路复用。I/O多路复用就是通过一种机制,一个线程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知线程进行相应的读写操作。目前支持 IO多路复用技术有:

  • Linux:selectpollepoll

  • MAC:kqueue

  • Windows:select

监听线程帮助我们监听哪些线程的事件已发生,发生后则通知相对应的线程进行处理,这样就可以避免进行很多无用的操作。对处理线程而言,整个处理过程只有调用 selectpollepoll 的时候才会阻塞,其他时段,他可以处理其他的事情,这样整个线程会被充分利用起来,这样就高效很多了。

什么是 Reactor模式

上面讲了 Reactor 模式的演变,那什么是 Reactor 模式呢?

wiki上是这样定义的:

Reactor 模式也叫做反应器设计模式,它是一种为处理服务请求并发提交到一个或者多个服务处理程序的事件设计模式。当请求抵达后,服务处理程序使用解多路分配策略,然后同步地派发这些请求至相关的请求处理程序。

简要概括就是: 将消息放到了一个队列中,通过异步线程池对其进行消费。暂时理解成下面这个样子:

对于Reactor模式来说,他并没队列,每当有一个 Event 输入到 Server端时,Service Handler 会将其转发(dispatch)相对应的handler进行处理。

Reactor的组件主要包括三个:

  • Reactor:派发器,将 client端的事件分发给相对应的Handler

  • Acceptor:请求连接器,Reactor 接收到 client 连接事件后,会将其转发给 Acceptor,Acceptor 则会接受 Client 的连接,建立对应的Handler,并向 Reactor注册此Handler

  • Handler:请求处理器,负责事件的处理。

模型大致如下图:

Reactor 模式

Reactor 模型中的Reactor可以是多个也可以是单个,Handler同样可以是单线程也可以是多线程,所以组合的模式大致有如下四种:

  • 单Reactor单线程/进程

  • 单Reactor多线程/进程

  • 多Reactor单线程/进程

  • 多Reactor多线程/进程

其中第三种多Reactor单线程并没有什么实际的意思,所以大明哥重点介绍第一、二、四种。

单Reactor单线程/进程

  • Reactor 线程通过 select (IO多路复用接口)监听事件,收到事件后通过Dispatch 来分发事件,事件会分发给Acceptor和Handler 两个组件,具体是哪个组件要看事件的类型。

  • 如果事件类型为建立连接,则将事件分发给Acceptor,Acceptor会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件。

  • 如果时间类型不是建立连接,则将该事件交由当前连接的Handler来处理。

优缺点

  • 优点:该模型是将所有处理逻辑放在一个线程中实现,模型简单,没有多线程、进程通信、竞争的问题

  • 缺点

    • 由于只有一个线程,无法充分利用CPU,性能堪忧。同时Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈。

    • 还有一个比较严重的可靠性问题,如果线程意外终止,或者进入死循环,则会导致整个线程都无法接受和处理事件了,造成节点故障。

单Reactor多线程/进程

单线程存在性能瓶颈,那我们就引入多线程方案。

Reactor 接受请求后,根据请求类型来进行分发,分发逻辑与 单Reactor单线程 模型一样,不同之处在于Handler不在进行业务处理了,它只负责接受和发送,Handler接受数据后,会将数据发送给 Worker 线程池中的线程处理,该线程才是处理业务的真正线程,线程将业务处理完成后,将数据发送给Handler,然后Handler 再send出去。

优缺点

  • 优点:由于Handler使用了多线程模式,则可以利用充分利用CPU的性能

  • 缺点:

    • Handler使用多线程模式,则会涉及到数据共享的问题,需要考虑互斥,实现肯定比 单Reactor单线程模式复杂一些

    • 单Reactor,一个线程处理事件监听、分发、响应,对于高并发场景,容易造成性能瓶颈

多Reactor多线程/进程

单Reactor多线程模式解决了Handler单线程的性能问题,但是Reactor还是单线程的,对于高并发场景还是会有性能瓶颈,所以需要对Reactor调整为 多线程模式

  • 主线程中的MainReactor对象通过select监听事件,接收到事件后通过Dispatch进行分发,如果事件类型为建立连接则将事件分发给Acceptor 进行连接建立

  • 如果收到的事件不是连接,则他将事件分发个某个SubReactor,SubrReactor 将连接加入到连接队列进行监听,并创建Handler进行各种事件处理

  • 如果有新的事件发生,SubReactor 则会调用当前连接的Handler来进行处理。Handler 通过read 读取数据后,将数据发送给Worker线程进行处理,Worker线程池则会分配线程进行业务处理,处理完成后返回结果,Handler接受结果后,通过send发送给客户端

优缺点

  • 优点:该模式主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理,同时主线程和子线程的交互也很简单,子线程接收主线程的连接后,只管业务处理即可,无须关注主线程

  • 缺点:模型复杂

这种模式适用于高并发场景,广泛运用于各种项目中,如大名鼎鼎的Netty。

Reactor 优缺点

Reactor模式有如下优点:

  • 响应快,不必为单个同步时间所阻塞

  • 可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销

  • 扩展性好,可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源

  • 复用性好,Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性

虽然Reactor有诸多优点,但是由于他的IO读写数据时还是在同一个线程中实现的,如果当前线程出现了一个长时间的IO数据读写,则会影响其他的client。那怎么解决呢?请静候下一篇文章。

参考资料

posted @ 2021-10-22 19:20  chenssy  阅读(9812)  评论(7编辑  收藏  举报