Netty浅析 - 2. 实现

96
简xiaoyao
2018.12.02 23:38* 字数 4603

前言

本篇文章主要分析Netty的系统结构以及其如何实现其对外宣称的特色,如果还未了解Netty的基础知识,最好先阅读本系列的第一篇文章Netty浅析 - 1. 基础

Netty总体结构

image.png

这张图摘自Netty官网,其展示的是Netty的模块结构,总体来说,Netty分为两大模块:

  1. 核心模块
    核心模块主要提供的是Netty的一些基础类和底层接口,主要包含三部分:
    • 用以提升性能,减少资源占用的Zero-Copy-Capable Rich Byte Buffer,即「零拷贝」缓冲区,Netty里的「零拷贝」与操作系统语境下的「零拷贝」不是同一个概念,具体会在后续章节做阐述
    • 统一的API,这是Netty对外宣传的简单易用API的一部分,什么意思呢?就是Netty为同步和异步IO提供统一的编程接口,举个例子,如果在前期希望使用BIO,后续随着业务变动,希望改用NIO,只需要改动几个简单的初始化参数,而不需要变动主体流程;相反,如果一开始不是基于Netty,而是直接基于BIO书写处理流程,后期想改成NIO,其变动是很大的,毕竟是两个不同的接口模块
    • 易扩展的事件模型,这里的重点在于易扩展,因为NIO本身就是基于事件的IO模型,而扩展性很好理解,如果一个框架无法扩展,那么也就意味着无法应对业务的变化
  2. 服务模块
    既然Netty的核心是IO,那么其服务模块基本也就和IO操作分不开了,主要有:
    • 网络接口数据处理相关服务,如报文的粘包,拆包处理,数据的加密,解密等
    • 各网络层协议实现服务,主要包括传输层和应用层相关网络协议的实现
    • 文件处理相关服务

Netty处理架构

介绍完Netty的模块结构,我们再来看一下它的处理架构:


image.png

Netty的架构也很清晰,就三层:

  1. 底层IO复用层,负责实现多路复用
  2. 通用数据处理层,主要对传输层的数据在进和出两个方向进行拦截处理,如编/解码,粘包处理等
  3. 应用实现层,开发者在使用Netty的时候基本就在这一层上折腾,同时Netty本身已经在这一层提供了一些常用的实现,如HTTP协议,FTP协议等

一般来说,数据从网络传递给IO复用层,IO复用层收到数据后会将数据传递给上层进行处理,这一层会通过一系列的处理Handler以及应用服务对数据进行处理,然后返回给IO复用层,通过它再传回网络

基于Reactor模式的IO复用

在Netty处理架构图中,可以看到在IO复用层上标注了一个「Reactor」:


image.png

这个「Reactor」代表的就是其IO复用层具体的实现模式 -- Reactor模式

image.png

这张图是从大名鼎鼎的Doug Lea的一份演讲稿中截取下来的,通过这张图示,就可以大致明白什么是Reactor模式了。在Reactor模式中,分为主反应组(MainReactor)和子反应组(subReactor),主反应组(MainReactor)负责处理连接,连接建立完成以后由主线程对应的acceptor将后续的数据处理分发给子反应组(subReactor)进行处理,对应代码为:

EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup(1);

ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
            ...

在这段代码中bossGroup对应的就是主反应组(MainReactor),workerGroup对应的是子反应组(subReactor),而NioEventLoopGroup其实就是一个实现了Java ExecutorService的线程池,其中的线程数可定制,若不设置线程数参数,则该参数值默认为2 * CPU核数量,在ServerBootstrap的初始化过程中,会为其添加一个实现了acceptor机制的Handler

image.png

而通过ServerBootstrapAcceptor,会在Channel建立后触发channelRead()方法,并在channelRead()内将此Channel绑定至子反应组对应的处理线程,后续的数据处理就交于它进行处理
image.png

在阅读这部分源码的时候需要注意一个点,按理来说,连接的建立应该是ACCEPT事件,怎么会触发channelRead()呢 ?其实netty内部将READACCEPT状态一并作为read的触发条件
image.png

介绍完了Netty关于IO复用层的实现,继续看其「易扩展」和「关注点分离」的核心:Pipeline

基于责任链模式的Channel-Pipeline

同样回过头去再看Netty处理架构图中的中间层 -- Pipeline


image.png

其字面意思为「管道」,顾名思义,管道的作用就在于传输,而对于Netty来说,它的管道传输的当然就是数据了,如果阅读过上一篇的读者应该知道,在上一篇关于Netty基础的介绍里,提到它有一个很重要的特色就在于:基于事件机制(Pipeline - Handler)达成关注点分离(消息编解码,协议编解码,业务处理),而Pipeline就是实现这一特色的核心所在,我们下面来看Netty是如何实现所谓的「易扩展」和「关注点分离」的

