资讯专栏INFORMATION COLUMN

Netty 之 Zero-copy 的实现(下)

endiat / 1726人阅读

摘要:系统调用返回,产生了第四次上下文切换。现在这个方法不仅减少了上下文切换,而且消除了参与的数据拷贝。

上一篇说到了 CompositeByteBuf ,这一篇接着上篇的讲下去。

FileRegion

让我们先看一个Netty官方的example

// netty-netty-4.1.16.Finalexamplesrcmainjavaio
ettyexamplefileFileServerHandler.java
public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
    RandomAccessFile raf = null;
    long length = -1;
    try {
        raf = new RandomAccessFile(msg, "r");
        length = raf.length();
    } catch (Exception e) {
        ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + "
");
        return;
    } finally {
        if (length < 0 && raf != null) {
            raf.close();
        }
    }

    ctx.write("OK: " + raf.length() + "
");
    if (ctx.pipeline().get(SslHandler.class) == null) {
        // SSL not enabled - can use zero-copy file transfer.
        ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length));
    } else {
        // SSL enabled - cannot use zero-copy file transfer.
        ctx.write(new ChunkedFile(raf));
    }
    ctx.writeAndFlush("
");
}

可以看到在没开启SSL的情况下handler是通过 DefaultFileRegion 类传输文件的,而 DefaultFileRegionFileRegion 接口的一个实现, FileRegion 的注释是这么写的:

A region of a file that is sent via a Channel which supports zero-copy file transfer.

FileRegion 内部封装了 Java NIO 的 FileChannel.transferTo() 方法,要了解 FileRegionZero-copy 的原理,我们得先了解 transferTo() 方法。

让我们看一段传输文件的一般写法吧。

File.read(file, buf, len);
Socket.send(socket, buf, len);

尽管上面的代码看起来很简单,但在内部实际包含了4次用户态-内核态上下文切换,和4次数据拷贝。

其中步骤有:

read() 调用导致了一次用户态到内核态的上下文切换,在内部,一个 sys_read() (或等价函数)被执行来从文件中读取数据。第一次拷贝是由 DMA 引擎将数据从磁盘文件存储到内核地址空间缓冲区。

被请求长度的数据从内核的读缓冲区拷贝到用户缓冲区,并且 read() 调用返回。这个返回导致又一次从内核态到用户态的上下文切换。现在数据是存储在用户地址空间缓冲区。

send() 调用引起了一次从用户态到内核态的上下文切换。第三次拷贝又一次将数据放进内核地址空间缓冲区,尽管这一次是放进另一个不同的缓冲区,和目标socket联系在一起。

send() 系统调用返回,产生了第四次上下文切换。第四次拷贝由 DMA 引擎独立异步地将数据从内核缓冲区传递给协议引擎。

看到这里可能有些读者会问,read() 函数为什么不直接将数据拷贝到用户地址空间的缓冲区,而要经内核地址空间的缓冲区转一次手,这不是白白多了一次拷贝操作吗?

对IO函数有了解的童鞋肯定知道,在IO函数的背后有一个缓冲区 buffer ,我们平常的读和写操作并不是直接和底层硬件设备打交道,而是通过一块叫缓冲区的内存区域缓存数据来间接读写。我们知道,和CPU、高速缓存、内存比,磁盘、网卡这些设备属于慢速设备,交换一次数据要花很多时间,同时会消耗总线传输带宽,所以我们要尽量降低和这些设备打交道的频率,而使用缓冲区中转数据就是为了这个目的。

引用参考文献[2]中的话:

Using the intermediate buffer on the read side allows the kernel buffer to act as a "readahead cache" when the application hasn"t asked for as much data as the kernel buffer holds. This significantly improves performance when the requested data amount is less than the kernel buffer size. The intermediate buffer on the write side allows the write to complete asynchronously.

大意是说,在读一侧的中间缓冲区可以作为预读缓存显著提高当请求数据大小小于内核缓冲区大小时的读性能,在写一侧的中间缓冲区可以允许写操作异步完成。

不过,当读请求数据的大小大于内核缓冲区时这个策略本身会变成一个性能瓶颈,数据在到达应用程序前会在磁盘、内核缓冲区、用户缓冲区之间反复多次拷贝。

让我们重新思考下上面的过程,会发现第二次和第三次的拷贝其实是不必要的,我们为什么不直接从读缓冲区将数据传输到socket缓冲区呢?实际上这就是 transferTo() 所做的。

public void transferTo(long position, long count, WritableByteChannel target);

