从I/O模型到Netty(三)

Netty

零、写在前面

本文虽然是讲Netty,但实际更关注的是Netty中的NIO的实现,所以对于Netty中的OIO(Old I/O)并没有做过多的描述,或者说根本只字未提,所以本文中所述的所有实现细节都是基于NIO版本的。

Netty作为一个已经发展了十多年的框架,已然非常成熟了,其中有大量的细节是普通使用者不知道或者不关心的,所以本文难免有遗漏或者纰漏的地方,如果你发现了请告知。

本文不涉及Netty5的部分。

虽然这一节叫「写在前面」,但实际上上最后写的。

一、零拷贝

Netty4和Netty3中的buffer包里的类有很大的区别,但提供的特性大致相同,其中很重要的一个是提供了「零拷贝」的特性。

在处理请求或生成回复时,往往要使用已有数据,并对之进行截取、拼接等操作。假设现在要进行一个拼接字符串(bytes1bytes2)的操作,如果使用Java NIO中的java.nio.ByteBuffer类的话,我们把它假设成一个byte数组(其底层真正的存储也是如此),往往要生成一个更大的byte数组newBytes,然后将bytes1bytes2分别复制到newBytes的地址中去——其实newBytes中的数据已经都存在内存中了,只是分属不同的数组,存储中不连续的内存上而已——这么做需要在内存中做额外的拷贝。

而使用Netty中的Buffer类(如io.netty.buffer.ByteBuf)的话,则不会生成新的newBytes数组,而是生成一个新的对象指向原来的两个数组bytes1bytes2

新的Buffer中使用了指向原来数组内存的指针

并不是说Java NIO中的这种拷贝的策略不好,抛开场景去谈性能是没有意义的。

Netty3中零拷贝的API和Netty4不尽相同,但实现原理是一样的,这里拿Netty4来举例,代码1中对使用Java NIO的java.nio.ByteBuffer和Netty4的io.netty.buffer.ByteBuf拼接数据进行对比。

