写点什么

【Netty 技术专题】「原理分析系列」Netty 强大特性之 ByteBuf 零拷贝技术原理分析

作者:浩宇天尚
  • 2021 年 12 月 29 日
  • 本文字数:7478 字

    阅读完需:约 25 分钟

【Netty技术专题】「原理分析系列」Netty强大特性之ByteBuf零拷贝技术原理分析

零拷贝 Zero-Copy

我们先来看下它的定义:


"Zero-copy" describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.


所谓的 Zero-copy,就是在操作数据时, 不需要将数据 buffer 从一个内存区域拷贝到另一个内存区域,少了一次内存的拷贝, 减少了 cpu 的执行,节省了内存带宽。

操作系统层面 Zero-Copy

在 OS 层面上的 Zero-copy 通常指避免在用户态(User-space) 与 内核态(Kernel-space) 之间来回拷贝数据。


  • 例如 Linux 提供的 mmap 系统调用, 它可以将一段用户空间内存映射到内核空间, 当映射成功后, 用户对这段内存区域的修改可以直接反映到内核空间;

  • 内核空间对这段区域的修改也直接反映用户空间。正因为有这样的映射关系, 我们就不需要在 用户态(User-space) 与 内核态(Kernel-space) 之间拷贝数据, 提高了数据传输的效率。


Netty 中的 Zero-copy 与上面我们所提到到 OS 层面上的 Zero-copy 不太一样, Netty 的 Zero-copy 完全是在用户态(Java 层面)的,它的 Zero-copy 的更多的是偏向于 优化数据操作 这样的概念.

Netty 的零拷贝 Zero-copy

  • Netty 提供了 CompositeByteBuf 类, 它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf, 避免了各个 ByteBuf 之间的拷贝。

  • 通过 wrap 操作, 我们可以将 byte[] 数组、ByteBuf、ByteBuffer 等包装成一个 Netty ByteBuf 对象, 进而避免了拷贝操作。

  • ByteBuf 支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf, 避免了内存的拷贝。

  • 通过 FileRegion 包装的 FileChannel.tranferTo 实现文件传输, 可以直接将文件缓冲区的数据发送到目标 Channel, 避免了传统通过循环 write 方式导致的内存拷贝问题。

通过 CompositeByteBuf 实现零拷贝

假设我们有一份协议数据, 它由头部和消息体组成, 而头部和消息体是分别存放在两个 ByteBuf 中的, 即:


ByteBuf header = ...ByteBuf body = ...
复制代码


在代码处理中, 通常希望将 header 和 body 合并为一个 ByteBuf, 方便处理, 那么通常的做法是:


ByteBuf allBuf = Unpooled.buffer(header.readableBytes() + body.readableBytes());allBuf.writeBytes(header);allBuf.writeBytes(body);
复制代码


可以看到, 我们将 header 和 body 都拷贝到了新的 allBuf 中了, 这无形中增加了两次额外的数据拷贝操作了。那么有没有更加高效优雅的方式实现相同的目的呢? 我们来看一下 CompositeByteBuf 是如何实现这样的需求的吧.


ByteBuf header = ...ByteBuf body = ...CompositeByteBuf compositeByteBuf = Unpooled.compositeBuffer();compositeByteBuf.addComponents(true, header, body);
复制代码


上面代码中, 我们定义了一个 CompositeByteBuf 对象, 然后调用


public CompositeByteBuf addComponents(boolean increaseWriterIndex, ByteBuf... buffers) {...}
复制代码


方法将 header 与 body 合并为一个逻辑上的 ByteBuf, 即:



不过需要注意的是, 虽然看起来 CompositeByteBuf 是由两个 ByteBuf 组合而成的, 不过在 CompositeByteBuf 内部, 这两个 ByteBuf 都是单独存在的, CompositeByteBuf 只是逻辑上是一个整体.


上面 CompositeByteBuf 代码还以一个地方值得注意的是, 我们调用 addComponents(boolean increaseWriterIndex, ByteBuf... buffers) 来添加两个 ByteBuf, 其中第一个参数是 true, 表示当添加新的 ByteBuf 时, 自动递增 CompositeByteBuf 的 writeIndex。


除了上面直接使用 CompositeByteBuf 类外, 我们还可以使用 Unpooled.wrappedBuffer 方法, 它底层封装了 CompositeByteBuf 操作, 因此使用起来更加方便:


ByteBuf header = ...ByteBuf body = ...ByteBuf allByteBuf = Unpooled.wrappedBuffer(header, body);
复制代码

通过 wrap 操作实现零拷贝

我们有一个 byte 数组, 我们希望将它转换为一个 ByteBuf 对象, 以便于后续的操作, 那么传统的做法是将此 byte 数组拷贝到 ByteBuf 中, 即:


byte[] bytes = ...ByteBuf byteBuf = Unpooled.buffer();byteBuf.writeBytes(bytes);
复制代码


显然这样的方式也是有一个额外的拷贝操作的,我们可以使用 Unpooled 的相关方法, 包装这个 byte 数组, 生成一个新的 ByteBuf 实例, 而不需要进行拷贝操作。上面的代码可以改为:


byte[] bytes = ...ByteBuf byteBuf = Unpooled.wrappedBuffer(bytes);
复制代码


通过 Unpooled.wrappedBuffer 方法来将 bytes 包装成为一个 UnpooledHeapByteBuf 对象, 而在包装的过程中, 是不会有拷贝操作的. 即最后我们生成的生成的 ByteBuf 对象是和 bytes 数组共用了同一个存储空间, 对 bytes 的修改也会反映到 ByteBuf 对象中.

通过 slice 操作实现零拷贝

slice 操作和 wrap 操作刚好相反, Unpooled.wrappedBuffer 可以将多个 ByteBuf 合并为一个, 而 slice 操作可以将一个 ByteBuf 切片 为多个共享一个存储区域的 ByteBuf 对象.ByteBuf 提供了两个 slice 操作方法:


public ByteBuf slice();public ByteBuf slice(int index, int length);
复制代码


不带参数的 slice 方法等同于 buf.slice(buf.readerIndex(), buf.readableBytes()) 调用, 即返回 buf 中可读部分的切片. 而 slice(int index, int length) 方法相对就比较灵活了, 我们可以设置不同的参数来获取到 buf 的不同区域的切片.


ByteBuf byteBuf = ...ByteBuf header = byteBuf.slice(0, 5);ByteBuf body = byteBuf.slice(5, 10);
复制代码


用 slice 方法产生 header 和 body 的过程是没有拷贝操作的, header 和 body 对象在内部其实是共享了 byteBuf 存储空间的不同部分而已. 即:


通过 FileRegion 实现零拷贝

Netty 中使用 FileRegion 实现文件传输的零拷贝, 不过在底层 FileRegion 是依赖于 Java NIO FileChannel.transfer 的零拷贝功能.


首先我们从最基础的 Java IO 开始吧. 假设我们希望实现一个文件拷贝的功能, 那么使用传统的方式, 我们有如下实现:


public static void copyFile(String srcFile, String destFile) throws Exception {    byte[] temp = new byte[1024];    FileInputStream in = new FileInputStream(srcFile);    FileOutputStream out = new FileOutputStream(destFile);    int length;    while ((length = in.read(temp)) != -1) {        out.write(temp, 0, length);    }    in.close();    out.close();}
复制代码


上面是一个典型的读写二进制文件的代码实现了. 不用我说, 大家肯定都知道, 上面的代码中不断中源文件中读取定长数据到 temp 数组中, 然后再将 temp 中的内容写入目的文件, 这样的拷贝操作对于小文件倒是没有太大的影响, 但是如果我们需要拷贝大文件时, 频繁的内存拷贝操作就消耗大量的系统资源了,下面我们来看一下使用 Java NIO 的 FileChannel 是如何实现零拷贝的:


public static void copyFileWithFileChannel(String srcFileName, String destFileName) throws Exception {    RandomAccessFile srcFile = new RandomAccessFile(srcFileName, "r");    FileChannel srcFileChannel = srcFile.getChannel();    RandomAccessFile destFile = new RandomAccessFile(destFileName, "rw");    FileChannel destFileChannel = destFile.getChannel();    long position = 0;    long count = srcFileChannel.size();    srcFileChannel.transferTo(position, count, destFileChannel);}
复制代码


可以看到, 使用了 FileChannel 后, 我们就可以直接将源文件的内容直接拷贝(transferTo) 到目的文件中, 而不需要额外借助一个临时 buffer, 避免了不必要的内存操作,我们来看一下在 Netty 中是怎么使用 FileRegion 来实现零拷贝传输一个文件的:


@Overridepublic void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {    RandomAccessFile raf = null;    long length = -1;    try {        // 1. 通过 RandomAccessFile 打开一个文件.        raf = new RandomAccessFile(msg, "r");        length = raf.length();    } catch (Exception e) {        ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + '\n');        return;    } finally {        if (length < 0 && raf != null) {            raf.close();        }    }    ctx.write("OK: " + raf.length() + '\n');    if (ctx.pipeline().get(SslHandler.class) == null) {        // SSL not enabled - can use zero-copy file transfer.        // 2. 调用 raf.getChannel() 获取一个 FileChannel.        // 3. 将 FileChannel 封装成一个 DefaultFileRegion        ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length));    } else {        // SSL enabled - cannot use zero-copy file transfer.        ctx.write(new ChunkedFile(raf));    }    ctx.writeAndFlush("\n");}
复制代码


可以看到, 第一步是通过 RandomAccessFile 打开文件, 然后 Netty 使用 DefaultFileRegion 来封装一个 FileChannel 即:


new DefaultFileRegion(raf.getChannel(), 0, length)
复制代码

java 零拷贝

零拷贝的“零”是指用户态和内核态间 copy 数据的次数为零。


传统的数据 copy(文件到文件、client 到 server 等)涉及到四次用户态内核态切换、四次 copy,四次 copy 中,两次在用户态和内核态间 copy 需要 CPU 参与、两次在内核态与 IO 设备间 copy 为 DMA 方式不需要 CPU 参与,零拷贝避免了用户态和内核态间的 copy、减少了两次用户态内核态间的切换。


  • java 的 zero copy 多在网络应用程序中使用。Java 的 libaries 在 linux 和 unix 中支持 zero copy,关键的 api 是 java.nio.channel.FileChannel 的 transferTo(),transferFrom()方法。

  • 可以用这两个方法来把 bytes 直接从调用它的 channel 传输到另一个 writable byte channel,中间不会使 data 经过应用程序,以便提高数据转移的效率。

Web 环境的使用零拷贝技术

许多 web 应用都会向用户提供大量的静态内容,这意味着有很多 data 从硬盘读出之后,会原封不动的通过 socket 传输给用户。这种操作看起来可能不会怎么消耗 CPU,但是实际上它是低效。


原始拷贝技术


kernal 把数据从 disk 读出来,然后把它传输给 user 级的 application,然后 application 再次把同样的内容再传回给处于 kernal 级的 socket,application 实际上只是作为一种低效的中间介质,用来把 disk file 的 data 传给 socket。


零拷贝技术


data 每次通过 user-kernel boundary,都会被 copy,这会消耗 CPU,并且占用 RAM 的带宽。因此你可以用一种叫做 Zero-Copy 的技术来去掉这些无谓的 copy。


  • 应用程序用 zero copy 来请求 kernel 直接把 disk 的 data 传输给 socket,而不是通过应用程序传输。Zero copy 提高了应用程序的性能,并且减少了 kernel 和 user 模式的上下文切换。

  • 使用 kernel buffer 做中介(而不是直接把 data 传到 user buffer 中)看起来比较低效(多了一次 copy)。然而实际上 kernel buffer 是用来提高性能的。


零拷贝的弊端问题


在进行读操作的时候,kernel buffer 起到了预读 cache 的作用,当写请求的 data size 比 kernel buffer 的 size 小的时候,这能够显著的提升性能。在进行写操作时,kernel buffer 的存在可以使得写请求完全异步。


悲剧的是,当请求的 data size 远大于 kernel buffer size 的时候,这个方法本身变成了性能的瓶颈。因为 data 需要在 disk,kernel buffer,user buffer 之间拷贝很多次(每次写满整个 buffer)。


而 Zero copy 正是通过消除这些多余的 data copy 来提升性能。

传统方式及涉及到的上下文切换

通过网络把一个文件传输给另一个程序,在 OS 的内部,这个 copy 操作要经历四次 user mode 和 kernel mode 之间的上下文切换,甚至连数据都被拷贝了四次,具体步骤如下:


  • read() 调用导致一次从 user mode 到 kernel mode 的上下文切换。在内部调用了 sys_read() 来从文件中读取 data。第一次 copy 由 DMA (direct memory access)完成,将文件内容从 disk 读出,存储在 kernel 的 buffer 中。

  • 然后请求的数据被 copy 到 user buffer 中,此时 read()成功返回。调用的返回触发了第二次 context switch: 从 kernel 到 user。至此,数据存储在 user 的 buffer 中。

  • send() Socket call 带来了第三次 context switch,这次是从 user mode 到 kernel mode。同时,也发生了第三次 copy:把 data 放到了 kernel adress space 中。当然,这次的 kernel buffer 和第一步的 buffer 是不同的 buffer。

  • 最终 send() system call 返回了,同时也造成了第四次 context switch。同时第四次 copy 发生,DMA egine 将 data 从 kernel buffer 拷贝到 protocol engine 中。第四次 copy 是独立而且异步的。



zero copy 方式及涉及的上下文转换

在 linux 2.4 及以上版本的内核中(如 linux 6 或 centos 6 以上的版本),开发者修改了 socket buffer descriptor,使网卡支持 gather operation,通过 kernel 进一步减少数据的拷贝操作。这个方法不仅减少了 context switch,还消除了和 CPU 有关的数据拷贝。user 层面的使用方法没有变,但是内部原理却发生了变化:


transferTo()方法使得文件内容被 copy 到了 kernel buffer,这一动作由 DMA engine 完成。 没有 data 被 copy 到 socket buffer。取而代之的是 socket buffer 被追加了一些 descriptor 的信息,包括 data 的位置和长度。然后 DMA engine 直接把 data 从 kernel buffer 传输到 protocol engine,这样就消除了唯一的一次需要占用 CPU 的拷贝操作。



Java NIO 零拷贝示例

NIO 中的 FileChannel 拥有 transferTo 和 transferFrom 两个方法,可直接把 FileChannel 中的数据拷贝到另外一个 Channel,或直接把另外一个 Channel 中的数据拷贝到 FileChannel。该接口常被用于高效的网络/文件的数据传输和大文件拷贝。


在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于 Java IO 中提供的方法。

通过网络把一个文件从 client 传到 server:
/** * disk-nic零拷贝 */class ZerocopyServer {    ServerSocketChannel listener = null;    protected void mySetup() {        InetSocketAddress listenAddr = new InetSocketAddress(9026);        try {            listener = ServerSocketChannel.open();            ServerSocket ss = listener.socket();            ss.setReuseAddress(true);            ss.bind(listenAddr);            System.out.println("监听的端口:" + listenAddr.toString());        } catch (IOException e) {            System.out.println("端口绑定失败 : " + listenAddr.toString() + " 端口可能已经被使用,出错原因: " + e.getMessage());            e.printStackTrace();        }
}
public static void main(String[] args) { ZerocopyServer dns = new ZerocopyServer(); dns.mySetup(); dns.readData(); }
private void readData() { ByteBuffer dst = ByteBuffer.allocate(4096); try { while (true) { SocketChannel conn = listener.accept(); System.out.println("创建的连接: " + conn); conn.configureBlocking(true); int nread = 0; while (nread != -1) { try { nread = conn.read(dst); } catch (IOException e) { e.printStackTrace(); nread = -1; } dst.rewind(); } } } catch (IOException e) { e.printStackTrace(); } }}
复制代码


class ZerocopyClient {    public static void main(String[] args) throws IOException {        ZerocopyClient sfc = new ZerocopyClient();        sfc.testSendfile();    }
public void testSendfile() throws IOException { String host = "localhost"; int port = 9026; SocketAddress sad = new InetSocketAddress(host, port); SocketChannel sc = SocketChannel.open(); sc.connect(sad); sc.configureBlocking(true); String fname = "src/main/java/zerocopy/test.data"; FileChannel fc = new FileInputStream(fname).getChannel(); long start = System.nanoTime(); long nsent = 0, curnset = 0; curnset = fc.transferTo(0, fc.size(), sc); System.out.println("发送的总字节数:" + curnset + " 耗时(ns):" + (System.nanoTime() - start)); try { sc.close(); fc.close(); } catch (IOException e) { System.out.println(e); } }}
复制代码
文件到文件的零拷贝
/** * disk-disk零拷贝 */class ZerocopyFile {    @SuppressWarnings("resource")    public static void transferToDemo(String from, String to) throws IOException {        FileChannel fromChannel = new RandomAccessFile(from, "rw").getChannel();        FileChannel toChannel = new RandomAccessFile(to, "rw").getChannel();        long position = 0;        long count = fromChannel.size();        fromChannel.transferTo(position, count, toChannel);        fromChannel.close();        toChannel.close();    }    @SuppressWarnings("resource")    public static void transferFromDemo(String from, String to) throws IOException {        FileChannel fromChannel = new FileInputStream(from).getChannel();        FileChannel toChannel = new FileOutputStream(to).getChannel();        long position = 0;        long count = fromChannel.size();        toChannel.transferFrom(fromChannel, position, count);        fromChannel.close();        toChannel.close();    }    public static void main(String[] args) throws IOException {        String from = "src/main/java/zerocopy/1.data";        String to = "src/main/java/zerocopy/2.data";        // transferToDemo(from,to);        transferFromDemo(from, to);    }}
复制代码


发布于: 刚刚
用户头像

浩宇天尚

关注

🏆 InfoQ写作平台-签约作者 🏆 2020.03.25 加入

【个人简介】酷爱计算机技术、醉心开发编程、喜爱健身运动、热衷悬疑推理的”极客达人“ 【技术格言】任何足够先进的技术都与魔法无异 【技术范畴】Java领域、Spring生态、MySQL专项、APM专题及微服务/分布式体系等

评论

发布
暂无评论
【Netty技术专题】「原理分析系列」Netty强大特性之ByteBuf零拷贝技术原理分析