transferTo() 方法将数据从一个文件channel传输到一个可写channel。在内部它依赖于操作系统对 Zero-copy 的支持,在UNIX/Linux系统上, transferTo() 实际会调用 sendfile() 这个系统函数,将数据从一个文件描述符传输到另一个。

#include 
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

可以看到我们将上下文切换已经从4次减少到2次,同时把数据拷贝从4次减少到3次(只有1次 CPU 参与,另外2次 DMA 引擎完成),那么我们可不可以把这唯一一次CPU参与的数据拷贝也省掉呢?

如果网卡支持 gather operations 内核就可以进一步减少数据拷贝。在 Linux kernels 2.4 及更新的版本,socket 描述符已经为适应这个需求做了变化。现在这个方法不仅减少了上下文切换,而且消除了CPU参与的数据拷贝。API接口是一样的,但是实质已经发生了变化:

transferTo() 方法引起 DMA 引擎将文件内容拷贝到内核缓冲区。

没有数据从内核缓冲区拷贝到socket缓冲区,只有携带位置和长度信息的描述符被追加到socket缓冲区上, DMA 引擎直接将内核缓冲区的数据传递到协议引擎,全程无需CPU拷贝数据。

到这里大家对 transferTo() 实现 Zero-copy 的原理应该很清楚了吧, FileRegion 是对 transferTo() 的一个封装,所以也是一样的。

DirectByteBuffer

DirectByteBuffer 是 Java NIO 用于实现堆外内存的一个很重要的类,而 NettyDirectByteBuffer 作为PooledDirectByteBufUnpooledDirectByteBuf 的内部数据容器(区别于 HeapByteBuf 直接用 byte[] 作为数据容器),以使用和操纵堆外内存。要了解 DirectByteBuffer 怎么实现 Zero-copy,我们要先了解 DirectByteBuffer 这个类和堆外内存。

DirectByteBuffer 类本身还是位于Java内存模型的堆中,堆内存是JVM可以直接管控、操纵的内存,而 DirectByteBuffer 中的 unsafe.allocateMemory(size) 是一个native方法,这个方法分配的是堆外内存,通过 C 的 malloc 来进行分配的。分配的内存是在系统本地的内存,并不在Java的内存中,也不属于JVM管控范围,所以在 DirectByteBuffer 一定会存在某种方式操纵堆外内存。

DirectByteBuffer 的父类 Buffer 中有个 address 属性:

// Used only by direct buffers
// NOTE: hoisted here for speed in JNI GetDirectBufferAddress
long address;

address 只会被直接缓存给使用到。之所以将 address 属性升级放在 Buffer 中,是为了在JNI调用 GetDirectBufferAddress 时提高效率。

address 表示分配的堆外内存的地址,JNI对这个堆外内存的操作都是通过这个 address 实现的。

在回答为什么堆外内存可以实现 Zero-copy 前,我们先要明确一个结论,那就是 操作系统不能直接访问Java堆的内存区域

JNI方法访问的内存区域是一个已经确定的内存区域,如果该内存地址指向的是一个Java堆内存的话,在操作系统正在访问这个内存地址时,JVM在这个时候进行了GC操作,GC经常会进行先标记再压缩的操作,即将可回收的空间做标记,然后清空标记位置的内存,然后会进行一个压缩,压缩会涉及到对象的移动,以腾出一块更加完整、连续的内存空间,以容纳更大的新对象,但是这个移动的过程会使JNI调用的数据错乱。

为了解决上述的问题,一般会做一个堆内存与堆外内存之间数据拷贝的操作:比如我们要完成一个从文件中读数据到堆内存的操作,即 FileChannelImpl.read(HeapByteBuffer) ,这里实际上File I/O会将数据读到堆外内存中,然后堆外内存再将数据拷贝到堆内存,这样我们就读到了文件中的内容。

static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
    if (var1.isReadOnly()) {
        throw new IllegalArgumentException("Read-only buffer");
    } else if (var1 instanceof DirectBuffer) {
        return readIntoNativeBuffer(var0, var1, var2, var4);
    } else {
        // 分配临时的堆外内存
        ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining());

        int var7;
        try {
            // File I/O 操作会将数据读入到堆外内存中
            int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
            var5.flip();
            if (var6 > 0) {
                // 将堆外内存的数据拷贝到堆外内存中
                var1.put(var5);
            }

            var7 = var6;
        } finally {
            // 里面会调用DirectBuffer.cleaner().clean()来释放临时的堆外内存
            Util.offerFirstTemporaryDirectBuffer(var5);
        }

        return var7;
    }
}

