#私藏项目实操分享#Netty技术专题「原理分析系列」Netty强大特性之ByteBuf零拷贝技术原理分析
Posted 浩宇天尚
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了#私藏项目实操分享#Netty技术专题「原理分析系列」Netty强大特性之ByteBuf零拷贝技术原理分析相关的知识,希望对你有一定的参考价值。
零拷贝Zero-Copy
我们先来看下它的定义:
操作系统层面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 来实现零拷贝传输一个文件的:
@Override
public 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);
以上是关于#私藏项目实操分享#Netty技术专题「原理分析系列」Netty强大特性之ByteBuf零拷贝技术原理分析的主要内容,如果未能解决你的问题,请参考以下文章
#私藏项目实操分享#SpringCloud技术专题「Gateway网关系列」微服务网关服务的Gateway功能配置指南分析
#私藏项目实操分享#Alibaba中间件技术系列「RocketMQ技术专题」RocketMQ消息发送的全部流程和落盘原理分析
#私藏项目实操分享#SpringCloud技术专题「Gateway网关系列」微服务网关服务的Gateway组件的原理介绍分析
#私藏项目实操分享#Alibaba中间件技术系列「RocketMQ技术专题」Broker配置介绍及发送流程异常(XX Busy)问题分析
#私藏项目实操分享#Java技术开发专题系列之Guava RateLimiter针对于限流器的入门到实战(含源码分析介绍)
#私藏项目实操分享# Java深层系列「技术盲区」让我们一起探索一下Netty(Java)底层的“零拷贝Zero-Copy”技术(上)