首先,Netty的Pipeline从数据传输的方向上来看分为进和出,这个和BIO相同;其次,最重要的在于Netty在Pipeline上通过责任链模式插入一系列的「Handler」,这一结构是它能实现「易扩展」和「关注点分离」的关键。想想看,所谓IO,不就是数据的「进」和「出」吗?而进来干啥呢?当然就是需要应用逻辑对其处理,那处理完了呢?还需要送回给请求方以示响应,而在进的过程中需要哪些处理逻辑,这些处理逻辑的先后顺序如何,处理完后出去的过程中需要哪些处理逻辑,这些处理逻辑的顺序又是如何,如果这些都可以方便的配置调整,是不是就达到了Netty宣称的「易扩展」和「关注点分离」(只需关注业务相关的Handler,网络协议相关的Handler直接调用即可,IO复用更无须关注)呢?

在Netty里,这一实现机制的核心类叫做ChannelPipeline

image.png

其中Channel负责数据通信,Handler负责逻辑处理,而ChannelPipeline就相当于一个由Handler串起来的处理链条,在Neety源码里有一个关于ChannelPipeline的比较形象的图形化描述:
image.png

看到没有,其实很简单,就是一个针对不同方向数据流的责任链,其中Inbound对应的是输入流,Outbound对应的是输出流(在这里再多提一句,责任链模式在很多框架里都有使用,比如Spring MVC里看到的各种Handler,也是基于责任链的封装)

强大的ByteBuf

既然是对Netty进行分析,就必然绕不过Netty自己封装的数据缓冲区:ByteBuf,它是Netty对外宣称的高性能的重要支撑,另外有必要提一下,在Netty里其核心缓冲区类叫「ByteBuf」,以便与NIO本身的缓冲区类「ByteBuffer」做区分,ByteBuf有如下特点:

  • 功能丰富的接口,Java NIO本身的缓冲区接口比较简单
  • 支持零拷贝,提升性能,减少资源占用
  • 支持动态扩展
  • 缓冲区初始块大小动态控制
  • 读写切换不需要手动调用clear(),flip();使用过Java NIO的小伙伴应该知道,其在进行读写切换时需要不停的通过clear()和flip()进行模式切换,很麻烦
  • 池化,提升性能,减少资源占用

下面将对上面提到的几个ByteBuf的重要特性进行实现分析,首先来看下ByteBuf是如何避免NIO中那繁琐的读写切换的。我们知道,对于Java NIO的Buffer,其有几个重要的属性:positionlimitcapacity,其中position代表的是下一个读或写的位置,limit是可被读或写的最高位,而capacity就是Buffer的容量了,之所以要在读和写切换的时候进行手动操作(clear()flip()),主要是因为在NIO中,positionlimit在读的时候代表的是下一个需读的位和可读的最高位,但是在写的时候又代表下一个需写的位和可写的最高位(其实就是capacity),换句话说这两个变量在不同的操作场景下有不同的含义,对应值也不同,所以需要在读写切换的时候进行手动操作

image.png

而Netty的ByteBuf则对这一点做了改进,其针对读写操作分别增加上了readerIndexwriterIndex,使用的时候不需要考虑读写转换
image.png

读的时候就变动readerIndex的值,而此时可读的最高位(对应NIO中的limit)其实就是writerIndex,同理写的时候就变动writerIndex,此时可写的最高位(对应NIO中的limit)就是capacity,说白了就是两个变量分别管理读和写的操作位,互不冲突,也就不存在读写切换的时候手动操作了;其实看到这里我们可以发现,NIO在接口设计的时候确实没有考虑周到,毕竟Netty的这种优化并不是有多难!

零拷贝Buf

在分析ByteBuf的「零拷贝」特性之前,先说说什么是「零拷贝」,所谓「零拷贝」, 通常指的是在 OS 层面上为了避免在用户态(User-space) 与 内核态(Kernel-space) 之间进行数据拷贝而采取的性能优化措施;例如 Linux 提供的 mmap 系统调用,它可以将一段用户空间内存映射到内核空间,当映射成功后,用户对这段内存区域的修改可以直接反映到内核空间;同样地,内核空间对这段区域的修改也直接反映用户空间。正因为有这样的映射关系,我们就不需要在用户态(User-space) 与内核态(Kernel-space) 之间拷贝数据,从而提高了数据传输的效率;对于Java的网络操作来说,网络接口在收到数据的时候需要先将数据复制到内核内存,然后在从内核内存复制到用户内存,同理往网络接口发数据也是先将数据从用户内存复制到内核内存,再从内核内存中将数据传给网络接口,所以如果是直接操纵内核内存,无疑处理的性能会更好

