当前位置:科学 > 正文

轻松突破文件IO瓶颈:内存映射mmap技术

2023-06-29 14:53:06  来源:嵌入式Linux内核

一、mmap基础概念

mmap 即 memory map,也就是内存映射。mmap 是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用 read、write 等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。


(相关资料图)

如下图所示:

mmap的作用,在应用这一层,是让你把文件的某一段,当作内存一样来访问。将文件映射到物理内存,将进程虚拟空间映射到那块内存。这样,进程不仅能像访问内存一样读写文件,多个进程映射同一文件,还能保证虚拟空间映射到同一块物理内存,达到内存共享的作用。

mmap 具有如下的特点:

mmap 向应用程序提供的内存访问接口是内存地址连续的,但是对应的磁盘文件的 block 可以不是地址连续的;mmap 提供的内存空间是虚拟空间(虚拟内存),而不是物理空间(物理内存),因此完全可以分配远远大于物理内存大小的虚拟空间(例如 16G 内存主机分配 1000G 的 mmap 内存空间);mmap 负责映射文件逻辑上一段连续的数据(物理上可以不连续存储)映射为连续内存,而这里的文件可以是磁盘文件、驱动假造出的文件(例如 DMA 技术)以及设备;mmap 由操作系统负责管理,对同一个文件地址的映射将被所有线程共享,操作系统确保线程安全以及线程可见性;mmap 的设计很有启发性。基于磁盘的读写单位是 block(一般大小为 4KB),而基于内存的读写单位是地址(虽然内存的管理与分配单位是 4KB)。

换言之,CPU 进行一次磁盘读写操作涉及的数据量至少是 4KB,但是进行一次内存操作涉及的数据量是基于地址的,也就是通常的 64bit(64 位操作系统)。mmap 下进程可以采用指针的方式进行读写操作,这是值得注意的。

二. 虚拟内存?虚拟空间?

其实是一个概念,前一篇对于这个词没有确切的定义,现在定义一下:

虚拟空间就是进程看到的所有地址组成的空间,虚拟空间是某个进程对分配给它的所有物理地址(已经分配的和将会分配的)的重新映射。

而虚拟内存,为啥叫虚拟内存,是因为它就不是真正的内存,是假的,因为它是由地址组成的空间,所以在这里,使用虚拟空间这个词更加确切和易懂。(不过虚拟内存这个词也不算错)

虚拟空间原理

物理内存

首先,物理地址实际上也不是连续的,通常是包含作为主存的DRAM和IO寄存器

以前的CPU(如X86)是为IO划分单独的地址空间,所以不能用直接访问内存的方式(如指针)IO,只能用专门的方法(in/read/out/write)诸如此类。现在的CPU利用PCI总线将IO寄存器映射到物理内存,所以出现了基于内存访问的IO。还有一点补充的,就如同进程空间有一块内核空间一样,物理内存也会有极小一部分是不能访问的,为内核所用。

三个总线

这里再补充下三个总线的知识,即:地址总线、数据总线、控制总线

地址总线,用来传输地址数据总线,用来传输数据控制总线,用来传输命令

比如CPU通过控制总线发送读取命令,同时用地址总线发送要读取的数据虚地址,经过MMU后到内存

内存通过数据总线将数据传输给CPU。虚拟地址的空间和指令集的地址长度有关,不一定和物理地址长度一致,比如现在的64位处理器,从VA角度看来,可以访问64位的地址,但地址总线长度只有48位,所以你可以访问一个位于2^52这个位置的地址。

虚拟内存地址转换(虚地址转实地址)

上面已经明确了虚拟内存是虚拟空间,即地址的集合这一概念。基于此,来说说原理。

如果还记得操作系统课程里面提到的虚地址,那么这个虚地址就是虚拟空间的地址了,虚地址通过转换得到实地址,转换方式课程内也讲得很清楚,虚地址头部包含了页号(段地址和段大小,看存储模式:页存储、段存储,段页式),剩下部分是偏移量,经过MMU转换成实地址。

存储方式

虚拟地址头部为页号通过查询页表得到物理页号,假设一页时1K,那么页号*偏移量就得到物理地址

虚拟地址头部为段号,段表中找到段基地址加上偏移量得到实地址

二、mmap原理

mmap函数创建一个新的vm_area_struct结构,并将其与文件/设备的物理地址相连。

