1.简介
1.1 解释
可以这么理解,能减少不必要的数据拷贝次数,就算是“零拷贝”。
Linux2.4内核新增sendfile系统调用。磁盘数据通过DMA(direct memory access)拷贝到内核Buffer,直接通过DMA拷贝到NIC (network interface controller)Buffer,无需CPU拷贝,这是操作系统意义上的零拷贝。
1.2 Linux I/O 机制
用户进程需要读取磁盘数据,需要CPU中断,发起IO请求,每次的IO中断,都带来CPU的上下文切换
1.3 DMA
为了解决CPU的上下文切换,出现了DMA(显卡、网卡、声卡都是支持 DMA ),直接内存读取。可以理解为,让硬件直接跳过CPU的调度,直接访问主内存
1.3Linux I/O 最新流程
- DMA等待数据准备好,把磁盘数据读取到操作系统内核缓冲区
- 用户进程,将内核缓冲区数据copy到用户空间
2.传统Java IO
直接上代码
//服务端读取 html 里的内容后变成字节数组
File file = new File("index.html");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
byte[] arr = new byte[(int) file.length()];
raf.read(arr);
//监听 8080 端口,接收请求处理
Socket socket = new ServerSocket(8080).accept();
//html 里的字节流写到 socket 中
socket.getOutputStream().write(arr);
读写流程图
备注:上半部分表示用户态和内核态的上下文切换。下半部分表示数据复制操作
- read 调用导致用户态到内核态的一次变化,同时,第一次复制开始:DMA(不使用CPU 拷贝数据到内存,而是 DMA 引擎传输数据到内存)引擎从磁盘读取 index.html 文件,并将数据放入到内核缓冲区。
- 发生第二次数据拷贝,即:将内核缓冲区的数据拷贝到用户缓冲区,同时,发生了一次用内核态到用户态的上下文切换。
- 发生第三次数据拷贝,我们调用 write 方法,系统将用户缓冲区的数据拷贝到 socket 缓冲区。此时,又发生了一次用户态到内核态的上下文切换。
- 第四次拷贝,数据异步的从 socket 缓冲区,使用 DMA 引擎拷贝到网络协议引擎。这一段,不需要进行上下文切换。
write 方法返回,再次从内核态切换到用户态
总结:传统IO,发生了4次数据拷贝,3次上下文切换
3.零拷贝
目的:减少 IO 流程中不必要的拷贝
零拷贝需要 OS 支持,也就是需要 kernel 暴露 api,虚拟机不能操作内核。
Linux 支持的(常见)零拷贝
3.1 mmap 内存映射
mmap 通过内存映射,将文件映射到内核缓冲区,同时,用户空间可以共享内核空间的数据。这样,在进行网络传输时,就可以减少内核空间到用户控件的拷贝次数。
直接贴图:
如上图,user buffer 和 kernel buffer 共享 index.html。如果你想把硬盘的 index.html 传输到网络中,再也不用拷贝到用户空间,再从用户空间拷贝到 socket 缓冲区。
现在,你只需要从内核缓冲区拷贝到 socket 缓冲区即可,这将减少一次内存拷贝(从 4 次变成了 3 次),但不减少上下文切换次数。
3.2 sendfile
Linux 2.1 版本提供了 sendFile 函数
其基本原理:数据根本不经过用户态,直接从内核缓冲区进入到 Socket Buffer,同时,由于和用户态完全无关,就减少了一次上下文切换。
如上图,我们进行 sendFile 系统调用时,
- 数据被 DMA 引擎从文件复制到内核缓冲区,然后调用 write 方法时,从内核缓冲区进入到socket,这时,是没有上下文切换的,因为都在内核空间。
- 最后,数据从 socket 缓冲区进入到协议栈。此时,数据经过了 3 次拷贝,2 次上下文切换。
3.3 Sendfile With DMA Scatter/Gather Copy
Scatter/Gather 可以看作是 sendfile 的增强版,批量 sendfile。
简单直接-直接从内核缓冲区拷贝到网络协议栈,避免了从内核缓冲区拷贝到 socket buffer 的操作,直接拷贝到协议栈,从而再一次减少了数据拷贝。
上图
现在,index.html 要从文件进入到网络协议栈,只需 2 次拷贝:
- 第一次使用 DMA 引擎从文件拷贝到内核缓冲区。
- 第二次从内核缓冲区将数据拷贝到网络协议栈。
内核缓存区只会拷贝一些 offset 和 length 信息到 socket buffer,基本无消耗。
3.4 mmap 和 sendFile 的区别
- mmap 适合小数据量读写,sendFile 适合大文件传输。
- sendFile 可以利用 DMA 方式,减少 CPU 拷贝,mmap 则不能(必须从内核拷贝到 Socket 缓冲区)。
- RocketMQ 在消费消息时,使用了 mmap。Kafka 使用了 sendFile。
4. NIO应用
4.1 MappedByteBuffer
Java NlO 中 的 Channel (通道)就相当于操作系统中的内核缓冲区,有可能是读缓冲区,也有可能是网络缓冲区,而 Buffer 就相当于操作系统中的用户缓冲区
MappedByteBuffer mappedByteBuffer = new RandomAccessFile(file, "r")
.getChannel()
.map(FileChannel.MapMode.READ_ONLY, 0, len);
NIO 中的 FileChannel.map() 方法其实就是采用了操作系统中的内存映射方式,底层就是调用 Linux mmap() 实现的
4.2 sendfile
- FileChannel.transferTo() 方法直接将当前通道内容传输到另一个通道,没有涉及到 Buffer 的任何操作,NIO中的 Buffer 是 JVM 堆或者堆外内存,但不论如何他们都是操作系统内核空间的内存。
- transferTo() 的实现方式就是通过系统调用 sendfile() (当然这是Linux中的系统调用)。
// 使用sendfile:读取磁盘文件,并网络发送
FileChannel sourceChannel = new RandomAccessFile(source, "rw").getChannel();
SocketChannel socketChannel = SocketChannel.open(sa);
sourceChannel.transferTo(0, sourceChannel.size(), socketChannel);
ZeroCopyFile实现文件复制:
class ZeroCopyFile {
public void copyFile(File src, File dest) {
try (FileChannel srcChannel = new FileInputStream(src).getChannel();
FileChannel destChannel = new FileInputStream(dest).getChannel()) {
srcChannel.transferTo(0, srcChannel.size(), destChannel);
} catch (IOException e) {
e.printStackTrace();
}
}
}
Java NIO 提供的 FileChannel.transferTo 和 transferFrom 并不保证一定能使用零拷贝。实际上是否能使用零拷贝与操作系统相关,如果操作系统提供 sendfile 这样的零拷贝系统调用,则这两个方法会通过这样的系统调用充分利用零拷贝的优势,否则并不能通过这两个方法本身实现零拷贝
4.3 Netty
Netty 中也用到了 FileChannel.transferTo 方法,所以 Netty 的零拷贝也包括上面讲的操作系统级别的零拷贝。
传统的 ByteBuffer,如果需要将两个 ByteBuffer 中的数据组合到一起,我们需要首先创建一个size=size1+size2 大小的新的数组,然后将两个数组中的数据拷贝到新的数组中。但是使用 Netty 提供的组合 ByteBuf,就可以避免这样的操作,因为 CompositeByteBuf 并没有真正将多个 Buffer 组合起来,而是保存了它们的引用,从而避免了数据的拷贝,实现了零拷贝。
CompositeByteBuf:将多个缓冲区显示为单个合并缓冲区的虚拟缓冲区。
建议使用 ByteBufAllocator.compositeBuffer() 或者 Unpooled.wrappedBuffer(ByteBuf…),而不是显式调用构造函数。
源码:
public abstract class ByteToMessageDecoder extends ChannelInboundHandlerAdapter {
public static final ByteToMessageDecoder.Cumulator MERGE_CUMULATOR = new ByteToMessageDecoder.Cumulator() {
public ByteBuf cumulate(ByteBufAllocator alloc, ByteBuf cumulation, ByteBuf in) {
ByteBuf var5;
try {
ByteBuf buffer;
if (cumulation.writerIndex() <= cumulation.maxCapacity() - in.readableBytes() && cumulation.refCnt() <= 1 && !cumulation.isReadOnly()) {
buffer = cumulation;
} else {
buffer = ByteToMessageDecoder.expandCumulation(alloc, cumulation, in.readableBytes());
}
buffer.writeBytes(in);
var5 = buffer;
} finally {
in.release();
}
return var5;
}
};
// 可以看出来这里用了ByteBufAllocator 来分配readable的空间,并写入累积器中
static ByteBuf expandCumulation(ByteBufAllocator alloc, ByteBuf cumulation, int readable) {
ByteBuf oldCumulation = cumulation;
cumulation = alloc.buffer(cumulation.readableBytes() + readable);
cumulation.writeBytes(oldCumulation); // 将原始累积器的数据copy到新的累积器
oldCumulation.release(); // 释放原始的累积器
return cumulation;
}
...
}
写文件 Region
从这里我们可以看出 netty 也调用了 FileChannelDe tansferTo 方法:
public class DefaultFileRegion extends AbstractReferenceCounted implements FileRegion {
private FileChannel file;
public long transferTo(WritableByteChannel target, long position) throws IOException {
long count = this.count - position;
if (count >= 0L && position >= 0L) {
if (count == 0L) {
return 0L;
} else if (this.refCnt() == 0) {
throw new IllegalReferenceCountException(0);
} else {
this.open();
long written = this.file.transferTo(this.position + position, count, target);
if (written > 0L) {
this.transferred += written;
} else if (written == 0L) {
validate(this, position);
}
return written;
}
} else {
throw new IllegalArgumentException("position out of range: " + position + " (expected: 0 - " + (this.count - 1L) + ')');
}
}
...
}