5 Netty 线程模型

当我们讨论 Netty 线程模型的时候,一般首先会想到的是经典的 Reactor 线 程模型,尽管不同的 NIO 框架对于 Reactor 模式的实现存在差异,但本质上还是 遵循了 Reactor 的基础线程模型。

下面让我们一起回顾经典的 Reactor 线程模型。

5.1 Reactor 线程模型

5.1.1 Reactor 单线程模型

Reactor 单线程模型,是指所有的 I/O 操作都在同一个 NIO 线程上面完成。 NIO 线程的职责如下。

• 作为NIO服务端,接收客户端的TCP连接;

• 作为NIO客户端,向服务端发起TCP连接;

• 读取通信对端的请求或者应答消息;

• 向通信对端发送消息请求或者应答消息。

Reactor 单线程模型如图 5-1 所示。

由于 Reactor 模式使用的是异步非阻塞 I/O,所有的 I/O 操作都不会导致阻塞,理论上一个线程可以独立处理所有 I/O 相关的操作。从架构层面看,一个 NIO 线程确实可以完成其承担的职责。例如,通过 Acceptor 类接收客户端的 TCP 连接请求消息,当链路建立成功之后,通过 Dispatch 将对应的 ByteBuffer 派发 到指定的 Handler 上,进行消息解码。用户线程消息编码后通过 NIO 线程将消息 发送给客户端。

5 Netty 线程模型

在一些小容量应用场景下,可以使用单线程模型。但是这对于高负载、大并 发的应用场景却不合适,主要原因如下:

• 一个NIO线程同时处理成百上千的链路,性能上无法支撑,即便NIO线程的 CPU负荷达到100%,也无法满足海量消息的编码、解码、读取和发送。

• 当NIO线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超 时,超时之后往往会进行重发,这更加重了NIO线程的负载,最终会导致 大量消息积压和处理超时,成为系统的性能瓶颈。

• 可靠性问题:一旦NIO线程意外跑飞,或者进入死循环,会导致整个系统 通信模块不可用,不能接收和处理外部消息,造成节点故障。 为了解决这些问题,演进出了 Reactor 多线程模型。下面我们一起学习下 Reactor 多线程模型。

5.1.2 Rector 多线程模型

Rector 多线程模型与单线程模型最大的区别就是有一组 NIO 线程来处理 I/O操作,它的原理如图 5-2 所示。

5 Netty 线程模型

Reactor 多线程模型的特点如下。

• 有专门一个NIO线程——Acceptor线程用于监听服务端,接收客户端的TCP 连接请求。

• 网络I/O操作——读、写等由一个NIO线程池负责,线程池可以采用标准的 JDK线程池实现,它包含一个任务队列和N个可用的线程,由这些NIO线程 负责消息的读取、解码、编码和发送。

• 一个NIO线程可以同时处理N条链路,但是一个链路只对应一个NIO线程, 防止发生并发操作问题。

在绝大多数场景下,Reactor 多线程模型可以满足性能需求。但是,在个别 特殊场景中,一个 NIO 线程负责监听和处理所有的客户端连接可能会存在性能问 题。例如并发百万客户端连接,或者服务端需要对客户端握手进行安全认证,但 是认证本身非常损耗性能。在这类场景下,单独一个 Acceptor 线程可能会存在 性能不足的问题,为了解决性能问题,产生了第三种 Reactor 线程模型——主从 Reactor 多线程模型。

5.1.3 主从 Reactor 线程模型

主从 Reactor 线程模型的特点是:服务端用于接收客户端连接的不再是一 个单独的 NIO 线程,而是一个独立的 NIO 线程池。Acceptor 接收到客户端 TCP连接请求并处理完成后(可能包含接入认证等),将新创建的 SocketChannel 注 册 到 I/O 线 程 池(sub reactor 线 程 池) 的 某 个 I/O 线 程 上, 由 它 负 责 SocketChannel 的读写和编解码工作。Acceptor 线程池仅仅用于客户端的登录、 握手和安全认证,一旦链路建立成功,就将链路注册到后端 subReactor 线程池 的 I/O 线程上,由 I/O 线程负责后续的 I/O 操作。

5 Netty 线程模型

利用主从 NIO 线程模型,可以解决一个服务端监听线程无法有效处理所有客 户端连接的性能不足问题。因此,在 Netty 的官方 demo 中,推荐使用该线程模型。

5.2 Netty线程模型