//代码1
public static void main(String[] args) {
    byte[] byte1 = "he     ".getBytes();
    byte[] byte2 = "llo     ".getBytes();

    ByteBuffer b1 = ByteBuffer.allocate(10);
    b1.put(byte1);
    ByteBuffer b2 = ByteBuffer.allocate(10);
    b2.put(byte2);
    ByteBuffer b3 = ByteBuffer.allocate(20);
    ByteBuffer[] b4 = {b1, b2};     #1
    b3.put(b1.array());
    b3.put(b2.array());             #2
    //读取内容
    System.out.println(new String(b3.array()));
    System.out.println("b1 addr:" + b1.array());
    System.out.println("b2 addr:" + b2.array());
    System.out.println("b3 addr:" + b3.array());

    ByteBuf nb1 = Unpooled.buffer(10);
    nb1.writeBytes(byte1);
    ByteBuf nb2 = Unpooled.buffer(10);
    nb2.writeBytes(byte2);
    //      nb2.array();
    ByteBuf nb3 = Unpooled.wrappedBuffer(nb1, nb2);
    nb3.array();                    #3
    //读取内容                       #4
    byte[] bytes = new byte[20];
    for(int i =0; i< nb3.capacity(); i++) {
        bytes[i] = nb3.getByte(i);
    }
    System.out.println(new String(bytes));

}
输出:
he     llo     
b1 addr:[B@4aa298b7
b2 addr:[B@7d4991ad
b3 addr:[B@28d93b30
he     llo   

可以看到,如果使用java.nio.ByteBuffer进行拼接,需要在#2的地方进行数组内存的拷贝,为了进一步提高这种场景下的系统性能,在使用Unpooled.wrappedBuffer(ByteBuf... buffers)进行拼接时并没有进行内存的拷贝,所以会在#3的地方抛出UnsupportedOperationException异常,因为此时b3中已经没有一个数组是存储自身实际内容了,Unpooled.wrappedBuffer返回的对象是io.netty.buffer.CompositeByteBuf.CompositeByteBuf的实例(具体逻辑见代码2)。

//代码2
public static ByteBuf wrappedBuffer(int maxNumComponents, ByteBuf... buffers) {
    switch (buffers.length) {
    case 0:
        break;
    case 1:
        ByteBuf buffer = buffers[0];
        if (buffer.isReadable()) {
            return wrappedBuffer(buffer.order(BIG_ENDIAN));
        } else {
            buffer.release();
        }
        break;
    default:
        for (int i = 0; i < buffers.length; i++) {
            ByteBuf buf = buffers[i];
            if (buf.isReadable()) {
                return new CompositeByteBuf(ALLOC, false, maxNumComponents, buffers, i, buffers.length);
            }
            buf.release();
        }
        break;
    }
    return EMPTY_BUFFER;
}

当然你也可以使用代码1中#1行的方法,创建一个ByteBuffer的数组,由于Java的数组中使用「引用」来指向其成员对象,这样就防止了内存拷贝,但这会带来另一个问题,在进行拼接之后,其结果是一个「ByteBuffer数组」而非「ByteBuffer对象」,这样会给实际编程带来很多不便。而使用ByteBuf的拼接则能在返回一个ByteBuf对象的同时又防止了内存拷贝,这就是Netty中所谓的零拷贝。

同时,更多的Netty中自定义的这些Buffer类可以带来的好处如下:
1. 根据这些类你可以自定义自己的Buffer类。
1. 透明的零拷贝实现。
1. ByteBuf提供了很多开箱即用的「访问特定类型字节数组(如getChar(int index))」特性。
1. 不需要每次都调用flip()来转换读与写。
1. 比ByteBuffer的性能要更好(初始化时不写0,不用GC)。

二、垃圾回收(GC)

你可能已经发现了,上一节中举得例子(要在拼接字符串的时候,得到一个同一类型——此处假设为Aclass——的对象,且实现零内存拷贝)中,完全可以在类Aclass中定义一个ByteBuffer数组,然后再增加对于Aclass中数组索引到这个ByteBuffer数组中的索引映射就好了。实际上,Netty中也是这么实现的。

//代码3
//org.jboss.netty.buffer.CompositeChannelBuffer.getByte(int)的实现
public byte getByte(int index) {
    //找到相应的子对象
    int componentId = componentId(index);
    //返回子对象中的字节数组中的相应字节
    return components[componentId].getByte(index - indices[componentId]);
}

而Netty中关于ByteBuf更有争议的部分在于,在ByteBuf的内存的管理上,它实现了自己的对象池。

自定义对象池,To be or not be

Netty的对象池是基于ThreadLocal的,所以线程与线程之间的池是不相关的。

Netty做了这么复杂的事情想优化内存的使用,以至于在Netty4中又进一步引入了自定义的对象池。在这个池中,Netty实现了自己的内存管理(分配和释放)。按照Netty文档上的说法,在处理网络事件时,往往需要在短时间内分配大量的、生命周期很短的对象,而如果要等待JVM的GC来回收这些对象,速度会很慢,同时GC本身也是要消耗资源的。

熟悉垃圾回收算法的朋友对于「引用计数」一定不陌生,iOS的Runtime中垃圾回收使用的就是引用计数,它是一种更高效、更原始的垃圾回收方法。高效体现在它的内存回收更「实时而直接」,原始体现在你需要在程序中显式地对引用计数进行增加和减少。当一个对象的引用计数降为0,则其内存会被回收。这种方式在手机这种「资源相而言更紧张」的设备上会带来很好的性能表现。

而JVM中采用的是「基于分代垃圾回收的构建引用树」的方法,所有不在这棵树上的对象则可回收,可想而知,构建这棵树本身就会消耗一定的资源,另外「分代垃圾回收」较「引用计数」也更复杂。

对于某些有这种需求(短时间内分配大量的、生命周期很短)的对象,Netty中使用引用计数来管理这些对象的分配和释放。具体的方法大致如下:
1. 首先Netty在JVM堆上申请一块较大的内存。
1. Netty的一直存储着指向这块内存中对象的引用,使得JVM的GC不去回收这块内存。
1. 当在Netty中需要申请一块生命周期较短的对象时(如ByteBuf),其真实内存就放入这块内存,同时维护一个这个对象的引用计数,在Netty中其初始值为1。
1. 当某个对象的引用计数降为0时,将这块内存标识为可用。

应用程序构建自己的内存池的做法是有争议的,往往会带来内存泄漏的结果,也不能获得JVM的GC算法带来的好处。但Netty的内存池已经证明,合理的使用内存池能够带来更好的性能。

直接I/O(Direct I/O),To be or not to be

在介绍I/O模型「从I/O模型到Netty(一)」时,就提到过Direct I/O,它带来的好处是在做I/O操作时,不需要把内存从用户空间拷贝到内核空间,节省了一部分资源,但在JVM的环境中申请Direct I/O要比在堆上分配内存消耗更多的性能。而利用Netty的对象池,刚好可以抵消这部分消耗,由池管理的Direct I/O的内存分配节省了GC的消耗。

有些地方会使用「零拷贝」来指代Direct I/O相对于Buffered I/O省去的那次拷贝(在用户空间和内核空间之间进行拷贝)

三、事件模型

假设在某种场景下,整个程序的目的都是处理单一的事情(比如一个web服务器的目的只是处理请求),我们可以将「与处理请求无关」的逻辑封装到一个框架内,在每次请求处理完后,都执行一次事件的分发和处理,这就是event loop了。很多语言中都有这种概念,如nodejs中的event loop,iOS中的run loop。

这是在「从I/O模型到Netty(一)」中提到过的EventLoop的概念,在Netty4中,则真正实现了这样一个概念。在Netty4的类里赫然能看到EventLoop的接口,但Netty4里的EventLoop和其他语言中Runtime级别的EventLoop还是有很大的区别的,其更像是一个执行预定义队列中任务的线程(继承自java.util.concurrent.ScheduledExecutorService,看下图中EventLoop的继承结构。

EventLoop接口的继承结构

其中,io.netty.channel.SingleThreadEventLoop比较重要,从它的名字就能看出来,它指的是单个线程的EventLoop,在Netty4的事件模型中,每一个EventLoop都有一个分配的线程,所有的I/O操作(也会使用事件进行传递)和事件的处理都是在这个线程中完成的。其执行逻辑如下图所示:

在本文之后的内容中有时候会不区分「EventLoop、EventExecutor和线程」,「EventExecutorGroup和线程池」的概念

事件在EventLoop中的执行逻辑

代码4中是上图中逻辑的实现代码。

//代码4
@Override
public void execute(Runnable task) {
    if (task == null) {
        throw new NullPointerException("task");
    }

    boolean inEventLoop = inEventLoop();
    if (inEventLoop) {
        addTask(task);
    } else {
        startThread();
        addTask(task);
        if (isShutdown() && removeTask(task)) {
            reject();
        }
    }

    if (!addTaskWakesUp && wakesUpForTask(task)) {
        wakeup(inEventLoop);
    }
}

四、线程模型

线程模型直接反应了一个程序在运行时是如何去「分配和执行任务」的。对于Netty而言,其基本的线程模型可以理解为之前介绍到的Reactor模型,只是在其之上做了一些扩展。比如说在服务端,最重要的I/O事件应该算是连接请求(CONNECT)事件了,它直接关系到了服务端程序的吞吐量,所以在Netty的线程模型中就设计了一个单独的线程去处理这个请求,其基本模型如下图所示:

改进的Reactor模型

由于篇幅所限,本文讨论的线程模型将只关注服务端,客户端当然也同样重要。

Netty中的事件流

简单来说,Netty中的管道(ChannelPipe)可以认为就是一个Handler的容器,里边存放了两种EventHandler(io.netty.channel.ChannelInboundHandlerio.netty.channel.ChannelOutboundHandler)。一个网络请求从建立连接开始到得到回复的过程,就是在这个管道中流入然后流出的过程,Netty的文档中是这么描述管道的:

                                                 从Channel或者
                                            ChannelHandlerContext
                                                 而来的I/O请求
                                                      |
  +---------------------------------------------------+---------------+
  |                           ChannelPipeline         |               |
  |                                                  \|/              |
  |    +---------------------+            +-----------+----------+    |
  |    | Inbound Handler  N  |            | Outbound Handler  1  |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  |               |
  |               |                                  \|/              |
  |    +----------+----------+            +-----------+----------+    |
  |    | Inbound Handler N-1 |            | Outbound Handler  2  |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  .               |
  |               .                                   .               |
  | ChannelHandlerContext.fireIN_EVT() ChannelHandlerContext.OUT_EVT()|
  |        [ method call]                       [method call]         |
  |               .                                   .               |
  |               .                                  \|/              |
  |    +----------+----------+            +-----------+----------+    |
  |    | Inbound Handler  2  |            | Outbound Handler M-1 |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  |               |
  |               |                                  \|/              |
  |    +----------+----------+            +-----------+----------+    |
  |    | Inbound Handler  1  |            | Outbound Handler  M  |    |
  |    +----------+----------+            +-----------+----------+    |
  |              /|\                                  |               |
  +---------------+-----------------------------------+---------------+
                  |                                  \|/
  +---------------+-----------------------------------+---------------+
  |       [ Socket.read() ]                    [ Socket.write() ]     |
  +-------------------------------------------------------------------+

简单说,就是一个事件在管道里的顺序是从第一个Inbound的Handler开始,执行到最后一个,当一个Outbound事件发生时,它是从相反的方向执行到第一个。

实际的实现是这样的,管道可以被认为是一个有序的Handler的序列(链表,见代码5),当一个Inbound事件发生时,它会从序列的最头部依次通过每一个Handler,如果这个Handler是Inbound类型,那么就被执行,否则依次往后。当一个Outbound事件发生时,它会从这个序列的当前位置开始执行,判断是否是Outbound类型,直至最头部。

//代码5
@Override
public final ChannelPipeline addLast(EventExecutorGroup group, String name, ChannelHandler handler) {
    final AbstractChannelHandlerContext newCtx;
    synchronized (this) {
        checkMultiplicity(handler);

        newCtx = newContext(group, filterName(name, handler), handler);

        addLast0(newCtx);
        。。。省略一些别的代码
    }
}
。。。省略一些别的代码
private void addLast0(AbstractChannelHandlerContext newCtx) {
    AbstractChannelHandlerContext prev = tail.prev;
    newCtx.prev = prev;
    newCtx.next = tail;
    prev.next = newCtx;
    tail.prev = newCtx;
}

每一个事件往管道的更深处发送需要Handler自身显式触发。

Netty3中的管道

在Netty3里,Inbound和Outbound的概念分别叫Upstream和Downstream,从上一节的图中能很清晰的看出来,一个往上,一个往下。

如果在上边所说的某一个Handler中包含一个很耗时的操作,那么处理I/O的线程就会造成阻塞,导致这个线程迟迟不能被回收并用以处理新的请求,所以在Netty3中引入了除I/O线程池之外的另一个线程池来处理业务逻辑。用户可以通过org.jboss.netty.handler.execution.ExecutionHandler来实现自己的业务线程池,它同时实现了UpstreamHandler和DownstreamHandler。

ExecutionHandler的引入给Netty带来很多问题,本来Netty一直秉持着I/O处理串行化(一个事件只被一个线程处理)的理念,但是在ExecutionHandler的场景下则会有多个线程参与到这个事件的处理中来,同时也增加了开发的复杂度,用户需要关心额外的多线程编程的东西。

Netty3中还有一个接口org.jboss.netty.channel.ChannelSink用来提供统一的「把Downstream写入底层」的API,在Netty4中已经看不到了。

Netty4的线程模型

我在自己的电脑上运行Netty4中自带的Echo的例子,使用VisualVM查看其线程列表,截图如下:

Netty4中自带的Echo服务端

然后依次启动10个client对这个server进行连接,截图如下:

同时连接10个客户端

本文中所有的讲述都是基于NIO的,所以这里看到启动的线程池是io.netty.channel.nio.NioEventLoopGroup的实例,其继承了类io.netty.util.concurrent.DefaultThreadFactory,Netty4中不再需要使用Java的线程池,所以线程的名称和Netty3中有些不同,大致的规则是线程的名称为<线程池的类名(第一个字母小写)>-<线程池启动的顺序>-<线程启动的顺序>

可以看到Netty运行起来之后有这样几个线程:
1. 一些无关的线程:JDK的线程,网络连接的线程,JMX的线程
1. 1个Boss线程(nioEventLoopGroup-2-1)
1. 8个Worker线程(nioEventLoopGroup-3-*)

一个小的细节,从上图可以看到Boss线程池是第二个被实例化的,其实还有一个线程池GlobalEventExecutor会被第一个实例化,它在Netty的整个生命周期都会存在。

在Netty4中线程是按照如下方式工作的:
1. 对于每一个端口的监听,会有一个单独的线程(Boss)去监听并处理其I/O事件。
1. Boss线程为这个事件生成对应的Channel,并绑定其对应的Pipeline,然后交给Worker(childGroup)。
1. Worker会将此Channel绑定到某个EventLoop(I/O线程)上,之后所有这个Channel上的事件默认都要在此EventLoop上执行。
1. 当事件需要执行耗时的工作时,为了不阻塞I/O线程,往往会自定义一个EventExecutorGroup(Netty4提供了io.netty.util.concurrent.DefaultEventExecutorGroup),将耗时的Handler放入其中执行。
1. 对于没有指定EventExecutorGroup的Handler,将默认指定为Channel上绑定的EventLoop。

其流程如下图所示:

Netty4线程的线程模型

Netty3与Netty4的不同

Netty3与4相比,大致的思想都是一样的,但是实现上有一些略微的不同,在Netty3.7源码中的EchoServer执行后其线程列表如下:

Netty3中自带的Echo服务端

Netty3与Netty4不同的地方有:
1. Netty3中ServerBootstrap的创建需要使用JDK的线程池,而Netty4封装了线程池,增加了很多如EventLoop的概念,这点可以从线程的命名上看得出来。
1. 由于#1的原因,导致了Pipeline中的Handler没有被约束在某个线程内执行,会出现多线程同步的问题。
1. 由于#1的原因,在Netty3中可以生成大量的业务线程来做Handler的处理,有时候看这样做可以提升系统的性能,但是其实这样做破坏了Netty只处理网络I/O事件的设计,整个Handler的执行过程变得很复杂,增加了系统开发和维护的复杂度。
1. Netty3中在Pipeline中切换线程可以使用org.jboss.netty.handler.execution.ExecutionHandler,而在新的线程模型中,Netty提供了io.netty.util.concurrent.DefaultEventExecutorGroup来实现这种切换。

五、一些关于Netty的周边

  1. 第一次看到Netty时想,WTH,它跟Jetty有什么关系,怎么长得这么像。

  2. 后来去逛了Netty的网站,看(xiang)了(xi)一(yue)看(du)最新的User Guide,看到了一段话让我一下子乐了。

    Some users might already have found other network application framework that claims to have the same advantage, and you might want to ask what makes Netty so different from them. The answer is the philosophy it is built on. Netty is designed to give you the most comfortable experience both in terms of the API and the implementation from the day one. It is not something tangible but you will realize that this philosophy will make your life much easier as you read this guide and play with Netty.

    粗体的文字大意是说,「Netty的好,不能用言语来表达,但是只要你去使用它,你能体会的到蕴藏在其中的哲学,它会让你的生活更加容易。」这个X装的我给💯。

  3. 因为要准备这篇内容,去搜了一下Netty的历史,原来它最早是一个叫Trustin Lee的人写的。然后用了十多年的积累才造就了今天Netty这样一个开箱即用的基于事件驱动的NIO网络框架。

KK笔记:kknotes.com
本文链接地址: 从I/O模型到Netty(三)

转载须以超链接形式标明文章原始出处和作者信息及版权声明

You may also like

一条评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注