- 所谓的零拷贝是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。
传统IO工作方式
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。
传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。
代码通常如下,一般会需要两个系统调用:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
代码很简单,虽然就两行代码,但是这里面发生了不少的事情。
首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read()
,一次是 write()
,每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。
上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。
其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:
- 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
- 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
- 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
- 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。
我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。
零拷贝技术
零拷贝技术实现的方式通常有 2 种:
- mmap + write
- sendfile
其核心是减少上下文切换和数据拷贝次数。
mmap + write
在前面我们知道,read()
系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap()
替换 read()
系统调用函数。
buf = mmap(file, len);
write(sockfd, buf, len);
mmap()
系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
具体过程如下:
- 应用进程调用了
mmap()
后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区; - 应用进程再调用
write()
,操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据; - 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。
我们可以得知,通过使用 mmap()
来代替 read()
, 可以减少一次数据拷贝的过程。所以整个过程进行了4次上下文切换和3次数据拷贝。
但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。
sendfile
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile()
,函数形式如下:
#include
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。
首先,它可以替代前面的 read()
和 write()
这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:
$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on
于是,从 Linux 内核 2.4
版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile()
系统调用的过程发生了点变化,具体过程如下:
- 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
- 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;
所以,这个过程之中,只进行了 2 次数据拷贝,如下图:
这就是所谓的零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。
所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
NIO中的零拷贝
mmap
/**
* 说明
* 1. MappedByteBuffer可让文件直接在内存(堆外内存)修改,操作系统不需要拷贝一
*/
public class MappedByteBufferTest {
public static void main(String[] args) {
try(RandomAccessFile randomAccessFile = new RandomAccessFile("netty/src/main/resources/1.txt", "rw");){
final FileChannel channel = randomAccessFile.getChannel();
/**
* 参数1 FileChannel.MapMode.READ_WRITE 读写模式
* 参数2 0: 可以直接修改的起始位置
* 参数3 5: 是映射到内存的大小,即将1.txt的多少个字节映射到内存
* 实际类型:directByteBuffer
*/
final MappedByteBuffer map = channel.map(FileChannel.MapMode.READ_WRITE, 0, 5);
map.put(0, (byte)'H');
map.put(3, (byte)'9');
} catch (IOException e) {
e.printStackTrace();
}
}
}
sendfile
FileChannel类型:
/**
* This method is potentially much more efficient than a simple loop
* that reads from this channel and writes to the target channel. Many
* operating systems can transfer bytes directly from the filesystem cache
* to the target channel without actually copying them.
*/
public abstract long transferTo(long position, long count, WritableByteChannel target)throws IOException;
如果 Linux 系统支持 sendfile()
系统调用,那么 transferTo()
实际上最后就会使用到 sendfile()
系统调用函数。
Netty中的零拷贝
在 Netty 的世界中 Zero Copy 其实有两层含义。
- 这里的零拷贝的是指代 Netty 中对于数据高效率操作方式。
- 指在 Java 之上(user space)允许 CompositeByteBuf 使用单个 ByteBuf 一样操作多个 ByteBuf 而不需要任何 copy。
- 以及允许使用
slice
,切分单个ByteBuf为多个,而实际上操作的还是同一个ByteBuf,不需要cotpy。
- 如果你所在的系统支持 zero copy,则可以使用 FileRegion 来写入 Channel,实际是就是调用上文Nio零拷贝中的transferTo方法进行传输。
CompositeBuf
package netty.zerocopy;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import java.nio.charset.StandardCharsets;
/**
* netty 零拷贝
* @author huangyichun
* @date 2020/12/9
*/
public class CompositeDemo {
public static void main(String[] args) {
ByteBuf buf1 = Unpooled.copiedBuffer("hello, world", StandardCharsets.UTF_8);
ByteBuf buf2 = Unpooled.copiedBuffer("let's go", StandardCharsets.UTF_8);
ByteBuf compositeBuf = Unpooled.wrappedBuffer(buf1, buf2);
compositeBuf.setBytes(1, "my name".getBytes());
System.out.println(buf1.toString(StandardCharsets.UTF_8));
System.out.println(buf2.toString(StandardCharsets.UTF_8));
System.out.println(compositeBuf.toString(StandardCharsets.UTF_8));
}
}
打印结果:
hmy nameorld
let's go
hmy nameorldlet's go
从上面这个案例可以看出,**compositeBuf** 实际是引用了 **buf1**和**buf2**, 因为我们改变了compositeBuf 内容后,buf1也被改变了,也就是说**CompositeBuf** 没有进行数据copy。
ByteBuf compositeBuf = Unpooled.wrappedBuffer(buf1, buf2); //生成一个CompositeBuf
我们可以查看源码:
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;
//上面案例实际上走到逻辑是这个, maxNumComponents=2
default:
for (int i = 0; i < buffers.length; i++) {
ByteBuf buf = buffers[i];
if (buf.isReadable()) {
//创建CompositeByteBuf
return new CompositeByteBuf(ALLOC, false, maxNumComponents, buffers, i);
}
buf.release();
}
break;
}
return EMPTY_BUFFER;
}
CompositeByteBuf的构造函数:
private CompositeByteBuf(ByteBufAllocator alloc, boolean direct, int maxNumComponents, int initSize) {
super(AbstractByteBufAllocator.DEFAULT_MAX_CAPACITY);
this.alloc = ObjectUtil.checkNotNull(alloc, "alloc");
if (maxNumComponents < 1) {
throw new IllegalArgumentException(
"maxNumComponents: " + maxNumComponents + " (expected: >= 1)");
}
this.direct = direct;
this.maxNumComponents = maxNumComponents;
components = newCompArray(initSize, maxNumComponents);
}
其参数含义:
- alloc: ByteBuf 分配
- direct: 是否是 DirectByteBuf
- maxNumComponents: 最多能组合 ByteBuf
- buffers:组合的 ByteBuf
- offset: ByteBuf 在总 ByteBuf 组合中的偏移值
Slice
package netty.zerocopy;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import java.nio.charset.StandardCharsets;
/**
* @author huangyichun
* @date 2020/12/9
*/
public class SliceDemo {
public static void main(String[] args) {
ByteBuf buf = Unpooled.copiedBuffer("hello, world", StandardCharsets.UTF_8);
ByteBuf pre = buf.slice(0, 6);
ByteBuf next = buf.slice(6, buf.readableBytes());
buf.setBytes(0, "MyName".getBytes());
System.out.println(buf.toString(StandardCharsets.UTF_8));
System.out.println(pre.toString(StandardCharsets.UTF_8));
System.out.println(next.toString(StandardCharsets.UTF_8));
}
}
打印结果:
MyName world
MyName
world
从结果可以明显看出来pre 和 next还是指向buf的。因此slice操作不涉及内容copy,而pre实际的类型是UnpooledSlicedByteBuf 其内部包含了一个指向buf的引用buffer,这里就不进行源码分析了。
FileRegion
下面实例来自:io.netty.example.file.FileServerHandler.channelRead0
@Override
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() + '\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.
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");
}
这里可以看出当 SSL 没有开启时可以使用 DefaultFileRegion,其是 FileRegion 的默认实现,内部封装了 FileChannel.transferTo 的方法,关于这个方法,上文已经介绍过了。
总结:
- 使用 Netty 提供的
CompositeByteBuf
类, 可以将多个ByteBuf
合并为一个逻辑上的ByteBuf
, 避免了各个ByteBuf
之间的拷贝。 -
ByteBuf
支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的ByteBuf
, 避免了内存的拷贝。 - 通过
FileRegion
包装的FileChannel.tranferTo
实现文件传输, 可以直接将文件缓冲区的数据发送到目标Channel
, 避免了传统通过循环 write 方式导致的内存拷贝问题.
参考:
https://binglau7.github.io/2018/12/10/Netty%E4%B8%8EZero-Copy/
https://mp.weixin.qq.com/s?__biz=MzAwNDA2OTM1Ng==&mid=2453146714&idx=2&sn=fa45883a655b280c949d0e1c33f4d844&scene=21#wechat_redirect