零拷贝技术:mmap和sendfile
概述
零拷贝,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.
通常是指计算机在网络上发送文件时,不需要将文件内容拷贝到用户空间(User Space),而直接在内核空间(Kernel Space)中传输到网络的方式。所谓的两个空间只是说明两个不一样的内存区域,通过减少不同内存区域的拷贝,减低 CPU 的消耗。
省去将操作系统的read buffer拷贝到程序的buffer,以及从程序buffer拷贝到socket buffer的步骤,直接将read buffer拷贝到socket buffer
普通IO
先看看普通IO在读取本地磁盘文件,然后通过网络发送出去的流程:
File file = new File("a.txt");
RandomAccessFile raf = new RandomAccessFile(file, "rw");
byte[] arr = new byte[(int)file.length()];
raf.read(arr);
Socket socket = new ServerSocket(8080).accept();
socket.getOutputStream().write(arr);
使用read读取数据时,会有一次用户态到内核态的切换,此时基于DMA引擎把磁盘上的数据拷贝到内核缓冲区中去,接着会从内核态切换到用户态,基于CPU把内核缓冲区的数据拷贝到用户缓冲区中去。
然后调用Socket输出流的write方法,会从用户态切换到内核态,基于CPU把用户缓冲区的数据拷贝到Socket缓冲区里去,接着有一个异步化过程,基于DMA引擎从Socket缓冲区把数据拷贝到网络协议引擎里发送出去。
都完成之后,从内核态切换回用户态。
故,从本地磁盘读取数据,通过网络发送出去,用户态和内核态之间需要发生4次切换;数据从磁盘取出来之后,一共要经过4次拷贝。
mmap
把磁盘文件映射到内存中,然后把映射到内存的数据通过Socket发送出去。
mmap,内存映射,直接将磁盘文件数基于DMA引擎拷贝据映射到内核缓冲区,同时用户缓冲区是跟内核缓冲区共享一块映射数据,建立映射后,不需要从内核缓冲区拷贝到用户缓冲区。故,可减少一次拷贝。总共是4次切换,3次拷贝。
sendfile
Linux提供sendfile技术。Kafka中,transferFrom和transferTo方法。
零拷贝技术,先从用户态切换到内核态,把磁盘数据拷贝到内核缓冲区,同时从内环缓冲区拷贝一些offset和length数据到socket缓冲区,接着从内核态切换到用户态,从内核缓冲区直接把数据拷贝到网络协议引擎里去,同时从Socket缓冲区拷贝一些offset和length信息到网络协议引擎里去,offset和length量几乎可以忽略。
只要2次切换,2次拷贝。
原理
读取本地磁盘文件,然后通过网络发送出去,可抽象成下面两个过程:
read(file, tmp_buf, len);
write(socket, tmp_buf, len);
首先调用read将磁盘文件A,读取到tmp_buf,后调用write将tmp_buf写入到socket中:
在这个过程中文件A的经历4次copy的过程:
- 调用read时,文件A拷贝到kernel模式
- 而后CPU控制将kernel模式数据copy到user模式下
- 调用write时,先将user模式下的内容copy到kernel模式下的socket的buffer中
- 最后将kernel模式下的socket buffer的数据copy到网卡设备中传送
数据从kernel模式到user模式走一圈,浪费2次copy(第一次从kernel模式拷贝到user模式;第二次从user模式再拷贝回kernel模式,即上面4次过程的第2和3步骤)。而且上面的过程中kernel和user模式的上下文的切换也是4次。
Zero-Copy的目标就是省略这些无谓的copy。应用程序用Zero-Copy来请求kernel直接把disk的data传输给socket,而不是通过应用程序传输,减少kernel和user模式上下文的切换,故而可以大大提高应用程序的性能。
应用
零拷贝技术,包括mmap和sendfile两种形式,在各种框架中常有体现。
Java
MappedByteBuffer & DirectByteBuffer
Java NIO中的FileChannel 提供map() 方法,该方法可以在一个打开的文件和 MappedByteBuffer 之间建立一个虚拟内存映射。MappedByteBuffer继承于 ByteBuffer,类似于一个基于内存的缓冲区,只不过该对象的数据元素存储在磁盘的一个文件中。调用 get() 方法会从磁盘中获取数据,此数据反映该文件当前的内容,调用 put() 方法会更新磁盘上的文件,并且对文件做的修改对其他阅读者也是可见的。
实例
public class MappedByteBufferTest {
public static void main(String[] args) throws Exception {
File file = new File("D://db.txt");
long len = file.length();
byte[] ds = new byte[(int) len];
MappedByteBuffer mappedByteBuffer = new FileInputStream(file).getChannel().map(FileChannel.MapMode.READ_ONLY, 0, len);
for (int offset = 0; offset < len; offset++) {
byte b = mappedByteBuffer.get();
ds[offset] = b;
}
Scanner scan = new Scanner(new ByteArrayInputStream(ds)).useDelimiter(" ");
while (scan.hasNext()) {
System.out.print(scan.next() + " ");
}
}
}
FileChannel.map()
抽象方法定义:
public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException;
方法提供三个参数:
- MapMode:映射模式,可选项:READ_ONLY,READ_WRITE,PRIVATE
- Position:从哪个位置开始映射,字节数的位置
- Size:从 Position 开始向后多少个字节
MapMode,当然请求的映射模式受到 Filechannel 对象的访问权限限制,如果在一个没有读权限的文件上启用 READ_ONLY,将抛出 NonReadableChannelException。PRIVATE 模式表示写时拷贝的映射,意味着通过 put() 方法所做的任何修改都会导致产生一个私有的数据拷贝并且该拷贝中的数据只有 MappedByteBuffer 实例可以看到。该过程不会对底层文件做任何修改,而且一旦缓冲区被施以垃圾收集动作,那些修改都会丢失。
map()
方法源码:
public MappedByteBuffer map(MapMode mode, long position, long size) throws IOException {
int pagePosition = (int)(position % allocationGranularity);
long mapPosition = position - pagePosition;
long mapSize = size + pagePosition;
try {
// If no exception was thrown from map0, the address is valid
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError x) {
// An OutOfMemoryError may indicate that we've exhausted memory
// so force gc and re-attempt map
System.gc();
try {
Thread.sleep(100);
} catch (InterruptedException y) {
Thread.currentThread().interrupt();
}
try {
addr = map0(imode, mapPosition, mapSize);
} catch (OutOfMemoryError y) {
// After a second OOME, fail
throw new IOException("Map failed", y);
}
}
// On Windows, and potentially other platforms, we need an open
// file descriptor for some mapping operations.
FileDescriptor mfd;
try {
mfd = nd.duplicateForMapping(fd);
} catch (IOException ioe) {
unmap0(addr, mapSize);
throw ioe;
}
assert (IOStatus.checkAll(addr));
assert (addr % allocationGranularity == 0);
int isize = (int)size;
Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
if ((!writable) || (imode == MAP_RO)) {
return Util.newMappedByteBufferR(isize, addr + pagePosition, mfd, um);
} else {
return Util.newMappedByteBuffer(isize, addr + pagePosition, mfd, um);
}
}
通过 Native 方法获取内存映射的地址,如果失败,手动 GC 再次映射。最后通过内存映射的地址实例化出 MappedByteBuffer,MappedByteBuffer 本身是一个抽象类,其实这里真正实例化出来的是 DirectByteBuffer。
DirectByteBuffer继承于MappedByteBuffer,开辟一段直接内存,并不会占用JVM内存空间。通过 Filechannel 映射出的 MappedByteBuffer 其实际也是 DirectByteBuffer,也可以手动开辟一段空间:ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(100); // 开辟100字节的直接内存空间
Channel-to-Channel传输
经常需要将文件从一个位置传输到另外一个位置,FileChannel.transferTo()
方法可用来提高传输的效率:
public class ChannelTransfer {
public static void main(String[] argv) throws Exception {
String files[] = new String[1];
files[0] = "D://db.txt";
catFiles(Channels.newChannel(System.out), files);
}
private static void catFiles(WritableByteChannel target, String[] files) throws Exception {
for (int i = 0; i < files.length; i++) {
FileInputStream fis = new FileInputStream(files[i]);
FileChannel channel = fis.getChannel();
channel.transferTo(0, channel.size(), target);
channel.close();
fis.close();
}
}
}
FileChannel.transferTo()
抽象方法定义:
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException;
参数分别是开始传输的位置,传输的字节数,以及目标通道。transferTo() 允许将一个通道交叉连接到另一个通道,而不需要一个中间缓冲区来传递数据。不需要中间缓冲区有两层意思:第一层不需要用户空间缓冲区来拷贝内核缓冲区,另外一层两个通道都有自己的内核缓冲区,两个内核缓冲区也可以做到无需拷贝数据。
FileChannel.transferTo()
实现Zero-Copy:
Spring Boot 2.0
spring-core 模块提供响应式 Encoder (编码器) 和 Decoder (解码器),使得能够串行化字符串与类型对象的转换。spring-web 模块添加 JSON(Jackson)和 XML(JAXB)实现,用于Web应用程序以及其他用于SSE流和零拷贝文件传输。
Netty
Netty的零拷贝与实际定义还是有点出入,Java是基于虚拟机的,其实都是用户空间,Netty中的零拷贝,更多的是一种数据的优化操作,比如多包合并处理上,Netty是将各个包的地址记录下来,在逻辑上合成一个整体,实际存储还是独立的,这样减少内存拷贝,降低 CPU 消耗。在传输数据时,最终处理的数据会需要对单个传输的报文,进行组合和拆分,NIO 原生的 ByteBuffer 无法做到。通过提供的 Composite(组合)和 Slice(拆分)两种 Buffer 来实现零拷贝。
requestPart1 = buffer1.slice(OFFSET_PAYLOAD, buffer1.readableBytes() - OFFSET_PAYLOAD);
requestPart2 = buffer2.slice(OFFSET_PAYLOAD, buffer2.readableBytes() - OFFSET_PAYLOAD);
request = ChannelBuffers.wrappedBuffer(requestPart1, requestPart2);
TCP 层 HTTP 报文被分成两个 ChannelBuffer,这两个 Buffer 对上层的逻辑(HTTP 处理)是没有意义的。但两个 ChannelBuffer 被组合起来就是一个有意义的 HTTP 报文,这个报文对应的 ChannelBuffer,才能称之为Message
,Virtual Buffer。
CompositeChannelBuffer源码:
public class CompositeChannelBuffer extends AbstractChannelBuffer {
private final ByteOrder order;
private ChannelBuffer[] components;
private int[] indices;
private int lastAccessedComponentId;
private final boolean gathering;
public byte getByte(int index) {
int componentId = componentId(index);
return components[componentId].getByte(index - indices[componentId]);
}
}
Components 用来保存的就是所有接收到的 Buffer,Indices 记录每个 buffer 的起始位置,lastAccessedComponentId 记录上一次访问的 ComponentId。CompositeChannelBuffer 并不会开辟新的内存并直接复制所有 ChannelBuffer 内容,而是直接保存所有 ChannelBuffer 的引用,并在子 ChannelBuffer 里进行读写,实现零拷贝。
RocketMQ
Consumer消费消息过程使用零拷贝,零拷贝包括2种方式,RocketMQ使用第一种方式,因小块数据传输的效果比sendfile方式好
- 使用mmap+write方式
优点:即使频繁调用,使用小文件块传输,效率也很高
缺点:不能很好的利用DMA方式,会比sendfile多消耗CPU资源,内存安全性控制复杂,需要避免JVM Crash问题 - 使用sendfile方式
优点:可以利用DMA方式,消耗CPU资源少,大块文件传输效率高,无内存安全新问题
缺点:小块文件效率低于mmap方式,只能是BIO方式传输,不能使用NIO
Kafka
Tomcat
Kafka
Kafka中存在大量的网络数据持久化到磁盘和磁盘文件通过网络发送的过程,使用Sendfile方式
参考
IBM-developerworks-Linux 中的零拷贝技术1
IBM-developerworks-Linux 中的零拷贝技术2
深入探秘Netty、Kafka中的零拷贝技术