vm_area_struct:

linux使用vm_area_struct来表示一个独立的虚拟内存区域,一个进程可以使用多个vm_area_struct来表示不用类型的虚拟内存区域(如堆,栈,代码段,MMAP区域等)。

vm_area_struct结构中包含了区域起始地址。同时也包含了一个vm_opt指针,其内部可引出所有针对这个区域可以使用的系统调用函数。从而,进程可以通过vm_area_struct获取操作这段内存区域所需的任何信息。

进程通过vma操作内存,而vma与文件/设备的物理地址相连,系统自动回写脏页面到对应的文件磁盘上(或写入到设备地址空间),实现内存映射文件。

[内核资料领取,](https://docs.qq.com/doc/DTmFTc29xUGdNSnZ2)

[Linux内核源码学习地址。](https://ke.qq.com/course/4032547?flowToken=1044435)

内存映射文件的原理:

首先创建虚拟区间并完成地址映射,此时还没有将任何文件数据拷贝至主存。当进程发起读写操作时,会访问虚拟地址空间,通过查询页表,发现这段地址不在物理页上,因为只建立了地址映射,真正的数据还没有拷贝到内存,因此引发缺页异常。缺页异常经过一系列判断,确定无非法操作后,内核发起请求调页过程。

最终会调用nopage函数把所缺的页从文件在磁盘里的地址拷贝到物理内存。之后进程便可以对这片主存进行读写,如果写操作修改了内容,一定时间后系统会自动回写脏页面到对应的磁盘地址,完成了写入到文件的过程。另外,也可以调用msync()来强制同步,这样所写的内存就能立刻保存到文件中。

mmap内存映射的实现过程,总的来说可以分为三个阶段:

(一)进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域

进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中

(二)调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系

为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。

(三)进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝

注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。

进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。

注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。

三、mmap 的 I/O 模型

mmap 也是一种零拷贝技术,其 I/O 模型如下图所示:

#include <sys/mman.h>void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)

mmap 技术有如下特点:

利用 DMA 技术来取代 CPU 来在内存与其他组件之间的数据拷贝,例如从磁盘到内存,从内存到网卡;用户空间的 mmap file 使用虚拟内存,实际上并不占据物理内存,只有在内核空间的 kernel buffer cache 才占据实际的物理内存;mmap() 函数需要配合 write() 系统调动进行配合操作,这与 sendfile() 函数有所不同,后者一次性代替了 read() 以及 write();因此 mmap 也至少需要 4 次上下文切换;mmap 仅仅能够避免内核空间到用户空间的全程 CPU 负责的数据拷贝,但是内核空间内部还是需要全程 CPU 负责的数据拷贝;

利用 mmap() 替换 read(),配合 write() 调用的整个流程如下:

用户进程调用 mmap(),从用户态陷入内核态,将内核缓冲区映射到用户缓存区;DMA 控制器将数据从硬盘拷贝到内核缓冲区(可见其使用了 Page Cache 机制);mmap() 返回,上下文从内核态切换回用户态;用户进程调用 write(),尝试把文件数据写到内核里的套接字缓冲区,再次陷入内核态;CPU 将内核缓冲区中的数据拷贝到的套接字缓冲区;DMA 控制器将数据从套接字缓冲区拷贝到网卡完成数据传输;write() 返回,上下文从内核态切换回用户态。

通过mmap实现的零拷贝I/O进行了4次用户空间与内核空间的上下文切换,以及3次数据拷贝;其中3次数据拷贝中包括了2次DMA拷贝和1次CPU拷贝

四、mmap 的优势

1.简化用户进程编程

在用户空间看来,通过 mmap 机制以后,磁盘上的文件仿佛直接就在内存中,把访问磁盘文件简化为按地址访问内存。这样一来,应用程序自然不需要使用文件系统的 write(写入)、read(读取)、fsync(同步)等系统调用,因为现在只要面向内存的虚拟空间进行开发。

但是,这并不意味着我们不再需要进行这些系统调用,而是说这些系统调用由操作系统在 mmap 机制的内部封装好了。

(1)基于缺页异常的懒加载

出于节约物理内存以及 mmap 方法快速返回的目的,mmap 映射采用懒加载机制。具体来说,通过 mmap 申请 1000G 内存可能仅仅占用了 100MB 的虚拟内存空间,甚至没有分配实际的物理内存空间。当你访问相关内存地址时,才会进行真正的 write、read 等系统调用。CPU 会通过陷入缺页异常的方式来将磁盘上的数据加载到物理内存中,此时才会发生真正的物理内存分配。

(2)数据一致性由 OS 确保

当发生数据修改时,内存出现脏页,与磁盘文件出现不一致。mmap 机制下由操作系统自动完成内存数据落盘(脏页回刷),用户进程通常并不需要手动管理数据落盘。

读写效率提高:避免内核空间到用户空间的数据拷贝

简而言之,mmap 被认为快的原因是因为建立了页到用户进程的虚地址空间映射,以读取文件为例,避免了页从内核空间拷贝到用户空间。

3.避免只读操作时的 swap 操作

虚拟内存带来了种种好处,但是一个最大的问题在于所有进程的虚拟内存大小总和可能大于物理内存总大小,因此当操作系统物理内存不够用时,就会把一部分内存 swap 到磁盘上。

在 mmap 下,如果虚拟空间没有发生写操作,那么由于通过 mmap 操作得到的内存数据完全可以通过再次调用 mmap 操作映射文件得到。但是,通过其他方式分配的内存,在没有发生写操作的情况下,操作系统并不知道如何简单地从现有文件中(除非其重新执行一遍应用程序,但是代价很大)恢复内存数据,因此必须将内存 swap 到磁盘上。

4.节约内存

由于用户空间与内核空间实际上共用同一份数据,因此在大文件场景下在实际物理内存占用上有优势。

4. mmap 不是银弹

mmap 不是银弹,这意味着 mmap 也有其缺陷,在相关场景下的性能存在缺陷:

由于 MMAP 使用时必须实现指定好内存映射的大小,因此 mmap 并不适合变长文件;如果更新文件的操作很多,mmap 避免两态拷贝的优势就被摊还,最终还是落在了大量的脏页回写及由此引发的随机 I/O 上,所以在随机写很多的情况下,mmap 方式在效率上不一定会比带缓冲区的一般写快;读/写小文件(例如 16K 以下的文件),mmap 与通过 read 系统调用相比有着更高的开销与延迟;同时 mmap 的刷盘由系统全权控制,但是在小数据量的情况下由应用本身手动控制更好;mmap 受限于操作系统内存大小:例如在 32-bits 的操作系统上,虚拟内存总大小也就 2GB,但由于 mmap 必须要在内存中找到一块连续的地址块,此时你就无法对 4GB 大小的文件完全进行 mmap,在这种情况下你必须分多块分别进行 mmap,但是此时地址内存地址已经不再连续,使用 mmap 的意义大打折扣,而且引入了额外的复杂性;

5. mmap 的适用场景

mmap 的适用场景实际上非常受限,在如下场合下可以选择使用 mmap 机制:

多个线程以只读的方式同时访问一个文件,这是因为 mmap 机制下多线程共享了同一物理内存空间,因此节约了内存;mmap 非常适合用于进程间通信,这是因为对同一文件对应的 mmap 分配的物理内存天然多线程共享,并可以依赖于操作系统的同步原语;mmap 虽然比 sendfile 等机制多了一次 CPU 全程参与的内存拷贝,但是用户空间与内核空间并不需要数据拷贝,因此在正确使用情况下并不比 sendfile 效率差;

6.mmap使用细节

使用mmap需要注意的一个关键点是,mmap映射区域大小必须是物理页大小(page_size)的整倍数(32位系统中通常是4k字节)。原因是,内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。为了匹配内存的操作,mmap从磁盘到虚拟地址空间的映射也必须是页。内核可以跟踪被内存映射的底层对象(文件)的大小,进程可以合法的访问在当前文件大小以内又在内存映射区以内的那些字节。也就是说,如果文件的大小一直在扩张,只要在映射区域范围内的数据,进程都可以合法得到,这和映射建立时文件的大小无关。映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关。同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。

在上面的知识前提下,我们下面看看如果大小不是页的整倍数的具体情况:

情形一:一个文件的大小是5000字节,mmap函数从一个文件的起始位置开始,映射5000字节到虚拟内存中。

分析:因为单位物理页面的大小是4096字节,虽然被映射的文件只有5000字节,但是对应到进程虚拟地址区域的大小需要满足整页大小,因此mmap函数执行后,实际映射到虚拟内存区域8192个 字节,5000~8191的字节部分用零填充。映射后的对应关系如下图所示:

此时: (1)读/写前5000个字节(0~4999),会返回操作文件内容。 (2)读字节50008191时,结果全为0。写50008191时,进程不会报错,但是所写的内容不会写入原文件中 。 (3)读/写8192以外的磁盘部分,会返回一个SIGSECV错误。情形二:一个文件的大小是5000字节,mmap函数从一个文件的起始位置开始,映射15000字节到虚拟内存中,即映射大小超过了原始文件的大小。

分析:由于文件的大小是5000字节,和情形一一样,其对应的两个物理页。那么这两个物理页都是合法可以读写的,只是超出5000的部分不会体现在原文件中。由于程序要求映射15000字节,而文件只占两个物理页,因此8192字节~15000字节都不能读写,操作时会返回异常。如下图所示:

此时: (1)进程可以正常读/写被映射的前5000字节(0~4999),写操作的改动会在一定时间后反映在原文件中。 (2)对于5000~8191字节,进程可以进行读写过程,不会报错。但是内容在写入前均为0,另外,写入后不会反映在文件中。 (3)对于8192~14999字节,进程不能对其进行读写,会报SIGBUS错误。 (4)对于15000以外的字节,进程不能对其读写,会引发SIGSEGV错误。情形三:一个文件初始大小为0,使用mmap操作映射了10004K的大小,即1000个物理页大约4M字节空间,mmap返回指针ptr。

分析:如果在映射建立之初,就对文件进行读写操作,由于文件大小为0,并没有合法的物理页对应,如同情形二一样,会返回SIGBUS错误。但是如果,每次操作ptr读写前,先增加文件的大小,那么ptr在文件大小内部的操作就是合法的。例如,文件扩充4096字节,ptr就能操作ptr ~ [ (char)ptr + 4095]的空间。只要文件扩充的范围在1000个物理页(映射范围)内,ptr都可以对应操作相同的大小。这样,方便随时扩充文件空间,随时写入文件,不造成空间浪费。

五、mmap映射

在内存映射的过程中,并没有实际的数据拷贝,文件没有被载入内存,只是逻辑上被放入了内存,具体到代码,就是建立并初始化了相关的数据结构(struct address_space),这个过程由系统调用mmap()实现,所以建立内存映射的效率很高。 既然建立内存映射没有进行实际的数据拷贝,那么进程又怎么能最终直接通过内存操作访问到硬盘上的文件呢?那就要看内存映射之后的几个相关的过程了。 mmap()会返回一个指针ptr,它指向进程逻辑地址空间中的一个地址,这样以后,进程无需再调用read或write对文件进行读写,而只需要通过ptr就能够操作文件。

但是ptr所指向的是一个逻辑地址,要操作其中的数据,必须通过MMU将逻辑地址转换成物理地址,这个过程与内存映射无关。 前面讲过,建立内存映射并没有实际拷贝数据,这时,MMU在地址映射表中是无法找到与ptr相对应的物理地址的,也就是MMU失败,将产生一个缺页中断,缺页中断的中断响应函数会在swap中寻找相对应的页面,如果找不到(也就是该文件从来没有被读入内存的情况),则会通过mmap()建立的映射关系,从硬盘上将文件读取到物理内存中,如图1中过程3所示。这个过程与内存映射无关。 如果在拷贝数据时,发现物理内存不够用,则会通过虚拟内存机制(swap)将暂时不用的物理页面交换到硬盘上,这个过程也与内存映射无关。mmap内存映射的实现过程:

进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝

适合的场景

您有一个很大的文件,其内容您想要随机访问一个或多个时间您有一个小文件,它的内容您想要立即读入内存并经常访问。这种技术最适合那些大小不超过几个虚拟内存页的文件。(页是地址空间的最小单位,虚拟页和物理页的大小是一样的,通常为4KB。)您需要在内存中缓存文件的特定部分。文件映射消除了缓存数据的需要,这使得系统磁盘缓存中的其他数据空间更大 当随机访问一个非常大的文件时,通常最好只映射文件的一小部分。映射大文件的问题是文件会消耗活动内存。如果文件足够大,系统可能会被迫将其他部分的内存分页以加载文件。将多个文件映射到内存中会使这个问题更加复杂。

不适合的场景

您希望从开始到结束的顺序从头到尾读取一个文件这个文件有几百兆字节或者更大。将大文件映射到内存中会快速地填充内存,并可能导致分页,这将抵消首先映射文件的好处。对于大型顺序读取操作,禁用磁盘缓存并将文件读入一个小内存缓冲区该文件大于可用的连续虚拟内存地址空间。对于64位应用程序来说,这不是什么问题,但是对于32位应用程序来说,这是一个问题该文件位于可移动驱动器上该文件位于网络驱动器上

示例代码

////  ViewController.m//  TestCode////  Created by zhangdasen on 2020/5/24.//  Copyright © 2020 zhangdasen. All rights reserved.//#import "ViewController.h"#import <sys/mman.h>#import <sys/stat.h>@interface ViewController ()@end@implementation ViewController- (void)viewDidLoad {    [super viewDidLoad];        NSString *path = [NSHomeDirectory() stringByAppendingPathComponent:@"test.data"];    NSLog(@"path: %@", path);    NSString *str = @"test str2";    [str writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:nil];        ProcessFile(path.UTF8String);    NSString *result = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];    NSLog(@"result:%@", result);}int MapFile(const char * inPathName, void ** outDataPtr, size_t * outDataLength, size_t appendSize){    int outError;    int fileDescriptor;    struct stat statInfo;        // Return safe values on error.    outError = 0;    *outDataPtr = NULL;    *outDataLength = 0;        // Open the file.    fileDescriptor = open( inPathName, O_RDWR, 0 );    if( fileDescriptor < 0 )    {        outError = errno;    }    else    {        // We now know the file exists. Retrieve the file size.        if( fstat( fileDescriptor, &statInfo ) != 0 )        {            outError = errno;        }        else        {            ftruncate(fileDescriptor, statInfo.st_size + appendSize);            fsync(fileDescriptor);            *outDataPtr = mmap(NULL,                               statInfo.st_size + appendSize,                               PROT_READ|PROT_WRITE,                               MAP_FILE|MAP_SHARED,                               fileDescriptor,                               0);            if( *outDataPtr == MAP_FAILED )            {                outError = errno;            }            else            {                // On success, return the size of the mapped file.                *outDataLength = statInfo.st_size;            }        }                // Now close the file. The kernel doesn’t use our file descriptor.        close( fileDescriptor );    }        return outError;}void ProcessFile(const char * inPathName){    size_t dataLength;    void * dataPtr;    char *appendStr = " append_key2";    int appendSize = (int)strlen(appendStr);    if( MapFile(inPathName, &dataPtr, &dataLength, appendSize) == 0) {        dataPtr = dataPtr + dataLength;        memcpy(dataPtr, appendStr, appendSize);        // Unmap files        munmap(dataPtr, appendSize + dataLength);    }}@end

关键词:

推荐阅读

月壤形成的主要原因 月壤与土壤有什么区别

月壤形成的主要原因月壤形成过程没有生物活动参与,没有有机质,还极度缺水干燥;组成月壤的矿物粉末基本是由陨石撞击破砰形成,因此,粉末 【详细】

域名抢注是是什么意思?投资角度来看什么域名好?

域名抢注是是什么意思域名抢注是通过抢先注册的方式获得互联网删除的域名的使用权。域名是由点分隔的一串数字,用于标记一台计算机或一组计 【详细】

捷达保养费用是多少?捷达是哪个国家的品牌?

捷达保养费用是多少?全新捷达的保修期为2年或6万公里,以先到者为准,新车可享受一次免费保养,首次免费保养在5000-7500km或1年内进行。如 【详细】

天然气泄露会造成爆炸吗?天然气泄漏怎么办?

天然气泄露会造成爆炸吗?家里用的天然气如果泄露是会发生爆炸的。当空气中含有混合天然气时,在与火源接触的一系列爆炸危险中,就会发生爆 【详细】

四部门明确App收集个人信息范围 个人信息保护范围判断标准

四部门明确App收集个人信息范围近日,国家互联网信息办公室、工业和信息化部、公安部、国家市场监督管理总局联合印发《常见类型移动互联网 【详细】

相关新闻

关于我们  |  联系方式  |  免责条款  |  招聘信息  |  广告服务  |  帮助中心

联系我们:85 572 98@qq.com备案号:粤ICP备18023326号-40

科技资讯网 版权所有