1、背景介绍
1.1传统RPC 调用性能差的三宗罪
1.1.1网络传输方式问题:传统的RPC 框架或者基于RMI 等方式的远程服务(过程)调用采用了同步阻塞IO,当客户端的
并发压力或者网络时延增大之后,同步阻塞IO 会由于频繁的wait 导致IO 线程经常性的阻塞,由于线程无法高效的工
作,IO 处理能力自然下降
1.1.2序列化方式问题:Java 序列化存在如下几个典型问题:
1) Java 序列化机制是Java 内部的一种对象编解码技术,无法跨语言使用;例如对于异构系统之间的对接,Java 序列化
后的码流需要能够通过其它语言反序列化成原始对象(副本),目前很难支持;
2) 相比于其它开源的序列化框架,Java 序列化后的码流太大,无论是网络传输还是持久化到磁盘,都会导致额外的资
源占用;
3) 序列化性能差(CPU 资源占用高)。
线程模型问题:由于采用同步阻塞IO,这会导致每个TCP 连接都占用1 个线程,由于线程资源是JVM 虚拟机非常宝
贵的资源,当IO 读写阻塞导致线程无法及时释放时,会导致系统性能急剧下降,严重的甚至会导致虚拟机无法创建新
的线程。
1.1.3
采用BIO 通信模型的服务端,通常由一个独立的Acceptor 线程负责监听客户端的连接,接收到客户端连接之后为客户
端连接创建一个新的线程处理请求消息,处理完成之后,返回应答消息给客户端,线程销毁,这就是典型的一请求一
应答模型。该架构最大的问题就是不具备弹性伸缩能力,当并发访问量增加后,服务端的线程个数和并发访问数成线
性正比,由于线程是JAVA 虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能急剧下降,随着并发量的继
续增加,可能会发生句柄溢出、线程堆栈溢出等问题,并导致服务器最终宕机。
1.2高性能的三个主题
1) 传输:用什么样的通道将数据发送给对方,BIO、NIO 或者AIO,IO 模型在很大程度上决定了框架的性能。
2) 协议:采用什么样的通信协议,HTTP 或者内部私有协议。协议的选择不同,性能模型也不同。相比于公有协议,
内部私有协议的性能通常可以被设计的更优。
3) 线程:数据报如何读取?读取之后的编解码在哪个线程进行,编解码后的消息如何派发,Reactor 线程模型的不同,
对性能的影响也非常大。
二 、Netty高性能之道
2.1 异步非阻塞通信
在IO 编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者IO 多路复用技术进行处理。IO 多路
复用技术通过把多个IO 的阻塞复用到同一个select 的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户
端请求。与传统的多线程/多进程模型比,I/O 多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者
线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源。
JDK1.4 提供了对非阻塞IO(NIO)的支持,JDK1.5_update10 版本使用epoll 替代了传统的select/poll,极大的提升了
NIO 通信的性能。JDK NIO 通信模型如下所示:
NIO 提供了SocketChannel 和ServerSocketChannel 两种不同的套接字通道实现。这两种新增的通道都支持阻塞和非阻塞两种模式。对于高负载、高并发的网络应用,需要使用NIO 的非阻塞模式进行开发。Netty 架构按照Reactor 模式设计和实现,它的服务端通信序列图如下:
客户端通信序列图如下:
Netty 的IO 线程NioEventLoop 聚合了多路复用器Selector,可以同时并发处理成百上千个客户端Channel,由于读
写操作都是非阻塞的,这就可以充分提升IO 线程的运行效率,避免由于频繁IO 阻塞导致的线程挂起。另外,由于Netty
采用了异步通信模式,一个IO 线程可以并发处理N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞IO 一
连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。
7
Netty 的IO 线程NioEventLoop 聚合了多路复用器Selector,可以同时并发处理成百上千个客户端Channel,由于读
写操作都是非阻塞的,这就可以充分提升IO 线程的运行效率,避免由于频繁IO 阻塞导致的线程挂起。另外,由于Netty
采用了异步通信模式,一个IO 线程可以并发处理N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞IO 一
连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。
2.2 零拷贝
Netty 的“零拷贝”主要体现在如下三个方面:
1) Netty 的接收和发送ByteBuffer 采用DIRECT BUFFERS,使用堆外直接内存进行Socket 读写,不需要进行字节缓冲
区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行Socket 读写,JVM 会将堆内存Buffer 拷贝一份到直接
内存中,然后才写入Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
2) Netty 提供了组合Buffer 对象,可以聚合多个ByteBuffer 对象,用户可以像操作一个Buffer 那样方便的对组合Buffer
进行操作,避免了传统通过内存拷贝的方式将几个小Buffer 合并成一个大的Buffer。
3) Netty 的文件传输采用了transferTo()方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过
循环write()方式导致的内存拷贝问题。
2.3内存池?
随着JVM 虚拟机和JIT 即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区Buffer,情
况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty 提供了基于
内存池的缓冲区重用机制。
性能测试经验表明,采用内存池的ByteBuf 相比于朝生夕灭的ByteBuf,性能高23 倍左右(性能数据与使用场景强相
关)
执行PooledDirectByteBuf 的newInstance 方法,代码如下:
static PooledDirectByteBuf newInstance(int maxCapacity) {
PooledDirectByteBuf buf = RECYCLER.get();
buf.reuse(maxCapacity);
return buf;
}
通过RECYCLER 的get 方法循环使用ByteBuf 对象,如果是非内存池实现,则直接创建一个新的ByteBuf 对象。从缓
冲池中获取ByteBuf 之后,调用AbstractReferenceCountedByteBuf 的setRefCnt 方法设置引用计数器,用于对象的引
用计数和内存回收(类似JVM 垃圾回收机制)。
4.2.4 高效的Reactor 线程模型
常用的Reactor 线程模型有三种,分别如下:
1) Reactor 单线程模型;
2) Reactor 多线程模型;
3) 主从Reactor 多线程模型
Reactor 单线程模型,指的是所有的IO 操作都在同一个NIO 线程上面完成,NIO 线程的职责如下:
1) 作为NIO 服务端,接收客户端的TCP 连接;
2) 作为NIO 客户端,向服务端发起TCP 连接;
3) 读取通信对端的请求或者应答消息;
4) 向通信对端发送消息请求或者应答消息。
Reactor 单线程模型示意图如下所示:
对于一些小容量应用场景,可以使用单线程模型。但是对于高负载、大并发的应用却不合适,主要原因如下:
1) 一个NIO 线程同时处理成百上千的链路,性能上无法支撑,即便NIO 线程的CPU 负荷达到100%,也无法满足海
量消息的编码、解码、读取和发送;
2) 当NIO 线程负载过重之后,处理速度将变慢,这会导致大量客户端连接超时,超时之后往往会进行重发,这更加重
了NIO 线程的负载,最终会导致大量消息积压和处理超时,NIO 线程会成为系统的性能瓶颈;
3) 可靠性问题:一旦NIO 线程意外跑飞,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消
息,造成节点故障。
为了解决这些问题,演进出了Reactor 多线程模型,下面我们一起学习下Reactor 多线程模型。
Rector 多线程模型与单线程模型最大的区别就是有一组NIO 线程处理IO 操作,它的原理图如下:
Reactor 多线程模型的特点:
1) 有专门一个NIO 线程-Acceptor 线程用于监听服务端,接收客户端的TCP 连接请求;
2) 网络IO 操作-读、写等由一个NIO 线程池负责,线程池可以采用标准的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 操作。
它的线程模型如下图所示
2.5 无锁化的串行设计理念
在大多数场景下,并行多线程处理可以提升系统的并发性能。但是,如果对于共享资源的并发访问处理不当,会带来
严重的锁竞争,这最终会导致性能的下降。为了尽可能的避免锁竞争带来的性能损耗,可以通过串行化设计,即消息
的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。
为了尽可能提升性能,Netty 采用了串行无锁化设计,在IO 线程内部进行串行操作,避免多线程竞争导致的性能下降。
表面上看,串行化设计似乎CPU 利用率不高,并发程度不够。但是,通过调整NIO 线程池的线程参数,可以同时启动
多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列-多个工作线程模型性能更优。
Netty 的串行化设计工作原理图如下:
Netty 的NioEventLoop 读取到消息之后,直接调用ChannelPipeline 的fireChannelRead(Object msg),只要用户不主
动切换线程,一直会由NioEventLoop 调用到用户的Handler,期间不进行线程切换,这种串行化处理方式避免了多线
程操作导致的锁的竞争,从性能角度看是最优的。
2.6 高效的并发编程
Netty 的高效并发编程主要体现在如下几点:
1) volatile 的大量、正确使用;
2) CAS 和原子类的广泛使用;
3) 线程安全容器的使用;
4) 通过读写锁提升并发性能。
2.7 高性能的序列化框架
1) 序列化后的码流大小(网络带宽的占用);
2) 序列化&反序列化的性能(CPU 资源占用);
3) 是否支持跨语言(异构系统的对接和开发语言切换)。
Netty 默认提供了对Google Protobuf 的支持,通过扩展Netty 的编解码接口,用户可以实现其它的高性能序列化框架,
例如Thrift 的压缩二进制编解码框架。
2.8 灵活的TCP 参数配置能力
合理设置TCP 参数在某些场景下对于性能的提升可以起到显著的效果,例如SO_RCVBUF 和SO_SNDBUF。如果设置
不当,对性能的影响是非常大的。下面我们总结下对性能影响比较大的几个配置项:
1) SO_RCVBUF 和SO_SNDBUF:通常建议值为128K 或者256K;
2) SO_TCPNODELAY:NAGLE 算法通过将缓冲区内的小封包自动相连,组成较大的封包,阻止大量小封包的发送阻
塞网络,从而提高网络应用效率。但是对于时延敏感的应用场景需要关闭该优化算法;
3) 软中断:如果Linux 内核版本支持RPS(2.6.35 以上版本),开启RPS 后可以实现软中断,提升网络吞吐量。RPS
根据数据包的源地址,目的地址以及目的和源端口,计算出一个hash 值,然后根据这个hash 值来选择软中断运行的
cpu,从上层来看,也就是说将每个连接和cpu 绑定,并通过这个hash 值,来均衡软中断在多个cpu 上,提升网络并
行处理性能。