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

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

为什么是 Reactor

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

  • 读请求(read request)

  • 读解析(read decode)

  • 处理程序(process service)

  • 应答编码 (encode reply)

  • 发送应答(send reply)

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

单线程模式

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

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

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

多线程模式

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

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

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

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

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

线程池模式

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

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

这种线程池的方式虽然解决了系统资源占用的问题,但是他依然带了了一个新的问题,每一个线程如何高效地处理请求呢?在上篇文章中 【死磕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上是这样定义的:

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

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

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

Reactor的组件主要包括三个:

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

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

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

模型大致如下图:

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

Reactor 模式

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

  • 单Reactor单线程/进程

  • 单Reactor多线程/进程

  • 多Reactor单线程/进程

  • 多Reactor多线程/进程

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

单Reactor单线程/进程

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

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

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

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

优缺点

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

  • 缺点

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

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

单Reactor多线程/进程

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

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

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

优缺点

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

  • 缺点:

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

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

多Reactor多线程/进程

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

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

  • 主线程中的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。那怎么解决呢?请静候下一篇文章。

参考资料

10-23 02:47