文章目录
1. DMA
在学习零拷贝等NIO技术之前,我们需要先知道什么是DMA。DMA(Direct Memory Access,直接存储器访问)。在DMA出现之前,CPU与外设之间的数据传送方式有程序传送方式、中断传送方式。CPU是通过系统总线与其他部件连接并进行数据传输。不管何种传送方式,都要消耗CPU,间接影响了其他任务的执行。
1.1 DMA原理
DMA的出现就是为了解决批量数据的输入/输出问题。DMA是指外部设备不通过CPU而直接与系统内存交换数据的接口技术。类比显卡,也是从CPU中剥离出来的功能。将这些特殊的模块进行剥离,使得CPU可以更加专注于计算工作。
通常系统总线是由CPU管理的,在DMA方式时,就希望CPU把这些总线让出来而由DMA控制器接管,控制传送的字节数,判断DMA是否结束,以及发出DMA结束信号。因此DMA控制器必须有以下功能:
1、能向CPU发出系统保持(HOLD)信号,提出总线接管请求;
2、当CPU发出允许接管信号后,对总线的控制由DMA接管;
3、能对存储器寻址及能修改地址指针,实现对内存的读写;
4、能决定本次DMA传送的字节数,判断DMA传送是否借宿。
5、发出DMA结束信号,使CPU恢复正常工作状态。
2. Page Cache
2.1 文件
从应用程序的角度看,操作系统提供了一个统一的虚拟机,在该虚拟机中没有各种机器的具体细节,只有进程、文件、地址空间以及进程间通信等逻辑概念。这种抽象虚拟机使得应用程序的开发变得相对容易。对于存储设备上的数据,操作系统向应用程序提供的逻辑概念就是"文件"。应用程序要存储或访问数据时,只需读或者写"文件"的一维地址空间即可,而这个地址空间与存储设备上存储块之间的对应关系则由操作系统维护。说白了,文件就是基于内核态Page Cache的一层抽象,下文有详细介绍。
2.2 Page Cache的作用
图中描述了 Linux 操作系统中文件 Cache 管理与内存管理以及文件系统的关系示意图。从图中可以看到,在 Linux 中,具体文件系统,如 ext2/ext3、jfs、ntfs 等,负责在文件 Cache和存储设备之间交换数据,位于具体文件系统之上的虚拟文件系统VFS负责在应用程序和文件 Cache 之间通过 read/write 等接口交换数据,而内存管理系统负责文件 Cache 的分配和回收,同时虚拟内存管理系统(VMM)则允许应用程序和文件 Cache 之间通过 memory map的方式交换数据。可见,在 Linux 系统中,文件 Cache 是内存管理系统、文件系统以及应用程序之间的一个联系枢纽。
2.3 Page Cache相关的数据结构
每一个 Page Cache 包含若干 Buffer Cache。
1、内存管理系统与Page Cache交互,负责维护每项 Page Cache 的分配和回收,同时在使用 memory map 方式访问时负责建立映射;
2、VFS 与Page Cache交互,负责 Page Cache 与用户空间的数据交换,即文件读写;
3、具体文件系统则一般只与 Buffer Cache 交互,它们负责在外围存储设备和 Buffer Cache 之间交换数据。
Page Cache、Buffer Cache、文件以及磁盘之间的关系如图所示
由图四中可见,一个Page Cache包含多个Buffer Cache,一个Buffer Cache与一个磁盘块一一对应。
假定了 Page 的大小是 4K,则文件的每个4K的数据块最多只能对应一个 Page Cache 项,它通过一个是 radix tree来管理文件块和page cache的映射关系,Radix tree 是一种搜索树,Linux 内核利用这个数据结构来通过文件内偏移快速定位 Cache 项。
3. 零拷贝
Linux内核中与Page Cache操作相关的API有很多,按其使用方式可以分成两类:一类是以拷贝方式操作的相关接口, 如read/write/sendfile等;另一类是以地址映射方式操作的相关接口,如mmap。其中sendfile和mmap都是零拷贝的实现方案。
我们经常听说Kafka和RocketMQ等消息中间件有利用零拷贝技术来加速数据处理,提高吞吐量。所谓零拷贝,就是用户态与内核态的数据拷贝的次数为零
3.1 常规文件读写
我们先看下正常文件读写所经历的阶段,即FileChannel#read
,FileChannel#write
,共涉及四次上下文切换(内核态和用户态的切换,包括read调用,read返回,write调用,write返回)和四次数据拷贝
JAVA中关于正常文件读写的示例代码:
@Test
public void testChannel() throws IOException {
FileInputStream inputStream = null;
FileOutputStream outputStream = null;
FileChannel inputChannel = null;
FileChannel outputChannel = null;
try {
inputStream = new FileInputStream(new File("/Users/djg/Downloads/branch.jpg"));
outputStream = new FileOutputStream(new File("/Users/djg/Downloads/branch2.jpg"));
// 获取通道
inputChannel = inputStream.getChannel();
outputChannel = outputStream.getChannel();
// 分配缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 将通道中数据存入缓冲区
while(inputChannel.read(byteBuffer) != -1){
// 切换成读取数据的模式
byteBuffer.flip();
//缓冲区中数据写到通道中区
outputChannel.write(byteBuffer);
// 清空缓冲区
byteBuffer.clear();
}
System.out.println("读写成功");
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
inputChannel.close();
outputChannel.close();
System.out.println("数据关闭成功");
}
}
3.2 mmap
mmap 把文件映射到用户空间里的虚拟地址空间,实现文件和进程虚拟地址空间中一段虚拟地址的一一对映关系。
省去了从内核缓冲区复制到用户空间的过程,进程就可以采用指针的方式读写操作这一段内存(文件 / page cache),而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作。相反,内核空间对这段区域的修改也直接反映到用户空间,从而可以实现用户态和内核态对此内存区域的共享。
但在真正使用到这些数据前却不会消耗物理内存,也不会有读写磁盘的操作,只有真正使用这些数据时,虚拟内存管理系统 VMS 才根据缺页加载的机制从磁盘加载对应的数据块到内核态的Page Cache。这样的文件读写文件方式少了数据从内核缓存到用户空间的拷贝,效率很高。
概括而言,mmap有以下特点:
- 文件(page cache)直接映射到用户虚拟地址空间,内核态和用户态共享一片page cache,避免了一次数据拷贝
- 建立mmap之后,并不会立马加载数据到内存,只有真正使用数据时,才会引发缺页异常并加载数据到内存
memory map具体步骤如下:
首先,应用程序调用mmap(图中1),陷入到内核中后调用do_mmap_pgoff(图中2)。该函数从应用程序的地址空间中分配一段区域作为映射的内存地址,并使用一个VMA(vm_area_struct)结构代表该区域,之后就返回到应用程序(图中3)。当应用程序访问mmap所返回的地址指针时(图中4),由于虚实映射尚未建立,会触发缺页中断(图中5)。之后系统会调用缺页中断处理函数(图中6),在缺页中断处理函数中,内核通过相应区域的VMA结构判断出该区域属于文件映射,于是调用具体文件系统的接口读入相应的Page Cache项(图中7、8、9),并填写相应的虚实映射表。经过这些步骤之后,应用程序就可以正常访问相应的内存区域了。
Java中关于mmap的示例:
@Test
public void channelTest() throws IOException {
FileChannel inputChannel = FileChannel.open(Paths.get("/Users/djg/Downloads/branch.jpg"), StandardOpenOption.READ);
FileChannel outputChannel = FileChannel.open(Paths.get("/Users/djg/Downloads/branch2.jpg"), StandardOpenOption.WRITE,StandardOpenOption.READ,StandardOpenOption.CREATE);
// 内存映射文件
MappedByteBuffer inputBuffer = inputChannel.map(FileChannel.MapMode.READ_ONLY,0,inputChannel.size());
MappedByteBuffer outputBuffer = outputChannel.map(FileChannel.MapMode.READ_WRITE,