目录
先备知识
物理内存
虚拟内存
内核空间和用户空间
内核空间
用户空间
DMA传输
上下文切换
传统IO执行流程
零拷贝实现的几种方式
用户态直接I/O
mmap+write实现的零拷贝
sendfile实现的零拷贝(用户不可见)
sendfile+DMA scatter/gather实现的零拷贝
splice
写时复制
总结
JAVA零拷贝API
Java NIO对mmap的支持
Java NIO对sendfile的支持
其它的零拷贝实现
Netty零拷贝
RocketMQ和Kafka对比
零拷贝(Zero-copy)技术指在计算机执行操作时,CPU 不需要先将数据从一个内存区域复制到另一个内存区域(例:CPU拷贝数据从内核缓冲区到用户缓冲区),从而可以减少CPU上下文切换以及 CPU 的拷贝时间。
它的作用是在数据报从网络设备到用户程序空间传递的过程中,减少数据拷贝次数(CPU拷贝),减少系统调用,实现 CPU 的零参与,彻底消除 CPU 在这方面的负载。
实现零拷贝用到的最主要技术是 DMA 数据传输技术和内存区域映射技术(mmap)。
物理内存,我们平时所称的内存也叫随机访问存储器( random-access memory )也叫 RAM 。而 RAM 分为两类:
SRAM
),这类 SRAM 用于 CPU 高速缓存 L1Cache,L2Cache,L3Cache。其特点是访问速度快,访问速度为 1 - 30 个时钟周期,但是容量小,造价高。DRAM
),这类 DRAM 用于我们常说的主存上,其特点的是访问速度慢(相对高速缓存),访问速度为 50 - 200 个时钟周期,但是容量大,造价便宜些(相对高速缓存)。内存由一个一个的存储器模块(memory module)组成,它们插在主板的扩展槽上。
常见的存储器模块通常以 64 位为单位( 8 个字节)传输数据到存储控制器上或者从存储控制器传出数据。
多个存储器模块连接到存储控制器上,就聚合成了主存。
DRAM 芯片就包装在存储器模块中,每个存储器模块中包含 8 个 DRAM 芯片,依次编号为 0 - 7
虚拟内存 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。
与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存(例如 RAM)的使用也更有效率。目前,大多数操作系统都使用了虚拟内存,如 Windows 家族的“虚拟内存”;Linux 的“交换空间”等。
现代处理器使用的是一种称为 虚拟寻址(Virtual Addressing) 的寻址方式。使用虚拟寻址,CPU 需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。
实际上完成虚拟地址转换为物理地址转换的硬件是 CPU 中含有一个被称为内存管理单元(Memory Management Unit, MMU) 的硬件。如下图所示:
通过虚拟地址访问内存有以下优势:
操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的权限。为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是内核空间(Kernel-space),一部分是用户空间(User-space)。 在 Linux 系统中,内核模块运行在内核空间,对应的进程处于内核态;而用户程序运行在用户空间,对应的进程处于用户态。
操作系统为每个进程都分配了内存空间,一部分是用户空间,一部分是内核空间。内核空间是操作系统内核访问的区域,是受保护的内存空间,而用户空间是用户应用程序访问的内存区域。
主要提供进程调度、内存分配、连接硬件资源等功能
内核空间总是驻留在内存中,它是为操作系统的内核保留的。应用程序是不允许直接在该区域进行读写或直接调用内核代码定义的函数的。按访问权限可以分为进程私有和进程共享两块区域。
每个普通的用户进程都有一个单独的用户空间,处于用户态的进程不能访问内核空间中的数据,也不能直接调用内核函数的 ,因此要进行系统调用的时候,就要将进程切换到内核态才行。用户空间包括以下几个内存区域:
DMA 的全称叫直接内存存取(Direct Memory Access),是一种允许外围设备(硬件子系统)直接访问系统主内存的机制。也就是说,基于 DMA 访问方式,系统主内存于硬盘或网卡之间的数据传输可以绕开 CPU 的全程调度。目前大多数的硬件设备,包括磁盘控制器、网卡、显卡以及声卡等都支持 DMA 技术。
数据读取操作的流程如下:
什么是CPU上下文?
CPU 寄存器,是CPU内置的容量小、但速度极快的内存。而程序计数器,则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。它们都是 CPU 在运行任何任务前,必须的依赖环境,因此叫做CPU上下文。
什么是CPU上下文切换?
它是指先把前一个任务的CPU上下文(也就是CPU寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
一般我们说的上下文切换,就是指内核(操作系统的核心)在CPU上对进程或者线程进行切换。进程从用户态到内核态的转变,需要通过系统调用来完成。系统调用的过程,会发生CPU上下文的切换。
CPU 寄存器里原来用户态的指令位置,需要先保存起来。接着,为了执行内核态代码,CPU 寄存器需要更新为内核态指令的新位置。最后才是跳转到内核态运行内核任务。
传统的IO流程,包括read和write的过程。
read
:把数据从磁盘读取到内核缓冲区,再拷贝到用户缓冲区
write
:先把数据写入到socket缓冲区,最后写入网卡设备。
用户应用进程调用read函数,向操作系统发起IO调用,上下文从用户态转为内核态(切换1)
DMA控制器把数据从磁盘中,读取到内核缓冲区。
CPU把内核缓冲区数据,拷贝到用户应用缓冲区,上下文从内核态转为用户态(切换2),read函数返回
用户应用进程通过write函数,发起IO调用,上下文从用户态转为内核态(切换3)
CPU将用户缓冲区中的数据,拷贝到socket缓冲区
DMA控制器把数据从socket缓冲区,拷贝到网卡设备,上下文从内核态切换回用户态(切换4),write函数返回
从流程图可以看出,传统IO的读写流程,包括了4次上下文切换(4次用户态和内核态的切换),4次数据拷贝(两次CPU拷贝以及两次的DMA拷贝)
在 Linux 中零拷贝技术主要有 3 个实现思路:用户态直接 I/O、减少数据拷贝次数以及写时复制技术。
用户态直接 I/O 使得应用进程或运行在用户态(user space)下的库函数直接访问硬件设备,数据直接跨过内核进行传输,内核在数据传输过程除了进行必要的虚拟存储配置工作之外,不参与任何其他工作,这种方式能够直接绕过内核,极大提高了性能。
用户态直接 I/O 只能适用于不需要内核缓冲区处理的应用程序,这些应用程序通常在进程地址空间有自己的数据缓存机制,称为自缓存应用程序,如数据库管理系统就是一个代表。其次,这种零拷贝机制会直接操作磁盘 I/O,由于 CPU 和磁盘 I/O 之间的执行时间差距,会造成大量资源的浪费,解决方案是配合异步 I/O 使用。
mmap 的函数原型如下:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
addr:指定映射的虚拟内存地址
length:映射的长度
prot:映射内存的保护模式
flags:指定映射的类型
fd:进行映射的文件句柄
offset:文件偏移量
使用 mmap 的目的是将内核中读缓冲区(read buffer)的地址与用户空间的缓冲区(user buffer)进行映射,从而实现内核缓冲区与应用程序内存的共享,省去了将数据从内核读缓冲区(read buffer)拷贝到用户缓冲区(user buffer)的过程,然而内核读缓冲区(read buffer)仍需将数据到内核写缓冲区(socket buffer)
用户进程通过mmap方法
向操作系统内核发起IO调用,上下文从用户态切换为内核态。
CPU利用DMA控制器,把数据从硬盘中拷贝到内核缓冲区。
上下文从内核态切换回用户态,mmap方法返回。
用户进程通过write
方法向操作系统内核发起IO调用,上下文从用户态切换为内核态。
CPU将内核缓冲区的数据拷贝到的socket缓冲区。
CPU利用DMA控制器,把数据从socket缓冲区拷贝到网卡,上下文从内核态切换回用户态,write调用返回。
可以发现,mmap+write
实现的零拷贝,I/O发生了4次用户空间与内核空间的上下文切换,以及3次数据拷贝。其中3次数据拷贝中,包括了2次DMA拷贝和1次CPU拷贝。
mmap 主要的用处是提高 I/O 性能,特别是针对大文件。对于小文件,内存映射文件反而会导致碎片空间的浪费,因为内存映射总是要对齐页边界,最小单位是 4 KB,一个 5 KB 的文件将会映射占用 8 KB 内存,也就会浪费 3 KB 内存。
mmap 的拷贝虽然减少了 1 次拷贝,提升了效率,但也存在一些隐藏的问题。当 mmap 一个文件时,如果这个文件被另一个进程所截获,那么 write 系统调用会因为访问非法地址被 SIGBUS 信号终止,SIGBUS 默认会杀死进程并产生一个 coredump,服务器可能因此被终止。
sendfile
是Linux2.1内核版本后引入的一个系统调用函数,API如下:
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
out_fd:为待写入内容的文件描述符,一个socket描述符。,
in_fd:为待读出内容的文件描述符,必须是真实的文件,不能是socket和管道。
offset:指定从读入文件的哪个位置开始读,如果为NULL,表示文件的默认起始位置。
count:指定在fdout和fdin之间传输的字节数。
sendfile表示在两个文件描述符之间传输数据,它是在操作系统内核中操作的,避免了数据从内核缓冲区和用户缓冲区之间的拷贝操作,因此可以使用它来实现零拷贝。
用户进程发起sendfile系统调用,上下文(切换1)从用户态转向内核态
DMA控制器,把数据从硬盘中拷贝到内核缓冲区。
CPU将读缓冲区中数据拷贝到socket缓冲区
DMA控制器,异步把数据从socket缓冲区拷贝到网卡,
上下文(切换2)从内核态切换回用户态,sendfile调用返回。
可以发现,sendfile
实现的零拷贝,I/O发生了2次用户空间与内核空间的上下文切换,以及3次数据拷贝。其中3次数据拷贝中,包括了2次DMA拷贝和1次CPU拷贝。
linux 2.4版本之后,对sendfile
做了优化升级,引入SG-DMA技术,其实就是对DMA拷贝加入了scatter/gather
操作,它可以直接从内核空间缓冲区中将数据读取到网卡。使用这个特点搞零拷贝,即还可以多省去一次CPU拷贝。
用户进程发起sendfile系统调用,上下文(切换1)从用户态转向内核态
DMA控制器,把数据从硬盘中拷贝到内核缓冲区。
CPU把内核缓冲区中的文件描述符信息(包括内核缓冲区的内存地址和偏移量)发送到socket缓冲区
DMA控制器根据文件描述符信息,直接把数据从内核缓冲区拷贝到网卡
上下文(切换2)从内核态切换回用户态,sendfile调用返回。
可以发现,sendfile+DMA scatter/gather
实现的零拷贝,I/O发生了2次用户空间与内核空间的上下文切换,以及2次数据拷贝。其中2次数据拷贝都是包DMA拷贝。这就是真正的 零拷贝(Zero-copy) 技术,全程都没有通过CPU来搬运数据,所有的数据都是通过DMA来进行传输的。
sendfile + DMA gather copy 拷贝方式同样存在用户程序不能对数据进行修改的问题,而且本身需要硬件的支持,它只适用于将数据从文件拷贝到 socket 套接字上的传输过程。
Linux 在 2.6.17 版本引入 splice 系统调用,不仅不需要硬件支持,还实现了两个文件描述符之间的数据零拷贝。
#include
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
fd_in参数是待输入描述符。如果它是一个管道文件描述符,则off_in必须设置为NULL;否则off_in表示从输入数据流的何处开始读取,此时若为NULL,则从输入数据流的当前偏移位置读入。
fd_out/off_out与上述相同,不过是用于输出。
len参数指定移动数据的长度。
flags参数则控制数据如何移动:
splice 拷贝方式也同样存在用户程序不能对数据进行修改的问题。除此之外,它使用了 Linux 的管道缓冲机制,可以用于任意两个文件描述符中传输数据,但是它的两个文件描述符参数中有一个必须是管道设备。
在某些情况下,内核缓冲区可能被多个进程所共享,如果某个进程想要这个共享区进行 write 操作,由于 write 不提供任何的锁操作,那么就会对共享区中的数据造成破坏,写时复制的引入就是 Linux 用来保护数据的。
写时复制指的是当多个进程共享同一块数据时,如果其中一个进程需要对这份数据进行修改,那么就需要将其拷贝到自己的进程地址空间中。这样做并不影响其他进程对这块数据的操作,每个进程要修改的时候才会进行拷贝,所以叫写时拷贝。这种方法在某种程度上能够降低系统开销,如果某个进程永远不会对所访问的数据进行更改,那么也就永远不需要拷贝。
无论是传统 I/O 拷贝方式还是引入零拷贝的方式,2 次 DMA Copy 是都少不了的,因为两次 DMA 都是依赖硬件完成的。下面从 CPU 拷贝次数、DMA 拷贝次数以及系统调用几个方面总结一下上述几种 I/O 拷贝方式的差别。
拷贝方式 | CPU拷贝 | DMA拷贝 | 系统调用 | 上下文切换 |
---|---|---|---|---|
传统方式(read + write) | 2 | 2 | read / write | 4 |
内存映射(mmap + write) | 1 | 2 | mmap / write | 4 |
sendfile | 1 | 2 | sendfile | 2 |
sendfile + DMA gather copy | 0 | 2 | sendfile | 2 |
splice | 0 | 2 | splice | 2 |
在 Java NIO 中的通道(Channel)就相当于操作系统的内核空间(kernel space)的缓冲区,而缓冲区(Buffer)对应的相当于操作系统的用户空间(user space)中的用户缓冲区(user buffer)。
MappedByteBuffer 是 NIO 基于内存映射(mmap)这种零拷贝方式的提供的一种实现,它继承自 ByteBuffer。
FileChannel 定义了一个 map() 方法,它可以把一个文件从 position 位置开始的 size 大小的区域映射为内存映像文件。抽象方法 map() 方法在 FileChannel 中的定义如下:
public abstract MappedByteBuffer map(MapMode mode,long position, long size)throws IOException;
MappedByteBuffer 相比 ByteBuffer 新增了 fore()、load() 和 isLoad() 三个重要的方法:
package create;import java.io.IOException;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;import static java.nio.channels.FileChannel.MapMode.READ_ONLY;
import static java.nio.channels.FileChannel.MapMode.READ_WRITE;public class Solution {private final static String CONTENT = "Zero copy implemented by MappedByteBuffer";private final static String FILE_NAME = "E:\\mmap.txt";public static void main(String[] args) throws IOException {new Solution().readAndWrite();}public void readAndWrite() {Path path = Paths.get(FILE_NAME);byte[] bytes = CONTENT.getBytes(StandardCharsets.UTF_8);try (FileChannel writeChannel = FileChannel.open(path, StandardOpenOption.READ,StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING);FileChannel readChannel = FileChannel.open(path, StandardOpenOption.READ);) {MappedByteBuffer mappedByteBuffer = writeChannel.map(READ_WRITE, 0, bytes.length);if (mappedByteBuffer != null) {mappedByteBuffer.put(bytes);mappedByteBuffer.force();}MappedByteBuffer buffer = readChannel.map(READ_ONLY, 0, bytes.length);byte[] readFile = new byte[buffer.capacity()];buffer.get(readFile, 0, buffer.capacity());System.out.println(new String(readFile));} catch (IOException e) {e.printStackTrace();}}}
map() 方法通过本地方法 map0() 为文件分配一块虚拟内存,作为它的内存映射区域,然后返回这块内存映射区域的起始地址。
map() 方法返回的是内存映射区域的起始地址,通过(起始地址 + 偏移量)就可以获取指定内存的数据。这样一定程度上替代了 read() 或 write() 方法,底层直接采用 sun.misc.Unsafe 类的 getByte() 和 putByte() 方法对数据进行读写。
private native long map0(int prot, long position, long mapSize) throws IOException;
FileChannel 是一个用于文件读写、映射和操作的通道,同时它在并发环境下是线程安全的,基于 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 getChannel() 方法可以创建并打开一个文件通道。
FileChannel 定义了 transferFrom() 和 transferTo() 两个抽象方法,它通过在通道和通道之间建立连接实现数据传输的。底层就是sendfile() 系统调用函数。
public abstract long transferTo(long position, long count, WritableByteChannel target)throws IOException;
public abstract long transferFrom(ReadableByteChannel src, long position, long count)throws IOException;
package create;import java.io.IOException;
import java.nio.channels.FileChannel;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;public class Solution {private final static String CONTENT = "Zero copy implemented by MappedByteBuffer";private static final String SOURCE_FILE = "E:\\source.txt";private static final String TARGET_FILE = "E:\\target.txt";public static void main(String[] args) throws IOException {new Solution().readAndWrite();}public void readAndWrite() {try (FileChannel read = FileChannel.open(Paths.get(SOURCE_FILE),StandardOpenOption.READ, StandardOpenOption.CREATE);FileChannel write = FileChannel.open(Paths.get(TARGET_FILE),StandardOpenOption.WRITE, StandardOpenOption.CREATE)) {
// read.transferTo(read.position(), read.size(), write);write.transferFrom(read, read.position(), read.size());} catch (IOException e) {e.printStackTrace();}}}
Netty 中的零拷贝和上面提到的操作系统层面上的零拷贝不太一样, 我们所说的 Netty 零拷贝完全是基于(Java 层面)用户态的,它的更多的是偏向于数据操作优化这样的概念,具体表现在以下几个方面:
其中第 1 条属于操作系统层面的零拷贝操作,后面 3 条只能算用户层面的数据操作优化。
RocketMQ 选择了 mmap + write 这种零拷贝方式,适用于业务级消息这种小块文件的数据持久化和传输;而 Kafka 采用的是 sendfile 这种零拷贝方式,适用于系统日志消息这种高吞吐量的大块文件的数据持久化和传输。但是值得注意的一点是,Kafka 的索引文件使用的是 mmap + write 方式,数据文件使用的是 sendfile 方式。
消息队列 | 零拷贝方式 | 优点 | 缺点 |
---|---|---|---|
RocketMQ | mmap + write | 适用于小块文件传输,频繁调用时,效率很高 | 不能很好的利用 DMA 方式,会比 sendfile 多消耗 CPU,内存安全性控制复杂,需要避免 JVM Crash 问题 |
Kafka | sendfile | 可以利用 DMA 方式,消耗 CPU 较少,大块文件传输效率高,无内存安全性问题 | 小块文件效率低于 mmap 方式,只能是 BIO 方式传输,不能使用 NIO 方式 |