【Netty权威指南】高性能之道
1、RPC调用性能模型分析
1.1、传统RPC调用性能差的三宗罪
"罪行”一:网络传输方式问题。传统的RPC框架或者基于RMI等方式的远程服务(过程)调用采用了同步阻塞IO,当客户端的并发压力或者网络时延增大之后,同步阻塞IO会由于频繁的wait导致IO线程经常性的阻塞,由于线程无法高效的工作,IO处理能力自然下降。
采用BIO通信模型的服务端,通常由一个独立的 Acceptor线程负责监听客户端的连接,接收到客户端连接之后,为其创建一个新的线程处理请求消息,处理完成之后,返回应答消息给客户端,线程销毁,这就是典型的一请求一应答模型。该架构最大的问题就是不具备弹性伸缩能力,当并发访问量增加后,服务端的线程个数和并发访问数成线性正比,由于线程是Java虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能急剧下降,随着并发量的继续增加,可能会发生句柄溢岀、线程堆栈溢岀等问题,并导致服务器最终宕机。
“罪行”二:序列化性能差。Java序列化存在如下几个典型问题:
(1)Java序列化机制是Java内部的一种对象编解码技术,无法跨语言使用。例如对于异构系统之间的对接,Java序列化后的码流需要能够通过其他语言反序列化成原始对象(副本),目前很难支持。
(2)相比于其他开源的序列化框架,Java序列化后的码流太大,无论是网络传输还是持久化到磁盘,都会导致额外的资源占用。
(3)序列化性能差,资源占用率高(主要是CPU资源占用高)。
“罪行”三:线程模型问题。由于采用同步阻塞IO,这会导致每个TCP连接都占用1个线程,由于线程资源是JVM虚拟机非常宝贵的资源,当IO读写阻塞导致线程无法及时释放时,会导致系统性能急剧下降,严重的甚至会导致虚拟机无法创建新的线程。
1.2、IO通信性能三原则
尽管影响IO通信性能的因素非常多,但是从架构层面看主要有三个要素。
(1)传输:用什么样的通道将数据发送给对方。可以选择BIO、NIO或者AIO,I/O模型在很大程度上决定了通信的性能;
(2)协议:采用什么样的通信协议,HTTP等公有协议或者内部私有协议。协议的选择不同,性能也不同。相比于公有协议,内部私有协议的性能通常可以被设计得更优:
(3)线程:数据报如何读取?读取之后的编解码在哪个线程进行,编解码后的消息如何派发, Reactor线程模型的不同,对性能的影响也非常大。
2、Netty高性能之道
2.1、异步非阻塞通信
在IO编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。IO多路复用技术通过把多个IO的阻塞复用到同一个 select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程/多进程模型比,IO多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源。
JDK1.4提供了对非阻塞IO(NlO)的支持,JDK1.5_update10版本使用epoll替代了传统的 select/poll,极大地提升了NO通信的性能与Socket和 ServerSocket类相对应,NIO也提供了 SocketChannel和ServerSocketChannel两种不同的套接字通道实现。这两种新增的通道都支持阻塞和非阻塞两种模式。阻塞模式使用非常简单,但是性能和可靠性都不好,非阻塞模式则正好相反。
开发人员一般可以根据自己的需要来选择合适的模式,一般来说,低负载、低并发的应用程序可以选择同步阻塞IO以降低编程复杂度。但是对于高负载、高并发的网络应用,需要使用NIO的非阻塞模式进行开发。
Netty的IO线程 NioEventLoop由于聚合了多路复用器 Selector,可以同时并发处理成百上千个客户端 SocketChannel。由于读写操作都是非阻塞的,这就可以充分提升IO线程的运行效率,避免由频繁的IO阻塞导致的线程挂起。另外,由于 Netty采用了异步通信模式,一个O线程可以并发处理N个客户端连接和读写操作,这从根本上解决了传统同步阻塞IO一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。
2.2、高效的 Reactor线程模型
常用的 Reactor线程模型有三种,分别如下。
(1) Reactor单线程模型;
(2) Reactor多线程模型
(3)主从 Reactor多线程模型
Reactor单线程模型,指的是所有的O操作都在同一个NIO线程上面完成,NIO线程的职责如下:
(1)作为NIO服务端,接收客户端的TCP连接;
(2)作为NIO客户端,向服务端发起TCP连接
(3)读取通信对端的请求或者应答消息;
(4)向通信对端发送消息请求或者应答消息。
由于 Reactor模式使用的是异步非阻塞I/O,所有的I/O操作都不会导致阻塞,理论上一个线程可以独立处理所有IO相关的操作。从架构层面看,一个NIO线程确实可以完成其承担的职责。例如,通过 Acceptor接收客户端的TCP连接请求消息,链路建立成功之后,通过 Dispatch将对应的 ByteBuffer派发到指定的 Handler上进行消息解码。用户 Handler可以通过NIO线程将消息发送给客户端。
对于一些小容量应用场景,可以使用单线程模型,但是对于高负载、大并发的应用却不合适,主要原因如下。
(1)一个NIO线程同时处理成百上千的链路,性能上无法支撑。即便NIO线程的CPU负荷达到100%,也无法满足海量消息的编码、解码、读取和发送;
(2)当NIO线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超时,超时之后往往会进行重发,这更加重了NIO线程的负载,最终会导致大量消息积压和处理超时,NIO线程会成为系统的性能瓶颈;
(3)可靠性问题。一旦NIO线程意外跑飞,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。
为了解决这些问题,演进出了 Reactor多线程模型,下面我们一起学习下 Reactor多线程模型Rector多线程模型与单线程模型最大的区别就是有一组NIO线程处理IO操作,它的特点如下。
(1)有一个专门的NIO线程—— Acceptor线程用于监听服务端,接收客户端的TCP连接请求;
(2)网络IO操作——读、写等由一个N1O线程池负责,线程池可以采用标准的JDK线程池实现,它包含一个任务队列和N个可用的线程,由这些NIO线程负责消息的读取、解码、编码和发送;
(3)1个NIO线程可以同时处理N条链路,但是1个链路只对应1个NIO线程,防止发生并发操作问题。
在绝大多数场景下, Reactor多线程模型都可以满足性能需求:但是,在极特殊应用场景中,一个NIO线程负责监听和处理所有的客户端连接可能会存在性能问题。例如百万客户端并发连接,或者服务端需要对客户端的握手消息进行安全认证,认证本身非常损耗性能。在这类场景下,单独一个 Acceptor线程可能会存在性能不足问题,为了解决性能问题,产生了第三种 Reactor线程模型——主从Reactor多线程模型。
主从 Reactor线程模型的特点是:服务端用于接收客户端连接的不再是个1个单独的NIO线程,而是一个独立的NIO线程池。 Acceptor接收到客户端TCP连接请求处理完成(可能包含接入认证等),将新创建的 SocketChannel注册到IO线程池(sub reactor线程池)的某个IO线程上,由它负责 SocketChannel的读写和编解码工作。 Acceptor线程池只用于客户端的登录、握手和安全认证,一旦链路建立成功,就将链路注册到后端subReactor线程池的IO线程上,由IO线程负责后续的IO操作。
利用主从NlO线程模型,可以解决1个服务端监听线程无法有效处理所有客户端连接的性能不足问题。因此,在Netty的官方Demo中,推荐使用该线程模型。
事实上,Netty的线程模型并非固定不变,通过在启动辅助类中创建不同的EventLoopGroup实例并进行适当的参数配置,就可以支持上述三种 Reactor线程模型。正是因为Netty对 Reactor线程模型的支持提供了灵活的定制能力,所以可以满足不同业务场景的性能诉求。
Netty单线程模型服务端代码示例如下。
从上面示例代码可以看出,构造方法的参数和线程组实例化个数不同,就能灵活地切换到不同的 Reactor线程模型上,用户使用起来非常方便。事实上,并没有标准的最优线程配置策略,用户需要在理解Nety线程模型的基础之上,根据业务的实际需求选择合适的线程模型和参数。
2.3、无锁化的串行设计
在大多数场景下,并行多线程处理可以提升系统的并发性能。但是,如果对于共享资源的并发访问处理不当,会带来严重的锁竞争,这最终会导致性能的下降。为了尽可能地避免锁竞争带来的性能损耗,可以通过串行化设计,即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁为了尽可能提升性能, Netty采用了串行无锁化设计,在IO线程内部进行串行操作,避免多线程竞争导致的性能下降。表面上看,串行化设计似乎CPU利用率不高,并发程度不够。但是,通过调整NIO线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列——多个工作线程模型性能更优。
Netty的串行化设计工作原理图如图22-1所示。
Netty的 NioEventLoop读取到消息之后,直接调用 ChannelPipeline的fireChannelRead(Object msg),只要用户不主动切换线程,一直会由 NioEventLoop调用到用户的 Handler,期间不进行线程切换。这种串行化处理方式避免了多线程操作导致的锁的竞争,从性能角度看是最优的。
2.4、高效的并发编程
Netty的高效并发编程主要体现在如下几点
(1)volatile的大量、正确使用;
(2)CAS和原子类的广泛使用;
(3)线程安全容器的使用;
(4)通过读写锁提升并发性能。
2.5、高性能的序列化框架
影响序列化性能的关键因素总结如下。
(1)序列化后的码流大小(网络带宽的占用);
(2)序列化&反序列化的性能(CPU资源占用)
(3)是否支持跨语言(异构系统的对接和开发语言切换)。
Netty默认提供了对 Google Protobuf的支持,通过扩展Netty的编解码接口,用户可以实现其他的髙性能序列化框架,例如 Thrift的压缩二进制编解码框架。
2.6、零拷贝
很多用户都听说过Nety具有“零拷贝”功能,但是具体体现在哪里又说不清楚,本小节就详细对 Netty的“零拷贝”功能进行讲解。
Netty的“零拷贝”主要体现在如下三个方面。
第一种情况。Netty的接收和发送 ByteBuffer采用 DIRECT BUFFERS,使用堆外直接内存进行 Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行 Socket读写,JVM会将堆内存 Buffer拷贝一份到直接内存中,然后才写入 Socket中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
JDK内存拷贝的代码如下。
下面我们对Netty收发数据报“零拷贝”相关代码进行分析。数据报读取代码如下
接收缓冲区 ByteBuffer的分配由 ChannelConfig负责,下面我们继续分析ChannelConfig创建 ByteBufAllocator的代码。
继续分析 AdaptiveRecvByteBufAllocator的实现,查看它的内存分配接口,代码如下。
为了提升IO操作的性能,默认使用 direct buffer,这就避免了读写数据报的二次内存拷贝,实现了读写 Socket的“零拷贝”功能。
下面我们继续看第二种“零拷贝”的实现 CompositeByteBuf,它对外将多个 ByteBuf封装成一个 ByteBuf,对外提供统一封装后的 ByteBuf接口,它的类定义如图22-4所示。
通过继承关系我们可以看出, CompositeByteBuf实际就是个 ByteBuf的装饰器,它将多个ByteBuf组合成一个集合,然后对外提供统一的 ByteBuf接口,相关定义如下。
添加 ByteBuf,不需要做内存拷贝,相关代码如下。
第三种“零拷贝”就是文件传输,Netty文件传输类 DefaultFileRegion通过 transferTo方法将文件发送到目标 Channel中,下面重点看 File Channel的 transferto方法,它的APIDOC说明如图22-5所示。
很多操作系统直接将文件缓冲区的内容发送到目标 Channel中,而不需要通过循环拷贝的方式,这是一种更加高效的传输方式,提升了传输性能,降低了CPU和内存占用,实现了文件传输的“零拷贝”。
2.7、内存池
随着JVM虚拟机和JIT即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区 Buffer,情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty提供了基于内存池的缓冲区重用机制。
下面我们一起看下 Netty ByteBuf的实现,如图22-6所示。
Netty提供了多种内存管理策略,通过在启动辅助类中配置相关参数,可以实现差异化的定制。
下面通过性能测试,我们看下基于内存池循环利用的 ByteBuf和普通 ByteBuf的性能差异。
测试场景一:使用内存池分配器创建直接内存缓冲区,代码示例如下。
测试场景二:使用非堆内存分配器创建的直接内存缓冲区。
各执行300万次,性能对比结果如下。
性能测试表明,采用内存池的 ByteBuf相比于朝生夕灭的 ByteBuf,性能高23倍左右(性能数据与使用场景强相关)。
下面简单分析下 Netty内存池的内存分配关键代码。
继续看 newDirectBuffer方法,发现它是一个抽象方法,由 AbstractBufAllocator的子类负责实现,子类实现代码如图22-7所示。
代码跳转到 PooledByteBufAllocator的 newDirectBuffer方法,从 Cache中获取内存区域 PoolArena,调用它的 allocate方法进行内存分配。
重点分析 newByteBuf的实现,它同样是个抽象方法,由子类 DirectArena和 HeapArena来实现不同类型的缓冲区分配,由于测试用例使用的是堆外内存,因此重点分析DirectArena的实现。如图22-8所示。
执行 PooledDirectByteBuf的 newInstance方法,代码如下。
通过 RECYCLER的get方法循环使用 ByteBuf对象,如果是非内存池实现,则直接创建一个新的 ByteBuf对象。从缓冲池中获取 ByteBuf之后,调用AbstractReferenceCountedByteBuf的 setRefCnt方法设置引用计数器,用于对象的引用计数和内存回收(类似JVM垃圾回收机制)。
2.8、灵活的TCP参数配置能力
合理设置TCP参数在某些场景下对于性能的提升可以起到显著的效果,例如SO_RCVBUF和 SO_SNDBUF。如果设置不当,对性能的影响是非常大的。下面我们总结
下对性能影响比较大的几个配置项。
(1) SO_RCVBUF和 SO_SNDBUF:通常建议值为128KB或者256KB;
(2) SO_TCPNODELAY: NAGLE算法通过将缓冲区内的小封包自动相连,组成较大的封包,阻止大量小封包的发送阻塞网络,从而提高网络应用效率。但是对于时延敏感的应用场景需要关闭该优化算法;
(3)软中断:如果 Linux内核版本支持RPS(2.6.35以上版本),开启RPS后可以实现软中断,提升网络吞吐量。RPS根据数据包的源地址,目的地址以及目的和源端口,计算出一个hash值,然后根据这个hash值来选择软中断运行的CPU。从上层来看,也就是说将每个连接和CPU绑定,并通过这个hash值,来均衡软中断在多个CPU上,提升网络并行处理性能
Nety在启动辅助类中可以灵活的配置TCP参数,满足不同的用户场景。相关配置接口定义如图22-9所示。