回到Netty,Netty中的 「零拷贝」与上面我们所提到到 OS 层面上的 「零拷贝」其实不太一样,Netty的 「零拷贝」 完全是在用户态里的,或者说更多的是偏向于减少JVM内的数据操作,具体体现在如下几个方面:

  • 通过 CompositeByteBuf类,将多个ByteBuf合并为一个逻辑上的ByteBuf,避免了各个ByteBuf之间的拷贝
  • 通过wrap操作,将byte[]数组、ByteBufByteBuffer等多个数据容器合并成一个ByteBuf对象,进而避免了拷贝操作
  • 通过slice操作,将ByteBuf分解为多个共享同一个存储区域的ByteBuf,避免了内存的拷贝
  • 通过FileRegion包装的FileChannel.tranferTo实现文件传输,将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题

这些操作之所以能避免不必要的拷贝操作,其实就在于内部对数据进行的是逻辑操作而非物理操作,操作完成后根据各逻辑引用的数据信息(大小,位置等)重新计算ByteBuf内部的控制属性(limitcapacityreaderIndexwriterIndex),如通过CompositeByteBuf将原本两个分别表示head和body的buffer组装成一个buffer:

image.png

虽然看起来CompositeByteBuf是由两个ByteBuf组合而成的,不过在CompositeByteBuf内部,这两个ByteBuf都是单独存在的(指针引用),CompositeByteBuf只是逻辑上是一个整体;这样在数据操作的时候不需要对数据进行物理挪动,只需要操作数据引用并计算关键因子即可,这种方式不但能提升性能,还可以减少内存占用,值得借鉴

Buf池化

在Netty中,ByteBuf用来作为数据的容器,是一种会被频繁创建和销毁的对象,ByteBuf需要的内存空间,可以在 JVM Heap 中申请分配,也可以在Direct Memory(堆外内存)中申请,其中在 Direct Memory 中分配的ByteBuf,其创建和销毁的代价比在 JVM Heap 中的更高,但抛开哪个代价高哪个代价低不说,光是频繁创建和频繁销毁这一点,就已奠定了效率不高的基调。Netty为了解决这个问题,引入了池化技术,池化技术的思想不复杂,和线程池思想类似,说白了就是对一些可重用的对象用完不回收,后面需要再次使用,以减少创建和销毁对象带来的资源损耗,下面结合Netty源码对其池化技术做剖析

首先看ByteBuf,它实现了ReferenceCounted接口,表明该类是一个引用计数管理对象

public abstract class ByteBuf implements ReferenceCounted, Comparable<ByteBuf>

而引用计数就是实现池化的关键技术点(不过并非只有池化的 ByteBuf 才有引用计数,非池化的也会有引用),继续看ReferenceCounted接口,它定义了这几个方法:

public interface ReferenceCounted {
    int refCnt();

    ReferenceCounted retain();

    ReferenceCounted retain(int increment);

    boolean release();

    boolean release(int decrement);
}

每一个引用计数对象,都维护了一个自身的引用计数,当第一次被创建时,引用计数为1,通过refCnt()方法可以得到当前的引用计数,retain()retain(int increment)增加自身的引用计数值,而release()release(int increment)则减少当前的引用计数值,如果引用计数值为 0,并且当前的 ByteBuf 被释放成功,那这两个方法的返回值就为true。而具体如何释放,各种不同类型的ByteBuf自己决定,如果是池化的ByteBuf,那么就会重新进池子,以待重用;如果是非池化的,则销毁底层的字节数组引用或者释放对应的堆外内存。具体的逻辑在AbstractReferenceCountedByteBuf类中可以看到:

    @Override
    public final boolean release() {
        for (;;) {
            int refCnt = this.refCnt;
            if (refCnt == 0) {
                throw new IllegalReferenceCountException(0, -1);
            }

            if (refCntUpdater.compareAndSet(this, refCnt, refCnt - 1)) {
                if (refCnt == 1) {
                    deallocate();
                    return true;
                }
                return false;
            }
        }
    }

释放对象的方法定义在 deallocate() 方法里,它是个抽象方法,既然是抽象的,那么就需要子类自行实现,对于非池化的 HeapByteBuf 来说,释放对象实际上就是释放底层字节数组的引用:

    @Override
    protected void deallocate() {
        array = null;
    }

