Netty系列之Netty线程模型

1. 背景

\

1.1. Java线程模型的演进

\

1.1.1. 单线程

\

时间回到十几年前,那时主流的CPU都仍是单核(除了商用高性能的小机),CPU的核心频率是机器最重要的指标之一。java

\

在Java领域当时比较流行的是单线程编程,对于CPU密集型的应用程序而言,频繁的经过多线程进行协做和抢占时间片反而会下降性能。react

\

1.1.2. 多线程

\

随着硬件性能的提高,CPU的核数愈来愈越多,不少服务器标配已经达到32或64核。经过多线程并发编程,能够充分利用多核CPU的处理能力,提高系统的处理效率和并发性能。算法

\

从2005年开始,随着多核处理器的逐步普及,java的多线程并发编程也逐渐流行起来,当时商用主流的JDK版本是1.4,用户能够经过 new Thread()的方式建立新的线程。数据库

\

因为JDK1.4并无提供相似线程池这样的线程管理容器,多线程之间的同步、协做、建立和销毁等工做都须要用户本身实现。因为建立和销毁线程是个相对比较重量级的操做,所以,这种原始的多线程编程效率和性能都不高。编程

\

1.1.3. 线程池

\

为了提高Java多线程编程的效率和性能,下降用户开发难度。JDK1.5推出了java.util.concurrent并发编程包。在并发编程类库中,提供了线程池、线程安全容器、原子类等新的类库,极大的提高了Java多线程编程的效率,下降了开发难度。后端

\

从JDK1.5开始,基于线程池的并发编程已经成为Java多核编程的主流。设计模式

\

1.2. Reactor模型

\

不管是C++仍是Java编写的网络框架,大多数都是基于Reactor模式进行设计和开发,Reactor模式基于事件驱动,特别适合处理海量的I/O事件。数组

\

1.2.1. 单线程模型

\

Reactor单线程模型,指的是全部的IO操做都在同一个NIO线程上面完成,NIO线程的职责以下:安全

\

1)做为NIO服务端,接收客户端的TCP链接;性能优化

\

2)做为NIO客户端,向服务端发起TCP链接;

\

3)读取通讯对端的请求或者应答消息;

\

4)向通讯对端发送消息请求或者应答消息。

\

Reactor单线程模型示意图以下所示:

\

6f640b55857b6b25035d11e7fb351a98.png

\

图1-1 Reactor单线程模型

\

因为Reactor模式使用的是异步非阻塞IO,全部的IO操做都不会致使阻塞,理论上一个线程能够独立处理全部IO相关的操做。从架构层面看,一个NIO线程确实能够完成其承担的职责。例如,经过Acceptor类接收客户端的TCP链接请求消息,链路创建成功以后,经过Dispatch将对应的ByteBuffer派发到指定的Handler上进行消息解码。用户线程能够经过消息编码经过NIO线程将消息发送给客户端。

\

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

\

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

\

2)当NIO线程负载太重以后,处理速度将变慢,这会致使大量客户端链接超时,超时以后每每会进行重发,这更加剧了NIO线程的负载,最终会致使大量消息积压和处理超时,成为系统的性能瓶颈;

\

3)可靠性问题:一旦NIO线程意外跑飞,或者进入死循环,会致使整个系统通讯模块不可用,不能接收和处理外部消息,形成节点故障。

\

为了解决这些问题,演进出了Reactor多线程模型,下面咱们一块儿学习下Reactor多线程模型。

\

1.2.2. 多线程模型

\

Rector多线程模型与单线程模型最大的区别就是有一组NIO线程处理IO操做,它的原理图以下:

\

b6a4229f5bf7adadc2d5f5b26911f6fc.png

\

图1-2 Reactor多线程模型

\

Reactor多线程模型的特色:

\

1)有专门一个NIO线程-Acceptor线程用于监听服务端,接收客户端的TCP链接请求;

\

2)网络IO操做-读、写等由一个NIO线程池负责,线程池能够采用标准的JDK线程池实现,它包含一个任务队列和N个可用的线程,由这些NIO线程负责消息的读取、解码、编码和发送;

\

3)1个NIO线程能够同时处理N条链路,可是1个链路只对应1个NIO线程,防止发生并发操做问题。

\

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

\

1.2.3. 主从多线程模型

\

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

\

它的线程模型以下图所示:

\

a46b34a7b47249a31b6bcb8a486c9495.png

\

图1-3 主从Reactor多线程模型

\

利用主从NIO线程模型,能够解决1个服务端监听线程没法有效处理全部客户端链接的性能不足问题。

\

它的工做流程总结以下:

\
  1. 从主线程池中随机选择一个Reactor线程做为Acceptor线程,用于绑定监听端口,接收客户端链接;\
  2. Acceptor线程接收客户端链接请求以后建立新的SocketChannel,将其注册到主线程池的其它Reactor线程上,由其负责接入认证、IP黑白名单过滤、握手等操做;\
  3. 步骤2完成以后,业务层的链路正式创建,将SocketChannel从主线程池的Reactor线程的多路复用器上摘除,从新注册到Sub线程池的线程上,用于处理I/O的读写操做。\

