Netty(十五) Netty之线程模型

Netty线程模型,解释的比较详细,参考:https://www.infoq.cn/article/netty-threading-model。文章摘录最主要部分,看完让我茅塞顿开。

1 Netty 线程模型
1.1 Netty 线程模型分类
事实上,Netty 的线程模型与 1.2 章节中介绍的三种 Reactor 线程模型相似,下面章节我们通过 Netty 服务端和客户端的线程处理流程图来介绍 Netty 的线程模型。

1.1.1. 服务端线程模型
一种比较流行的做法是服务端监听线程和 IO 线程分离,类似于 Reactor 的多线程模型,它的工作原理图如下:

在这里插入图片描述

图 1-1 Netty 服务端线程工作流程

下面我们结合 Netty 的源码,对服务端创建线程工作流程进行介绍:

第一步,从用户线程发起创建服务端操作,代码如下:

在这里插入图片描述

图 1-2 用户线程创建服务端代码示例

通常情况下,服务端的创建是在用户进程启动的时候进行,因此一般由 Main 函数或者启动类负责创建,服务端的创建由业务线程负责完成。在创建服务端的时候实例化了 2 个 EventLoopGroup,1 个 EventLoopGroup 实际就是一个 EventLoop 线程组,负责管理 EventLoop 的申请和释放。

EventLoopGroup 管理的线程数可以通过构造函数设置,如果没有设置,默认取 -Dio.netty.eventLoopThreads,如果该系统参数也没有指定,则为可用的 CPU 内核数 × 2。

bossGroup 线程组实际就是 Acceptor 线程池,负责处理客户端的 TCP 连接请求,如果系统只有一个服务端端口需要监听,则建议 bossGroup 线程组线程数设置为 1。

workerGroup 是真正负责 I/O 读写操作的线程组,通过 ServerBootstrap 的 group 方法进行设置,用于后续的 Channel 绑定。

第二步,Acceptor 线程绑定监听端口,启动 NIO 服务端,相关代码如下:

在这里插入图片描述

图 1-3 从 bossGroup 中选择一个 Acceptor 线程监听服务端

其中,group() 返回的就是 bossGroup,它的 next 方法用于从线程组中获取可用线程,代码如下:

在这里插入图片描述
图 1-4 选择 Acceptor 线程

服务端 Channel 创建完成之后,将其注册到多路复用器 Selector 上,用于接收客户端的 TCP 连接,核心代码如下:

在这里插入图片描述

图 1-5 注册 ServerSocketChannel 到 Selector

第三步,如果监听到客户端连接,则创建客户端 SocketChannel 连接,重新注册到 workerGroup 的 IO 线程上。首先看 Acceptor 如何处理客户端的接入:

在这里插入图片描述

图 1-6 处理读或者连接事件

调用 unsafe 的 read()方法,对于 NioServerSocketChannel,它调用了 NioMessageUnsafe 的 read() 方法,代码如下:

在这里插入图片描述

图 1-7 NioServerSocketChannel 的 read() 方法

最终它会调用 NioServerSocketChannel 的 doReadMessages 方法,代码如下:

在这里插入图片描述

图 1-8 创建客户端连接 SocketChannel

其中 childEventLoopGroup 就是之前的 workerGroup, 从中选择一个 I/O 线程负责网络消息的读写。

第四步,选择 IO 线程之后,将 SocketChannel 注册到多路复用器上,监听 READ 操作。

在这里插入图片描述

图 1-9 监听网络读事件

第五步,处理网络的 I/O 读写事件,核心代码如下:

在这里插入图片描述
图 1-10 处理读写事件

1.2. Reactor 线程 NioEventLoop
1.2.1. NioEventLoop 介绍
NioEventLoop 是 Netty 的 Reactor 线程,它的职责如下:

作为服务端 Acceptor 线程,负责处理客户端的请求接入;
作为客户端 Connecor 线程,负责注册监听连接操作位,用于判断异步连接结果;
作为 IO 线程,监听网络读操作位,负责从 SocketChannel 中读取报文;
作为 IO 线程,负责向 SocketChannel 写入报文发送给对方,如果发生写半包,会自动注册监听写事件,用于后续继续发送半包数据,直到数据全部发送完成;
作为定时任务线程,可以执行定时任务,例如链路空闲检测和发送心跳消息等;
作为线程执行器可以执行普通的任务线程(Runnable)。
在服务端和客户端线程模型章节我们已经详细介绍了 NioEventLoop 如何处理网络 IO 事件,下面我们简单看下它是如何处理定时任务和执行普通的 Runnable 的。

