user buffer
kernel buffer
send buffer
和 recv buffer
,它们合称为 socket buffer
DMA 的全称叫直接存储器访问(Direct Memory Access),是一种允许外围设备(硬件子系统)直接访问系统主内存的机制。
DMA 是为了避免 CPU 在磁盘操作时承担过多的中断负载而设计的;在磁盘操作中,CPU 可将总线控制权交给 DMA 控制器,由 DMA 输出读写命令,直接控制 RAM 与 I/O 接口进行 DMA 传输,无需 CPU 直接控制传输,也没有中断处理方式那样保留现场和恢复现场过程,使得 CPU 的效率大大提高。
在没有 DMA 的时候, 整个 IO 过程是这样的:
流程如下图所示:
DMA 下读取磁盘数据流程如下:
整体流程如图所示:
在有了 DMA 后, 程序传统IO实际上是调用系统的read()
和write()
实现,通过read()
把数据从硬盘读取到内核缓冲区,再复制到用户缓冲区;然后再通过write()
写入到socket缓冲区,最后写入网卡设备
具体流程:
read()
方法向操作系统发起调用,此时上下文从用户态转向内核态read()
返回write()
方法发起调用,上下文从用户态转为内核态write()
返回我们可以看到在传统文件读取过程发生了四次用户态和内核态的切换还有四次IO拷贝(两次DMA拷贝, 两次CPU拷贝), 要提高传输的性能, 就需要减少用户态与内核态的切换和内存拷贝的次数。
在Linux中零拷贝的实现方式主要有: mmap + write
、sendfile
、splice
mmap 是 Linux 提供的一种内存映射文件方法,即将一个进程的地址空间中的一段虚拟地址映射到磁盘文件地址。
mmap 主要实现方式是将内核读缓冲区的地址和用户缓冲区的地址进行映射,内核缓冲区和应用缓冲区共享,从而减少了从读缓冲区到用户缓冲区的一次CPU拷贝(等于传统的一次 read()调用
),然而内核读缓冲区(read buffer)仍需将数据拷贝到内核写缓冲区(socket buffer)
基于 mmap + write 系统调用的零拷贝方式,整个过程发生了4次用户态和内核态的上下文切换和3次拷贝(减少了一次内核态到用户态的切换),具体流程如下:
mmap 主要的用处是提高 I/O 性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是 4 KB,一个 5 KB 的文件将会映射占用 8 KB 内存,也就会浪费 3 KB 内存。
通过使用 sendfile
数据可以直接在内核空间进行传输,因此避免了用户空间和内核空间的拷贝,同时由于使用sendfile替代了read+write
从而节省了一次系统调用,也就是2次上下文切换。
整个过程发生了2次用户态和内核态的上下文切换和3次拷贝,具体流程如下:
sendfile方法IO数据对用户空间完全不可见,所以只能适用于完全不需要用户空间处理的情况,比如静态文件服务器。
sendfile 只适用于把数据从磁盘中读出来往 socket buffer 发送的场景
Linux 2.4 内核进行了优化,提供了带有 scatter/gather 的 sendfile 操作,这个操作可以把最后一次 CPU COPY 去除。其原理就是在内核空间 Read BUffer 和 Socket Buffer 不做数据复制,而是将 Read Buffer 的内存地址、偏移量记录到相应的 Socket Buffer 中,这样就不需要复制。其本质和虚拟内存的解决方法思路一致,就是内存地址的记录。
通过以下命令查看网卡是否支持 scatter-gather:
ethtool -k eth0 | grep scatter-gather
scatter-gather: on
它将读缓冲区中的数据描述信息–内存地址和偏移量记录到socket缓冲区,由 DMA 根据这些将数据从读缓冲区拷贝到网卡,相比之前版本减少了一次CPU拷贝的过程
整个过程发生了2次用户态和内核态的上下文切换和2次拷贝,其中更重要的是完全没有CPU拷贝,具体流程如下:
相较于普通 sendfile 方式, 这种方式只进行了两次数据拷贝, 省略了一次CPU拷贝 DMA gather 和 sendfile 一样数据对用户空间不可见,而且需要硬件支持,同时输入文件描述符只能是文件,但是过程中完全没有CPU拷贝过程,极大提升了性能。
splice 调用和 sendfile 非常相似,用户应用程序必须拥有两个已经打开的文件描述符,一个表示输入设备,一个表示输出设备。与sendfile不同的是,splice允许任意两个文件互相连接,而并不只是文件与socket进行数据传输。对于从一个文件描述符发送数据到socket这种特例来说,一直都是使用sendfile系统调用,而splice一直以来就只是一种机制,它并不仅限于sendfile的功能。也就是说 sendfile 是 splice 的一个子集。
splice() 是基于 Linux 的管道缓冲区 (pipe buffer) 机制实现的,所以splice()的两个入参文件描述符要求必须有一个是管道设备。
使用 splice() 完成一次磁盘文件到网卡的读写过程如下:
kafka 文件传输中用到了 Java NIO 库中的 transferTo
:
long transferFrom(FileChannel fileChannel, long position, long count) throws IOException {
return fileChannel.transferTo(position, count, socketChannel);
}
在 Linux 系统中 transferTo()
实际上最后就会使用到 sendfile()
系统调用函数。
Nginx 也支持零拷贝技术,一般默认是开启零拷贝技术,这样有利于提高文件传输的效率,是否开启零拷贝技术的配置如下:
http {
...
sendfile on
...
}
在大文件传输的场景下,零拷贝技术并不是最优选择;因为在零拷贝的任何一种实现中,都会有「DMA 将数据从磁盘拷贝到内核缓存区——Page Cache」这一步,但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能。
这是因为在大文件传输场景下,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,PageCache 空间很快被这些大文件占满;且由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:
另外异步 I/O 并没有涉及到 PageCache;使用异步 I/O 就意味着要绕开 PageCache,因为填充 PageCache 的过程在内核中必须阻塞。
所以异步 I/O 中使用的是direct I/O
(对比使用 PageCache 的buffer I/O),这样才能不阻塞进程,立即返回。
OS层面的零拷贝主要避免在用户态(User-space)
和内核态(Kernel-space)
之间来回拷贝数据。
Netty中的 zero-copy
不同于操作系统,它完全是在用户态(java 层面),更多的偏向于优化数据操作这样的概念,体现在:
CompositeByteBuf
类, 它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf, 避免了各个 ByteBuf 之间的拷贝FileRegion
包装的FileChannel.transferTo
实现文件传输, 可以直接将文件缓冲区的数据发送到目标 Channel, 避免了传统通过循环 write 方式导致的内存拷贝问题上述的 Netty 包装了 FileChannel.transferTo
实际上也是对操作系统 sendfile
的一个封装, 我们可以理解为 Netty 即支持了系统层面的零拷贝, 还有一个重要作用就是:防止 JVM 中不必要的复制
ByteBuf是Netty进行数据读写交互的单位,结构如下:
writerIndex-readerIndex
个字节可读,当 readerIndex 与 writerIndex 相等的时候,ByteBuf 不可读Composite buffer
实现了透明的零拷贝,将物理上的多个 Buffer 组合成了一个逻辑上完整的 CompositeByteBuf.
比如在网络编程中, 一个完整的 http 请求常常会被分散到多个 Buffer 中。用 CompositeByteBuf 很容易将多个分散的Buffer组装到一起,而无需额外的复制:
ByteBuf header = Unpooled.buffer();// 模拟http请求头
ByteBuf body = Unpooled.buffer();// 模拟http请求主体
CompositeByteBuf httpBuf = Unpooled.compositeBuffer();
// 这一步,不需要进行header和body的额外复制,httpBuf只是持有了header和body的引用
// 接下来就可以正常操作完整httpBuf了
httpBuf.addComponents(header, body);
而 JDK ByteBuffer 完成这一需求:
ByteBuffer header = ByteBuffer.allocate(1024);// 模拟http请求头
ByteBuffer body = ByteBuffer.allocate(1024);// 模拟http请求主体
// 需要创建一个新的ByteBuffer来存放合并后的buffer信息,这涉及到复制操作
ByteBuffer httpBuffer = ByteBuffer.allocate(header.remaining() + body.remaining());
// 将header和body放入新创建的Buffer中
httpBuffer.put(header);
httpBuffer.put(body);
httpBuffer.flip();
相比于JDK,Netty的实现更合理,省去了不必要的内存复制,可以称得上是JVM层面的零拷贝。
例如我们有一个 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 对象中
Unpooled 提供的方法可以将一个或多个 buffer 包装为一个 ByteBuf 对象, 从而避免了拷贝操作.
slice 操作和 wrap 操作刚好相反, Unpooled.wrappedBuffer
可以将多个 ByteBuf 合并为一个
而 slice 操作将一个 ByteBuf 切片为多个共享一个存储区域的 ByteBuf 对象,如:
ByteBuf byteBuf = ...
ByteBuf header = byteBuf.slice(0, 5);
ByteBuf body = byteBuf.slice(5, 10);
用 slice 方法产生 byteBuf 的过程是没有拷贝操作的, header 和 body 对象在内部其实是共享了 byteBuf 存储空间的不同部分而已
Netty 中使用 FileRegion 实现文件传输的零拷贝, 不过在底层 FileRegion 是依赖于 Java NIO FileChannel.transfer
的零拷贝功能.
Java NIO FileChannel.transfer
实际上是对 sendfile 的一种实现, 直接在内核态之间拷贝内存
一个文件拷贝的功能, 那么使用传统的方式实现如下:
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 来实现零拷贝传输一个文件:
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");
}