对于非池化的DirectByteBuf来说,释放对象实际上就是释放堆外内存:

    @Override
    protected void deallocate() {
        ByteBuffer buffer = this.buffer;
        if (buffer == null) {
            return;
        }

        this.buffer = null;

        if (!doNotFree) {
            PlatformDependent.freeDirectBuffer(buffer);
        }

        if (leak != null) {
            leak.close();
        }
    }

对于池化的 ByteBuf 来说,就是把自己归还到对象池里:

    @Override
    protected final void deallocate() {
        if (handle >= 0) {
            final long handle = this.handle;
            this.handle = -1;
            memory = null;
            chunk.arena.free(chunk, handle);
            if (leak != null) {
                leak.close();
            } else {
                recycle();
            }
        }
    }

熟悉JVM GC的同学应该对这个引用计数的机制不会感到陌生,因为JVM在判断一个Java对象是否存活时有一种方式使用的就是计数法;另外Netty的池化缓存在实现上借鉴了buddy allocation和slab allocation的思想并进行了比较复杂的设计(buddy allocation是基于一定规则对内存进行分割,回收时进行合并,尽可能保证系统有足够的连续内存;而slab allocation是把内存分割为大小不等的内存块,请求内存是分配最贴近请求size的内存块,避免内存浪费),可以减少对象的创建与销毁对性能的影响,因为缓冲区对象的创建与销毁会占用内存带宽以及GC资源,另外由于池化缓存本身比较复杂,如线程私有池与全局共有池,其声明与释放都需要手动处理(比如本地池内的缓冲区对象如果不是在同一个线程内释放就会导致内存泄漏,这也是为什么JVM GC的时候需要有Stop The World),Netty提供了内存泄漏监控工具ResourceLeakDetector,如果发生了内存泄漏,它会通过日志记录并提醒,这个工具主要是防止对象被GC的时候其占用的资源没有被释放(如内存),或者没有执行release方法

也许有人会说,既然池化缓存实现复杂,用起来还得防止内存泄漏,那么它到底能给性能带来多大提升呢?我们可以看下Twitter对Netty池化缓存做的性能测试结果:


image.png

这张图的Y轴显示的是创建对象花费的时间,而X轴代表的是所创建对象的大小,同时在实验中,使用了四种不同的对象,分别是非池化堆内存对象(Unpooled Heap),池化堆内存对象(Pooled Heap),非池化直接内存对象(Unpooled Direct),池化直接内存对象(Pooled Direct)。结果现实,随着被创建对象大小的增加,池化技术的优势愈加明显,当然当对象很小时,池化反而不如JVM本身的对象创建性能(可以结合ByteBuf的实现原理,想想为什么?)

除了对象创建的性能,Twitter还测试了使用池化技术时GC相关的表现,实验模拟了在16000个连接下,对256byte大小的数据包进行循环传输:


image.png

结果表明,相对于非池化,池化的GC停顿减少了近4倍,而垃圾的增长也慢了4倍。所以说,Netty对ByteBuf进行的复杂的重写还是值得的

NIO epoll死循环问题及Netty解决方案

最后说说Netty是如何解决著名的「NIO epoll死循环」问题的。什么是「NIO epoll死循环」呢?在Linux系统中,当某个socket的连接突然中断后,会重设事件集eventSet,而eventSet的重设就会导致Selector被唤醒(但其实这个时候是没有任何事件需要处理的,select()方法应该还是处于阻塞状态),虽然被唤醒了,但其实是没有事件需要处理的,所以就又返回select()方法之前(正常情况下是处理完事件重新回去被select()阻塞),此时select()方法还是会直接返回,如此反复便造成死循环:

image.png

这个问题的原因本质上就是NIO的Selector实现有问题,Netty解决的方式其实比较简单粗暴,它会记录一段时间内空轮询的次数,如果超过一定阈值,就认为这个bug出现了,此时会重新生成一个新的selector取代旧的selector,避免死循环,具体的处理代码在NioEventLoop中:
image.png

Netty主要类关系图

这里贴一张Netty主要实现类的关系图,对需要阅读Netty源码的小伙伴可能有一个参考作用


image.png

总结

本篇主要介绍了Netty的总体架构,并对Netty的一些重要的实现机制进行了简单的剖析,如果在阅读本篇时发现对一些基础的概念和知识不是很了解,可以阅读本系列的第一篇Netty浅析 - 1. 基础进行相关学习,如果希望对Netty的实现有更深入的了解,推荐去Netty的官网,或者阅读Netty源码,如果还希望了解Netty其他相关知识,也可以阅读本系列的最后一篇文章Netty浅析 - 3. 总结

技术
Gupao