2. Netty线程模型

\

2.1. Netty线程模型分类

\

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

\

2.1.1. 服务端线程模型

\

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

\

e5da0c60482a1b0f48374ad571822a63.png

\

图2-1 Netty服务端线程工做流程

\

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

\

第一步,从用户线程发起建立服务端操做,代码以下:

\

27782f4868c0b78ea0b5abbf5662c27d.png

\

图2-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服务端,相关代码以下:

\

4e9822c6479ae083c792fe2f2260d826.png

\

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

\

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

\

3af37ba5d92b191938f2496085583c43.png

\

图2-4 选择Acceptor线程

\

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

\

b47b27622b260b7cdb9bfa3fb6f3cb72.png

\

图2-5 注册ServerSocketChannel 到Selector

\

第三步,若是监听到客户端链接,则建立客户端SocketChannel链接,从新注册到workerGroup的IO线程上。首先看Acceptor如何处理客户端的接入:

\

5d78ba680395bc01fdc20938cf3a0dcd.png

\

图2-6 处理读或者链接事件

\

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

\

ca4c98778a5c1b4f1613bf8b4c8a987e.png

\

图2-7 NioServerSocketChannel的read()方法

\

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

\

547f875df3e4357de21573281c31a6c6.png

\

图2-8 建立客户端链接SocketChannel

\

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

\

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

\

76eb89a529674f4be6a0b58be1ea5775.png

\

图2-9 监听网络读事件

\

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

\

10b4359d6308df0f8aa328936e4f60d0.png

\

图2-10 处理读写事件

\

2.1.2. 客户端线程模型

\

相比于服务端,客户端的线程模型简单一些,它的工做原理以下:

\

6c0700d85a33d2bd5d6528b764c1e464.png

\

图2-11 Netty客户端线程模型

\

第一步,由用户线程发起客户端链接,示例代码以下:

\

d8328d5493ec86bc695891dae9f0279b.png

\

图2-12 Netty客户端建立代码示例

\

你们发现相比于服务端,客户端只须要建立一个EventLoopGroup,由于它不须要独立的线程去监听客户端链接,也不必经过一个单独的客户端线程去链接服务端。Netty是异步事件驱动的NIO框架,它的链接和全部IO操做都是异步的,所以不须要建立单独的链接线程。相关代码以下:

\

f0d3b4d5d3e3873b8a6dec0382ec20ef.png

\

图2-13 绑定客户端链接线程

\

当前的group()就是以前传入的EventLoopGroup,从中获取可用的IO线程EventLoop,而后做为参数设置到新建立的NioSocketChannel中。

\

第二步,发起链接操做,判断链接结果,代码以下:

\

884351aea5efccc5283ad1248c602656.png

\

图2-14 链接操做

\

判断链接结果,若是没有链接成功,则监听链接网络操做位SelectionKey.OP_CONNECT。若是链接成功,则调用pipeline().fireChannelActive()将监听位修改成READ。

\

第三步,由NioEventLoop的多路复用器轮询链接操做结果,代码以下:

\

b0210477718e66fb8bda5fccd62d9163.png

\

图2-15 Selector发起轮询操做

\

判断链接结果,若是或链接成功,从新设置监听位为READ:

\

68440c6c28d0372dddde460c31abef8d.png

\

图2-16 判断链接操做结果

\

09d591e448e0996543e5b26276b3fb1f.png

\

图2-17 设置操做位为READ

\

第四步,由NioEventLoop线程负责I/O读写,同服务端。

\

总结:客户端建立,线程模型以下:

\
  1. 由用户线程负责初始化客户端资源,发起链接操做;\
  2. 若是链接成功,将SocketChannel注册到IO线程组的NioEventLoop线程中,监听读操做位;\
  3. 若是没有当即链接成功,将SocketChannel注册到IO线程组的NioEventLoop线程中,监听链接操做位;\
  4. 链接成功以后,修改监听位为READ,可是不须要切换线程。\

2.2. Reactor线程NioEventLoop

\

2.2.1. NioEventLoop介绍

\

NioEventLoop是Netty的Reactor线程,它的职责以下:

\
  1. 做为服务端Acceptor线程,负责处理客户端的请求接入;\
  2. 做为客户端Connecor线程,负责注册监听链接操做位,用于判断异步链接结果;\
  3. 做为IO线程,监听网络读操做位,负责从SocketChannel中读取报文;\
  4. 做为IO线程,负责向SocketChannel写入报文发送给对方,若是发生写半包,会自动注册监听写事件,用于后续继续发送半包数据,直到数据所有发送完成;\
  5. 做为定时任务线程,能够执行定时任务,例如链路空闲检测和发送心跳消息等;\
  6. 做为线程执行器能够执行普通的任务线程(Runnable)。\