Netty 的线程模型并不是一成不变的,它实际取决于用户的启动参数配置。 通过设置不同的启动参数,Netty 可以同时支持 Reactor 单线程模型、多线程模 型和主从 Reactor 多线层模型。

下面让我们通过一张原理图(图 5-4)来快速了解 Netty 的线程模型: 可以通过调整 Netty 服务端启动参数来设置它的线程模型。 服务端启动的时候,创建了两个 NioEventLoopGroup,它们实际是两个独立 的 Reactor 线程池。一个用于接收客户端的 TCP 连接,另一个用于处理 I/O 相关的读写操作,或者执行系统 Task、定时任务 Task 等。

5 Netty 线程模型

5 Netty 线程模型

Netty 用于接收客户端请求的线程池职责如下。

(1)接收客户端 TCP 连接,初始化 Channel 参数;

(2)将链路状态变更事件通知给 ChannelPipeline。 Netty 处理 I/O 操作的 Reactor 线程池职责如下。 (1)异步读取通信对端的数据报,发送读事件到 ChannelPipeline;

(2)异步发送消息到通信对端,调用 ChannelPipeline 的消息发送接口;

(3)执行系统调用 Task;

(4)执行定时任务 Task,例如链路空闲状态监测定时任务。

通过调整线程池的线程个数、是否共享线程池等方式,Netty 的 Reactor 线 程模型可以在单线程、多线程和主从多线程间切换,这种灵活的配置方式可以最 大程度地满足不同用户的个性化定制。

为了尽可能地提升性能,Netty 在很多地方进行了无锁化的设计,例如在 I/ O 线程内部进行串行操作,避免多线程竞争导致的性能下降问题。表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整 NIO 线程池的线 程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程 设计相比一个队列—多个工作线程的模型性能更优。

它的设计原理如图 5-5 所示 :

5 Netty 线程模型

Netty 的 NioEventLoop 读取到消息之后,直接调用 ChannelPipeline 的 fireChannelRead (Object msg)。 只 要 用 户 不 主 动 切 换 线 程, 一 直 都 是 由 NioEventLoop 调用用户的 Handler,期间不进行线程切换。这种串行化处理方式 避免了多线程操作导致的锁的竞争,从性能角度看是最优的。

5.3 最佳实践

5.3.1 时间可控的简单业务直接在 I/O 线程上处理

时间可控的简单业务直接在 I/O 线程上处理,如果业务非常简单,执行时间 非常短,不需要与外部网元交互、访问数据库和磁盘,不需要等待其它资源,则 建议直接在业务 ChannelHandler 中执行,不需要再启业务的线程或者线程池。 避免线程上下文切换,也不存在线程并发问题。

5.3.2 复杂和时间不可控业务建议投递到后端业务线程池统一处理

复杂和时间不可控业务建议投递到后端业务线程池统一处理,对于此类业务, 不建议直接在业务 ChannelHandler 中启动线程或者线程池处理,建议将不同的 业务统一封装成 Task,统一投递到后端的业务线程池中进行处理。过多的业务 ChannelHandler 会带来开发效率和可维护性问题,不要把 Netty 当作业务容器, 对于大多数复杂的业务产品,仍然需要集成或者开发自己的业务容器,做好和 Netty 的架构分层。

5.3.3 业务线程避免直接操作 ChannelHandler

业务线程避免直接操作 ChannelHandler,对于 ChannelHandler,IO 线程和 业务线程都可能会操作,因为业务通常是多线程模型,这样就会存在多线程操作 ChannelHandler。为了尽量避免多线程并发问题,建议按照 Netty 自身的做法, 通过将操作封装成独立的 Task 由 NioEventLoop 统一执行,而不是业务线程直接 操作,相关代码如下所示:

5 Netty 线程模型

如果你确认并发访问的数据或者并发操作是安全的,则无需多此一举,这个 需要根据具体的业务场景进行判断,灵活处理。

5.3.4 线程数量计算

推荐的线程数量计算公式有以下两种。

• 公式一:线程数量=(线程总时间/瓶颈资源时间)× 瓶颈资源的线程并 行数;

• 公式二:QPS=1000/线程总时间×线程数。 由于用户场景的不同,对于一些复杂的系统,实际上很难计算出最优线程配 置,只能是根据测试数据和用户场景,结合公式给出一个相对合理的范围,然后 对范围内的数据进行性能测试,选择相对最优值。

备注:本文参考《深入浅出Netty》,作者:李林锋