本文共 9564 字,大约阅读时间需要 31 分钟。
《Netty 进阶之路》、《分布式服务框架原理与实践》作者李林锋手把手教你Netty框架如何学习和进阶。李林锋此后还将在InfoQ上开设Netty专题持续出稿,感兴趣的同学可以持续关注。
Netty的一个特点就是入门相对比较容易,但是真正掌握并精通是非常困难的,原因有如下几个:
对于很多初学者,在学习过程中经常会遇到如下几个问题:
Netty入门相对简单,但是要在实际项目中用好它,出了问题能够快速定位和解决,却并非易事。只有在入门阶段扎实的学好Netty,后面使用才能够得心应手。
需要熟悉和掌握的类库主要包括:
首先介绍缓冲区(Buffer)的概念,Buffer是一个对象,它包含一些要写入或者要读出的数据。在NIO类库中加入Buffer对象,体现了新库与原I/O的一个重要区别。在面向流的I/O中,可以将数据直接写入或者将数据直接读到Stream对象中。在NIO库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的;在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。
缓冲区实质上是一个数组。通常它是一个字节数组(ByteBuffer),也可以使用其他种类的数组。但是一个缓冲区不仅仅是一个数组,缓冲区提供了对数据的结构化访问以及维护读写位置(limit)等信息。
最常用的缓冲区是ByteBuffer,一个ByteBuffer提供了一组功能用于操作byte数组。比较常用的就是get和put系列方法,如下所示:
Channel是一个通道,可以通过它读取和写入数据,它就像自来水管一样,网络数据通过Channel读取和写入。通道与流的不同之处在于通道是双向的,流只是在一个方向上移动(一个流必须是InputStream或者OutputStream的子类),而且通道可以用于读、写或者同时用于读写。因为Channel是全双工的,所以它可以比流更好地映射底层操作系统的API。特别是在UNIX网络编程模型中,底层操作系统的通道都是全双工的,同时支持读写操作。
比较常用的Channel是SocketChannel和ServerSocketChannel,其中SocketChannel的继承关系如下图所示:
Selector是Java NIO编程的基础,熟练地掌握Selector对于掌握NIO编程至关重要。多路复用器提供选择已经就绪的任务的能力。简单来讲,Selector会不断地轮询注册在其上的Channel,如果某个Channel上面有新的TCP连接接入、读和写事件,这个Channel就处于就绪状态,会被Selector轮询出来,然后通过SelectionKey可以获取就绪Channel的集合,进行后续的I/O操作。
作为异步事件驱动、高性能的NIO框架,Netty代码中大量运用了Java多线程编程技巧,熟练掌握多线程编程是掌握Netty的必备条件。
需要掌握的多线程编程相关知识包括:
以关键字synchronized为例,它可以保证在同一时刻,只有一个线程可以执行某一个方法或者代码块。同步的作用不仅仅是互斥,它的另一个作用就是共享可变性,当某个线程修改了可变数据并释放锁后,其它的线程可以获取被修改变量的最新值。如果没有正确的同步,这种修改对其它线程是不可见的。
下面我们就通过对Netty的源码进行分析,看看Netty是如何对并发可变数据进行正确同步的。以AbstractBootstrap为例进行分析,首先看它的option方法:
这个方法的作用是设置ServerBootstrap或Bootstrap的Socket属性,它的属性集定义如下:
private final Map\u0026lt;ChannelOption\u0026lt;?\u0026gt;, Object\u0026gt; options = new LinkedHashMap\u0026lt;ChannelOption\u0026lt;?\u0026gt;, Object\u0026gt;();
由于是非线程安全的LinkedHashMap,所以如果多线程创建、访问和修改LinkedHashMap时,必须在外部进行必要的同步。由于ServerBootstrap和Bootstrap被调用方线程创建和使用,无法保证它的方法和成员变量不被并发访问。因此,作为成员变量的options必须进行正确的同步。由于考虑到锁的范围需要尽可能的小,所以对传参的option和value的合法性判断不需要加锁,保证锁的范围尽可能的细粒度。
Netty加锁的地方非常多,大家在阅读代码的时候可以仔细体会下,为什么有的地方要加锁,有的地方有不需要?如果不需要,为什么?当你对锁的原理理解以后,对于这些锁的使用时机和技巧理解起来就相对容易了。
Netty的核心类库可以分为5大类,需要熟练掌握:
1、ByteBuf和相关辅助类:ByteBuf是个Byte数组的缓冲区,它的基本功能应该与JDK的ByteBuffer一致,提供以下几类基本功能:
从内存分配的角度看,ByteBuf可以分为两类:堆内存(HeapByteBuf)字节缓冲区:特点是内存的分配和回收速度快,可以被JVM自动回收;缺点就是如果进行Socket的I/O读写,需要额外做一次内存复制,将堆内存对应的缓冲区复制到内核Channel中,性能会有一定程度的下降。直接内存(DirectByteBuf)字节缓冲区:非堆内存,它在堆外进行内存分配,相比于堆内存,它的分配和回收速度会慢一些,但是将它写入或者从Socket Channel中读取时,由于少了一次内存复制,速度比堆内存快。
2、Channel和Unsafe:io.netty.channel.Channel是Netty网络操作抽象类,它聚合了一组功能,包括但不限于网路的读、写,客户端发起连接、主动关闭连接,链路关闭,获取通信双方的网络地址等。它也包含了Netty框架相关的一些功能,包括获取该Chanel的EventLoop,获取缓冲分配器ByteBufAllocator和pipeline等。Unsafe是个内部接口,聚合在Channel中协助进行网络读写相关的操作,它提供的主要功能如下表所示:
3、ChannelPipeline和ChannelHandler: Netty的ChannelPipeline和ChannelHandler机制类似于Servlet和Filter过滤器,这类拦截器实际上是职责链模式的一种变形,主要是为了方便事件的拦截和用户业务逻辑的定制。Servlet Filter是JEE Web应用程序级的Java代码组件,它能够以声明的方式插入到HTTP请求响应的处理过程中,用于拦截请求和响应,以便能够查看、提取或以某种方式操作正在客户端和服务器之间交换的数据。拦截器封装了业务定制逻辑,能够实现对Web应用程序的预处理和事后处理。过滤器提供了一种面向对象的模块化机制,用来将公共任务封装到可插入的组件中。这些组件通过Web部署配置文件(web.xml)进行声明,可以方便地添加和删除过滤器,无须改动任何应用程序代码或JSP页面,由Servlet进行动态调用。通过在请求/响应链中使用过滤器,可以对应用程序(而不是以任何方式替代)的Servlet或JSP页面提供的核心处理进行补充,而不破坏Servlet或JSP页面的功能。由于是纯Java实现,所以Servlet过滤器具有跨平台的可重用性,使得它们很容易地被部署到任何符合Servlet规范的JEE环境中。
Netty的Channel过滤器实现原理与Servlet Filter机制一致,它将Channel的数据管道抽象为ChannelPipeline,消息在ChannelPipeline中流动和传递。ChannelPipeline持有I/O事件拦截器ChannelHandler的链表,由ChannelHandler对I/O事件进行拦截和处理,可以方便地通过新增和删除ChannelHandler来实现不同的业务逻辑定制,不需要对已有的ChannelHandler进行修改,能够实现对修改封闭和对扩展的支持。ChannelPipeline是ChannelHandler的容器,它负责ChannelHandler的管理和事件拦截与调度:
Netty中的事件分为inbound事件和outbound事件。inbound事件通常由I/O线程触发,例如TCP链路建立事件、链路关闭事件、读事件、异常通知事件等。
Outbound事件通常是由用户主动发起的网络I/O操作,例如用户发起的连接操作、绑定操作、消息发送等操作。ChannelHandler类似于Servlet的Filter过滤器,负责对I/O事件或者I/O操作进行拦截和处理,它可以选择性地拦截和处理自己感兴趣的事件,也可以透传和终止事件的传递。基于ChannelHandler接口,用户可以方便地进行业务逻辑定制,例如打印日志、统一封装异常信息、性能统计和消息编解码等。
4、EventLoop:Netty的NioEventLoop并不是一个纯粹的I/O线程,它除了负责I/O的读写之外,还兼顾处理以下两类任务:
普通Task:通过调用NioEventLoop的execute(Runnable task)方法实现,Netty有很多系统Task,创建它们的主要原因是:当I/O线程和用户线程同时操作网络资源时,为了防止并发操作导致的锁竞争,将用户线程的操作封装成Task放入消息队列中,由I/O线程负责执行,这样就实现了局部无锁化。
定时任务:通过调用NioEventLoop的schedule(Runnable command, long delay, TimeUnit unit)方法实现。
Netty的线程模型并不是一成不变的,它实际取决于用户的启动参数配置。通过设置不同的启动参数,Netty可以同时支持Reactor单线程模型、多线程模型和主从Reactor多线层模型。它的工作原理如下所示:
通过调整线程池的线程个数、是否共享线程池等方式,Netty的Reactor线程模型可以在单线程、多线程和主从多线程间切换,这种灵活的配置方式可以最大程度地满足不同用户的个性化定制。
为了尽可能地提升性能,Netty在很多地方进行了无锁化的设计,例如在I/O线程内部进行串行操作,避免多线程竞争导致的性能下降问题。表面上看,串行化设计似乎CPU利用率不高,并发程度不够。但是,通过调整NIO线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列—多个工作线程的模型性能更优。它的设计原理如下图所示:
5、Future和Promise:在Netty中,所有的I/O操作都是异步的,这意味着任何I/O调用都会立即返回,而不是像传统BIO那样同步等待操作完成。异步操作会带来一个问题:调用者如何获取异步操作的结果?ChannelFuture就是为了解决这个问题而专门设计的。下面我们一起看它的原理。ChannelFuture有两种状态:uncompleted和completed。当开始一个I/O操作时,一个新的ChannelFuture被创建,此时它处于uncompleted状态——非失败、非成功、非取消,因为I/O操作此时还没有完成。一旦I/O操作完成,ChannelFuture将会被设置成completed,它的结果有如下三种可能:
ChannelFuture的状态迁移图如下所示:
Promise是可写的Future,Future自身并没有写操作相关的接口,Netty通过Promise对Future进行扩展,用于设置I/O操作的结果,它的接口定义如下:
需要重点掌握Netty服务端和客户端的创建,以及创建过程中使用到的核心类库和API、以及消息的发送和接收、消息的编解码。
Netty服务端创建流程如下:
Netty客户端创建流程如下:
实践主要分为两类,如果项目中需要用到Netty,则直接在项目中应用,通过实践来不断提升对Netty的理解和掌握。如果暂时使用不到,则可以通过学习一些开源的RPC或者服务框架,看这些框架是怎么集成并使用Netty的。以gRPC Java版为例,我们一起看下gRPC是如何使用Netty的。
gRPC通过对Netty HTTP/2的封装,向用户屏蔽底层RPC通信的协议细节,Netty HTTP/2服务端的创建流程如下:
服务端HTTP/2消息的读写主要通过gRPC的NettyServerHandler实现,它的类继承关系如下所示:
从类继承关系可以看出,NettyServerHandler主要负责HTTP/2协议消息相关的处理,例如HTTP/2请求消息体和消息头的读取、Frame消息的发送、Stream状态消息的处理等,相关接口定义如下:
gRPC的客户端调用主要包括基于Netty的HTTP/2客户端创建、客户端负载均衡、请求消息的发送和响应接收处理四个流程,gRPC的客户端调用总体流程如下图所示:
gRPC的客户端调用总体流程如下图所示:
gRPC的客户端调用流程如下:
客户端Stub(GreeterBlockingStub)调用sayHello(request),发起RPC调用。
通过DnsNameResolver进行域名解析,获取服务端的地址信息(列表),随后使用默认的LoadBalancer策略,选择一个具体的gRPC服务端实例。
如果与路由选中的服务端之间没有可用的连接,则创建NettyClientTransport和NettyClientHandler,发起HTTP/2连接。
对请求消息使用PB(Protobuf)做序列化,通过HTTP/2 Stream发送给gRPC服务端。
接收到服务端响应之后,使用PB(Protobuf)做反序列化。
回调GrpcFuture的set(Response)方法,唤醒阻塞的客户端调用线程,获取RPC响应。
需要指出的是,客户端同步阻塞RPC调用阻塞的是调用方线程(通常是业务线程),底层Transport的I/O线程(Netty的NioEventLoop)仍然是非阻塞的。
gRPC服务端线程模型整体上可以分为两大类:
gRPC服务端线程模型和交互图如下所示:
其中,HTTP/2服务端创建、HTTP/2请求消息的接入和响应发送都由Netty负责,gRPC消息的序列化和反序列化、以及应用服务接口的调用由gRPC的SerializingExecutor线程池负责。
gRPC客户端的线程主要分为三类:
gRPC客户端线程模型工作原理如下图所示(同步阻塞调用为例):
客户端调用主要涉及的线程包括:
SerializingExecutor通过调用responseFuture的set(value),唤醒阻塞的应用线程,完成一次RPC调用。
gRPC采用的是网络I/O线程和业务调用线程分离的策略,大部分场景下该策略是最优的。但是,对于那些接口逻辑非常简单,执行时间很短,不需要与外部网元交互、访问数据库和磁盘,也不需要等待其它资源的,则建议接口调用直接在Netty /O线程中执行,不需要再投递到后端的服务线程池。避免线程上下文切换,同时也消除了线程并发问题。
例如提供配置项或者接口,系统默认将消息投递到后端服务调度线程,但是也支持短路策略,直接在Netty的NioEventLoop中执行消息的序列化和反序列化、以及服务接口调用。
减少锁竞争优化:当前gRPC的线程切换策略如下:
优化之后的gRPC线程切换策略:
通过线程绑定技术(例如采用一致性hash做映射),将Netty的I/O线程与后端的服务调度线程做绑定,1个I/O线程绑定一个或者多个服务调用线程,降低锁竞争,提升性能。
尽管Netty应用广泛,非常成熟,但是由于对Netty底层机制不太了解,用户在实际使用中还是会经常遇到各种问题,大部分问题都是业务使用不当导致的。Netty使用者需要学习Netty的故障定位技巧,以便出了问题能够独立、快速的解决。‘’
如果业务的ChannelHandler接收不到消息,可能的原因如下:
定位策略如下:
通过jmap -dump:format=b,file=xx pid命令Dump内存堆栈,然后使用MemoryAnalyzer工具对内存占用进行分析,查找内存泄漏点,然后结合代码进行分析,定位内存泄漏的具体原因,示例如下所示:
如果出现性能问题,首先需要确认是Netty问题还是业务问题,通过jstack命令或者jvisualvm工具打印线程堆栈,按照线程CPU使用率进行排序(top -Hp命令采集),看线程在忙什么。通常如果采集几次都发现Netty的NIO线程堆栈停留在select操作上,说明I/O比较空闲,性能瓶颈不在Netty,需要继续分析看是否是后端的业务处理线程存在性能瓶颈:
如果发现性能瓶颈在网络I/O读写上,可以适当调大NioEventLoopGroup中的work I/O线程数,直到I/O处理性能能够满足业务需求。
李林锋,10年Java NIO、平台中间件设计和开发经验,精通Netty、Mina、分布式服务框架、API Gateway、PaaS等,《Netty进阶之路》、《分布式服务框架原理与实践》作者。目前在华为终端应用市场负责业务微服务化、云化、全球化等相关设计和开发工作。
新浪微博 Nettying
微信:NettyingEmail:neu_lilinfeng@sina.com转载地址:http://gnqtx.baihongyu.com/