在服务端和客户端线程模型章节咱们已经详细介绍了NioEventLoop如何处理网络IO事件,下面咱们简单看下它是如何处理定时任务和执行普通的Runnable的。

\

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

\

9c83a2110e86501e905e5b6224395fc5.png

\

图2-18 NioEventLoop继承关系

\

c72d68d4e600d993aa4beb629e1fe8dc.png

\

图2-19 线程池和任务队列定义

\

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

\

b2b062625d3c0a7d99b03342f3c47241.png

\

图2-20 执行用户自定义Task

\

5ffae32af134e3ebcae78e85cc761bd5.png

\

图2-21 NioEventLoop实现ScheduledExecutorService

\

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

\

fd881581efab51e53d085f4fa8ed43db.png

\

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

\

2.3. NioEventLoop设计原理

\

2.3.1. 串行化设计避免线程竞争

\

咱们知道当系统在运行过程当中,若是频繁的进行线程上下文切换,会带来额外的性能损耗。多线程并发执行某个业务流程,业务开发者还须要时刻对线程安全保持警戒,哪些数据可能会被并发修改,如何保护?这不只下降了开发效率,也会带来额外的性能损耗。

\

串行执行Handler链

\

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

\\

c9cea26e2e1d13f7b9c2f44b101d3f49.png

\

图2-23 NioEventLoop串行执行ChannelHandler

\

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

\

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

\

2.3.2. 定时任务与时间轮算法

\

在Netty中,有不少功能依赖定时任务,比较典型的有两种:

\
  1. 客户端链接超时控制;\
  2. 链路空闲检测。\

一种比较经常使用的设计理念是在NioEventLoop中聚合JDK的定时任务线程池ScheduledExecutorService,经过它来执行定时任务。这样作单纯从性能角度看不是最优,缘由有以下三点:

\
  1. 在IO线程中聚合了一个独立的定时任务线程池,这样在处理过程当中会存在线程上下文切换问题,这就打破了Netty的串行化设计理念;\
  2. 存在多线程并发操做问题,由于定时任务Task和IO线程NioEventLoop可能同时访问并修改同一份数据;\
  3. 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的结构,它的原理图以下:

\

5f516670720748377c9c80284abb38ab.png

\

图2-24 时间轮工做原理

\

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

\

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

\

248b3e8b4774fcd278c7b8b84ddc7586.png

\

图2-25 执行任务队列

\

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

\

994fef0eb013f90c54c389cf25d6b8bb.png

\

图2-26 计算时延

\

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

\

fdfeac30d5bbea3e1d846ab5fd748502.png

\

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

\

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

\

d604de6b2acd437bd2befcc66703240e.png

\

图2-28 检测超时的定时任务

\

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

\

c1ab61b0444f74d7948d73ab3a33aba0.png

\

图2-29 执行定时任务

\

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

\

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

\

90bc3dd2b1c50bb527e9d9ab18e945a8.png

\

图2-30 执行时间上限检测

\

2.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线程自己,而不是提供用户相关的业务线程模型。

\

2.4. Netty线程开发最佳实践

\

2.4.1. 时间可控的简单业务直接在IO线程上处理

\

若是业务很是简单,执行时间很是短,不须要与外部网元交互、访问数据库和磁盘,不须要等待其它资源,则建议直接在业务ChannelHandler中执行,不须要再启业务的线程或者线程池。避免线程上下文切换,也不存在线程并发问题。

\

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

\

对于此类业务,不建议直接在业务ChannelHandler中启动线程或者线程池处理,建议将不一样的业务统一封装成Task,统一投递到后端的业务线程池中进行处理。

\

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

\

2.4.3. 业务线程避免直接操做ChannelHandler

\

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

\

3a75dd7218c5846532722176255c2ee0.png

\

图2-31 封装成Task防止多线程并发操做

\

若是你确认并发访问的数据或者并发操做是安全的,则无需画蛇添足,这个须要根据具体的业务场景进行判断,灵活处理。

\

3. 总结

\

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

\

4. Netty学习推荐书籍

\

目前市面上介绍netty的文章不少,若是读者但愿系统性的学习Netty,推荐两本书:

\

1) 《Netty in Action》,建议阅读英文原版。

\

2) 《Netty权威指南》,建议经过理论联系实际方式学习。

\

5. 做者简介

\

李林锋,2007年毕业于东北大学,2008年进入华为公司从事高性能通讯软件的设计和开发工做,有6年NIO设计和开发经验,精通Netty、Mina等NIO框架,Netty中国社区创始人和Netty框架推广者。

\

新浪微博:Nettying 微信:Nettying Netty学习群:195820454

\

感谢郭蕾对本文的审校和策划。

\

给InfoQ中文站投稿或者参与内容翻译工做,请邮件至editors@cn.infoq.com。也欢迎你们经过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注咱们,并与咱们的编辑和其余读者朋友交流。