首先 NioEventLoop 继承 SingleThreadEventExecutor,这就意味着它实际上是一个线程个数为 1 的线程池,类继承关系如下所示:

在这里插入图片描述

NioEventLoop 继承关系

在这里插入图片描述
图 线程池和任务队列定义

对于用户而言,直接调用 NioEventLoop 的 execute(Runnable task) 方法即可执行自定义的 Task,代码实现如下:

在这里插入图片描述
图 执行用户自定义 Task

在这里插入图片描述

图 NioEventLoop 实现 ScheduledExecutorService

通过调用 SingleThreadEventExecutor 的 schedule 系列方法,可以在 NioEventLoop 中执行 Netty 或者用户自定义的定时任务,接口定义如下:

在这里插入图片描述

图 NioEventLoop 的定时任务执行接口定义

1.3. NioEventLoop 设计原理
1.3.1. 串行化设计避免线程竞争
我们知道当系统在运行过程中,如果频繁的进行线程上下文切换,会带来额外的性能损耗。多线程并发执行某个业务流程,业务开发者还需要时刻对线程安全保持警惕,哪些数据可能会被并发修改,如何保护?这不仅降低了开发效率,也会带来额外的性能损耗。

串行执行 Handler 链

为了解决上述问题,Netty 采用了串行化设计理念,从消息的读取、编码以及后续 Handler 的执行,始终都由 IO 线程 NioEventLoop 负责,这就意外着整个流程不会进行线程上下文的切换,数据也不会面临被并发修改的风险,对于用户而言,甚至不需要了解 Netty 的线程细节,这确实是个非常好的设计理念,它的工作原理图如下:

在这里插入图片描述
图 NioEventLoop 串行执行 ChannelHandler

一个 NioEventLoop 聚合了一个多路复用器 Selector,因此可以处理成百上千的客户端连接,Netty 的处理策略是每当有一个新的客户端接入,则从 NioEventLoop 线程组中顺序获取一个可用的 NioEventLoop,当到达数组上限之后,重新返回到 0,通过这种方式,可以基本保证各个 NioEventLoop 的负载均衡。一个客户端连接只注册到一个 NioEventLoop 上,这样就避免了多个 IO 线程去并发操作它。

Netty 通过串行化设计理念降低了用户的开发难度,提升了处理性能。利用线程组实现了多个串行化线程水平并行执行,线程之间并没有交集,这样既可以充分利用多核提升并行处理能力,同时避免了线程上下文的切换和并发保护带来的额外性能损耗。

2.3.2. 定时任务与时间轮算法
在 Netty 中,有很多功能依赖定时任务,比较典型的有两种:

客户端连接超时控制;
链路空闲检测。
一种比较常用的设计理念是在 NioEventLoop 中聚合 JDK 的定时任务线程池 ScheduledExecutorService,通过它来执行定时任务。这样做单纯从性能角度看不是最优,原因有如下三点:

在 IO 线程中聚合了一个独立的定时任务线程池,这样在处理过程中会存在线程上下文切换问题,这就打破了 Netty 的串行化设计理念;
存在多线程并发操作问题,因为定时任务 Task 和 IO 线程 NioEventLoop 可能同时访问并修改同一份数据;
JDK 的 ScheduledExecutorService 从性能角度看,存在性能优化空间。
最早面临上述问题的是操作系统和协议栈,例如 TCP 协议栈,其可靠传输依赖超时重传机制,因此每个通过 TCP 传输的 packet 都需要一个 timer 来调度 timeout 事件。这类超时可能是海量的,如果为每个超时都创建一个定时器,从性能和资源消耗角度看都是不合理的。

根据 George Varghese 和 Tony Lauck 1996 年的论文《Hashed and Hierarchical Timing Wheels: data structures to efficiently implement a timer facility》提出了一种定时轮的方式来管理和维护大量的 timer 调度。Netty 的定时任务调度就是基于时间轮算法调度,下面我们一起来看下 Netty 的实现。

定时轮是一种数据结构,其主体是一个循环列表,每个列表中包含一个称之为 slot 的结构,它的原理图如下:

在这里插入图片描述

图 时间轮工作原理

定时轮的工作原理可以类比于时钟,如上图箭头(指针)按某一个方向按固定频率轮动,每一次跳动称为一个 tick。这样可以看出定时轮由个 3 个重要的属性参数:ticksPerWheel(一轮的 tick 数),tickDuration(一个 tick 的持续时间)以及 timeUnit(时间单位),例如当 ticksPerWheel=60,tickDuration=1,timeUnit= 秒,这就和时钟的秒针走动完全类似了。