而写操作则反之,我们会将堆内存的数据先写到堆外内存,然后操作系统会将堆外内存的数据写入到堆内存。

如果我们直接使用堆外内存,即直接在堆外分配一块内存来存储数据,这样就可以避免堆内存和堆外内存之间的数据拷贝,进行I/O操作时直接将堆外内存地址传给JNI的I/O函数就好了。

这里引用一段 stackoverflow 里关于 ByteBuffer.allocate() vs. ByteBuffer.allocateDirect() 的讨论:

Operating systems perform I/O operations on memory areas. These memory areas, as far as the operating system is concerned, are contiguous sequences of bytes. It"s no surprise then that only byte buffers are eligible to participate in I/O operations. Also recall that the operating system will directly access the address space of the process, in this case the JVM process, to transfer the data. This means that memory areas that are targets of I/O perations must be contiguous sequences of bytes. In the JVM, an array of bytes may not be stored contiguously in memory, or the Garbage Collector could move it at any time. Arrays are objects in Java, and the way data is stored inside that object could vary from one JVM implementation to another.

这也是堆外内存 DirectByteBuffer 被引进的原因。

但是同时,创建和销毁一块堆外内存的花销要比堆内存昂贵得多,这是因为堆外内存的创建和销毁要通过系统相关的 native 方法,而不是在 Java 堆上直接由 JVM 操控。为了更有效地重用堆外内存,Netty 引入了内存池机制手动管理内存,这是一个 Java 版的 Jemalloc,后面有机会再写篇文章专门介绍这个,因为我现在也不是很懂(先挖个坑)。

总结

到这里关于 Netty 实现 Zero-copy 的4种机制,切片共用,组合缓冲区,操作系统层的零拷贝以及堆外内存已经介绍完了,因为本人也是最近刚开始学习 Netty 框架,对很多知识点掌握得还不是很通透,如果文章写得有什么不妥的地方还请大家不吝赐教。

参考

[1] 对于 Netty ByteBuf 的零拷贝(Zero Copy) 的理解
[2] Efficient data transfer through zero copy
[3] 堆外内存 之 DirectByteBuffer 详解

文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。

转载请注明本文地址:https://www.ucloud.cn/yun/67967.html

相关文章

  • Netty Zero-copy 实现(上)

    摘要:维基百科中对的解释是零拷贝技术是指计算机执行操作时,不需要先将数据从某处内存复制到另一个特定区域。维基百科里提到的零拷贝是在硬件和操作系统层面的,而本文主要介绍的是在应用层面的优化。 维基百科中对 Zero-copy 的解释是 零拷贝技术是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。 维基百科里提到...

    sf_wangchong 评论0 收藏0
  • 对于 Netty ByteBuf 零拷贝(Zero Copy) 理解

    摘要:根据对的定义即所谓的就是在操作数据时不需要将数据从一个内存区域拷贝到另一个内存区域因为少了一次内存的拷贝因此的效率就得到的提升在层面上的通常指避免在用户态与内核态之间来回拷贝数据例如提供的系统调用它可以将一段用户空间内存映射到内 根据 Wiki 对 Zero-copy 的定义: Zero-copy describes computer operations in which the C...

    ConardLi 评论0 收藏0
  • Netty源码解析

    摘要:一旦某个事件触发,相应的则会被调用,并进行处理。事实上,内部的连接处理协议编解码超时等机制,都是通过完成的。开启源码之门理解了的事件驱动机制,我们现在可以来研究的各个模块了。 Netty是什么 大概用Netty的,无论新手还是老手,都知道它是一个网络通讯框架。所谓框架,基本上都是一个作用:基于底层API,提供更便捷的编程模型。那么通讯框架到底做了什么事情呢?回答这个问题并不太容易,我们...

    _Suqin 评论0 收藏0
  • Netty 源码分析 三 我就是大名鼎鼎 EventLoop(一)

    摘要:目录源码之下无秘密做最好的源码分析教程源码分析之番外篇的前生今世的前生今世之一简介的前生今世之二小结的前生今世之三详解的前生今世之四详解源码分析之零磨刀不误砍柴工源码分析环境搭建源码分析之一揭开神秘的红盖头源码分析之一揭开神秘的红盖头客户端 目录 源码之下无秘密 ── 做最好的 Netty 源码分析教程 Netty 源码分析之 番外篇 Java NIO 的前生今世 Java NI...

    livem 评论0 收藏0

发表评论

0条评论

endiat

|高级讲师

TA的文章

阅读更多
最新活动
阅读需要支付1元查看
<