下面我们具体分析下 Netty 的实现:时间轮的执行由 NioEventLoop 来复杂检测,首先看任务队列中是否有超时的定时任务和普通任务,如果有则按照比例循环执行这些任务,代码如下:

在这里插入图片描述
图 执行任务队列

如果没有需要理解执行的任务,则调用 Selector 的 select 方法进行等待,等待的时间为定时任务队列中第一个超时的定时任务时延,代码如下:

在这里插入图片描述
图 计算时延

从定时任务 Task 队列中弹出 delay 最小的 Task,计算超时时间,代码如下:

在这里插入图片描述

图 从定时任务队列中获取超时时间

定时任务的执行:经过周期 tick 之后,扫描定时任务列表,将超时的定时任务移除到普通任务队列中,等待执行,相关代码如下:

在这里插入图片描述

图 检测超时的定时任务

检测和拷贝任务完成之后,就执行超时的定时任务,代码如下:

在这里插入图片描述

图 执行定时任务

为了保证定时任务的执行不会因为过度挤占 IO 事件的处理,Netty 提供了 IO 执行比例供用户设置,用户可以设置分配给 IO 的执行比例,防止因为海量定时任务的执行导致 IO 处理超时或者积压。

因为获取系统的纳秒时间是件耗时的操作,所以 Netty 每执行 64 个定时任务检测一次是否达到执行的上限时间,达到则退出。如果没有执行完,放到下次 Selector 轮询时再处理,给 IO 事件的处理提供机会,代码如下:

在这里插入图片描述
图 执行时间上限检测

1.3.3. 聚焦而不是膨胀
Netty 是个异步高性能的 NIO 框架,它并不是个业务运行容器,因此它不需要也不应该提供业务容器和业务线程。合理的设计模式是 Netty 只负责提供和管理 NIO 线程,其它的业务层线程模型由用户自己集成,Netty 不应该提供此类功能,只要将分层划分清楚,就会更有利于用户集成和扩展。

令人遗憾的是在 Netty 3 系列版本中,Netty 提供了类似 Mina 异步 Filter 的 ExecutionHandler,它聚合了 JDK 的线程池 java.util.concurrent.Executor,用户异步执行后续的 Handler。

ExecutionHandler 是为了解决部分用户 Handler 可能存在执行时间不确定而导致 IO 线程被意外阻塞或者挂住,从需求合理性角度分析这类需求本身是合理的,但是 Netty 提供该功能却并不合适。原因总结如下:

  1. 它打破了 Netty 坚持的串行化设计理念,在消息的接收和处理过程中发生了线程切换并引入新的线程池,打破了自身架构坚守的设计原则,实际是一种架构妥协;

  2. 潜在的线程并发安全问题,如果异步 Handler 也操作它前面的用户 Handler,而用户 Handler 又没有进行线程安全保护,这就会导致隐蔽和致命的线程安全问题;

  3. 用户开发的复杂性,引入 ExecutionHandler,打破了原来的 ChannelPipeline 串行执行模式,用户需要理解 Netty 底层的实现细节,关心线程安全等问题,这会导致得不偿失。

鉴于上述原因,Netty 的后续版本彻底删除了 ExecutionHandler,而且也没有提供类似的相关功能类,把精力聚焦在 Netty 的 IO 线程 NioEventLoop 上,这无疑是一种巨大的进步,Netty 重新开始聚焦在 IO 线程本身,而不是提供用户相关的业务线程模型。

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

1.4.2. 复杂和时间不可控业务建议投递到后端业务线程池统一处理
对于此类业务,不建议直接在业务 ChannelHandler 中启动线程或者线程池处理,建议将不同的业务统一封装成 Task,统一投递到后端的业务线程池中进行处理。

过多的业务 ChannelHandler 会带来开发效率和可维护性问题,不要把 Netty 当作业务容器,对于大多数复杂的业务产品,仍然需要集成或者开发自己的业务容器,做好和 Netty 的架构分层。

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

在这里插入图片描述
图 封装成 Task 防止多线程并发操作

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

2.总结
尽管 Netty 的线程模型并不复杂,但是如何合理利用 Netty 开发出高性能、高并发的业务产品,仍然是个有挑战的工作。只有充分理解了 Netty 的线程模型和设计原理,才能开发出高质量的产品。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值