iOS之深入解析文件内存映射MMAP

Posted Forever_wj

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了iOS之深入解析文件内存映射MMAP相关的知识,希望对你有一定的参考价值。

一、常规文件操作

  • 常规文件操作(read/write)有以下重要步骤:
    • 进程发起读文件请求;
    • 内核通过查找进程文件符表,定位到内核已打开文件集上的文件信息,从而找到此文件的 inode;
    • inode 在 address_space 上查找要请求的文件页是否已经缓存在内核页高速缓冲中。如果存在,则直接返回这片文件页的内容;
    • 如果不存在,则通过 inode 定位到文件磁盘地址,将数据从磁盘复制到内核页高速缓冲,之后再次发起读页面过程,进而将内核页高速缓冲中的数据发给用户进程。
  • 常规文件操作为了提高读写效率和保护磁盘,使用了页缓存机制。由于页缓存处在内核空间,不能被用户进程直接寻址,所以需要将页缓存中数据页再次拷贝到内存对应的用户空间中;
  • read/write 是系统调用很耗时,如下图,它首先将文件内容从硬盘拷贝到内核空间的一个缓冲区,然后再将这些数据拷贝到用户空间,实际上完成了两次数据拷贝;

  • 如果两个进程都对磁盘中的一个文件内容进行访问,那么这个内容在物理内存中有三份:进程 A 的地址空间 + 进程 B 的地址空间 + 内核页高速缓冲空间;
  • 写操作也是一样,待写入的 buffer 在内核空间不能直接访问,必须要先拷贝至内核空间对应的主存,再写回磁盘中(延迟写回),也是需要两次数据拷贝。

二、mmap 内存映射

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

  • 由上图可以看出,进程的虚拟地址空间,由多个虚拟内存区域构成。虚拟内存区域是进程的虚拟地址空间中的一个同质区间,即具有同样特性的连续地址范围。上图中所示的text数据段(代码段)、初始数据段、BSS 数据段、堆、栈和内存映射,都是一个独立的虚拟内存区域。而为内存映射服务的地址空间处在堆栈之间的空余部分。
  • 在日常开发中偶尔会遇到 mmap,它最常用到的场景是 MMKV,其次用到的是日志打印。
  • 进程是 App 运行的基本单位,进程之间相对独立。ios 系统中 App 运行的内存空间地址是虚拟空间地址,存储数据是在各自的沙盒。当在 App 中去读写沙盒中的文件时,会使用 NSFileManager 去查找文件,然后可以使用 NSData 去加载二进制数据。文件操作的更底层实现过程,是使用 linux 的 read()、write() 函数直接操作文件句柄(也叫文件描述符 fd)。
  • 在操作系统层面,当 App 读取一个文件时,实际是有两步:
    • 将文件从磁盘读取到物理内存;
    • 从系统空间拷贝到用户空间(可以认为是复制到系统给 App 统一分配的内存)。
  • iOS 系统使用页缓存机制,通过 MMU(Memory Management Unit)将虚拟内存地址和物理地址进行映射,并且由于进程的地址空间和系统的地址空间不一样,所以还需要多一次拷贝。
  • 而 mmap 将磁盘上文件的地址信息与进程用的虚拟逻辑地址进行映射,建立映射的过程与普通的内存读取不同:正常的是将文件拷贝到内存,mmap 只是建立映射而不会将文件加载到内存中。

  • 在内存映射的过程中,并没有实际的数据拷贝,文件没有被载入内存,只是逻辑上被放入了内存,具体到代码,就是建立并初始化了相关的数据结构(struct address_space),这个过程由系统调用 mmap() 实现,所以建立内存映射的效率很高。
  • 既然建立内存映射没有进行实际的数据拷贝,那么进程又怎么能最终直接通过内存操作访问到硬盘上的文件呢?那就要看内存映射之后的几个相关的过程。
  • mmap() 会返回一个指针 ptr,它指向进程逻辑地址空间中的一个地址,这样以后,进程无需再调用 read 或 write 对文件进行读写,而只需要通过 ptr 就能够操作文件。但是 ptr 所指向的是一个逻辑地址,要操作其中的数据,必须通过 MMU 将逻辑地址转换成物理地址,如上图中过程 2 所示。这个过程与内存映射无关。
  • 前面说道,建立内存映射并没有实际拷贝数据,这时,MMU 在地址映射表中是无法找到与 ptr 相对应的物理地址的,也就是 MMU 失败,将产生一个缺页中断,缺页中断的中断响应函数会在 swap 中寻找相对应的页面,如果找不到(也就是该文件从来没有被读入内存的情况),则会通过 mmap() 建立的映射关系,从硬盘上将文件读取到物理内存中,如上图中过程 3 所示。这个过程与内存映射无关。
  • 如果在拷贝数据时,发现物理内存不够用,则会通过虚拟内存机制(swap)将暂时不用的物理页面交换到硬盘上,如图1中过程4所示。这个过程也与内存映射无关。
  • mmap 内存映射的实现过程,总的来说可以分为三个阶段:
    • 进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域;
    • 调用内核空间的系统调用函数 mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系;
    • 进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝。
② 适用场景
  • 有一个很大的文件,因为映射有额外的性能消耗,所以适用于频繁读操作的场景;(单次使用的场景不建议使用)。
  • 有一个小文件,它的内容您想要立即读入内存并经常访问。这种技术最适合那些大小不超过几个虚拟内存页的文件(页是地址空间的最小单位,虚拟页和物理页的大小是一样的,通常为 4KB)。
  • 需要在内存中缓存文件的特定部分。文件映射消除了缓存数据的需要,这使得系统磁盘缓存中的其他数据空间更大。
  • 当随机访问一个非常大的文件时,通常最好只映射文件的一小部分。映射大文件的问题是文件会消耗活动内存。如果文件足够大,系统可能会被迫将其他部分的内存分页以加载文件。将多个文件映射到内存中会使这个问题更加复杂。
③ 不适合的场景
  • 希望从开始到结束的顺序从头到尾读取一个文件;
  • 文件有几百兆字节或者更大,将大文件映射到内存中会快速地填充内存,并可能导致分页,这将抵消首先映射文件的好处。对于大型顺序读取操作,禁用磁盘缓存并将文件读入一个小内存缓冲区;
  • 该文件大于可用的连续虚拟内存地址空间。对于 64 位应用程序来说,这不是什么问题,但是对于 32 位应用程序来说,这是一个问题。32 位虚拟内存最大是 4GB,可以只映射部分;
  • 因为每次操作内存会同步到磁盘,所以不适用于移动磁盘或者网络磁盘上的文件;
  • 变长文件不适用。
④ 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 相关函数
  • 函数原型:
	void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
  • 返回说明:
    • 成功执行时,mmap() 返回被映射区的指针;
    • 失败时,mmap() 返回 MAP_FAILED [其值为 (void *)-1], error 被设为以下的某个值:
	 1 EACCES:访问出错
	 2 EAGAIN:文件已被锁定,或者太多的内存已被锁定
	 3 EBADF:fd不是有效的文件描述词
	 4 EINVAL:一个或者多个参数无效
	 5 ENFILE:已达到系统对打开文件的限制
	 6 ENODEV:指定文件所在的文件系统不支持内存映射
	 7 ENOMEM:内存不足,或者进程已超出最大内存映射数量
	 8 EPERM:权能不足,操作不允许
	 9 ETXTBSY:已写的方式打开文件,同时指定MAP_DENYWRITE标志
	10 SIGSEGV:试着向只读区写入
	11 SIGBUS:试着访问不属于进程的内存区
  • 参数
    • start:映射区的开始地址;
    • length:映射区的长度;
    • prot:期望的内存保护标志,不能与文件的打开模式冲突,是以下的某个值,可以通过 or 运算合理地组合在一起;
	1 PROT_EXEC :页内容可以被执行
	2 PROT_READ :页内容可以被读取
	3 PROT_WRITE :页可以被写入
	4 PROT_NONE :页不可访问
    • flags:指定映射对象的类型,映射选项和映射页是否可以共享,它的值可以是一个或者多个以下位的组合体;
	 1 MAP_FIXED //使用指定的映射起始地址,如果由start和len参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。
	 2 MAP_SHARED //与其它所有映射这个对象的进程共享映射空间。对共享区的写入,相当于输出到文件。直到msync()或者munmap()被调用,文件实际上不会被更新。
	 3 MAP_PRIVATE //建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。
	 4 MAP_DENYWRITE //这个标志被忽略。
	 5 MAP_EXECUTABLE //同上
	 6 MAP_NORESERVE //不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。
	 7 MAP_LOCKED //锁定映射区的页面,从而防止页面被交换出内存。
	 8 MAP_GROWSDOWN //用于堆栈,告诉内核VM系统,映射区可以向下扩展。
	 9 MAP_ANONYMOUS //匿名映射,映射区不与任何文件关联。
	10 MAP_ANON //MAP_ANONYMOUS的别称,不再被使用。
	11 MAP_FILE //兼容标志,被忽略。
	12 MAP_32BIT //将映射区放在进程地址空间的低2GB,MAP_FIXED指定时会被忽略。当前这个标志只在x86-64平台上得到支持。
	13 MAP_POPULATE //为文件映射通过预读的方式准备好页表。随后对映射区的访问不会被页违例阻塞。
	14 MAP_NONBLOCK //仅和MAP_POPULATE一起使用时才有意义。不执行预读,只为已存在于内存中的页面建立页表入口。
    • fd:有效的文件描述词,如果 MAP_ANONYMOUS 被设定,为了兼容问题,其值应为-1;
    • offset:被映射对象内容的起点。
  • 相关函数:
	int munmap(void * addr, size_t len)
  • 成功执行时,munmap() 返回0;失败时,munmap 返回 -1,error 返回标志和 mmap 一致;该调用在进程地址空间中解除一个映射关系,addr 是调用 mmap() 时返回的地址,len 是映射区的大小;当映射关系解除后,对原来映射地址的访问将导致段错误发生。
	int msync(void *addr, size_t len, int flags)
  • 一般说来,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往往在调用 munmap() 后才执行该操作。可以通过调用 msync() 实现磁盘上文件内容与共享内存区的内容一致。
  • 当映射关系解除后,对原来映射地址的访问将导致段错误发生。
⑥ mmap 使用细节
  • 使用 mmap 需要注意的一个关键点是,mmap 映射区域大小必须是物理页大小(page_size)的整倍数(32 位系统中通常是 4k 字节)。这是因为内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位,为了匹配内存的操作,mmap 从磁盘到虚拟地址空间的映射也必须是页。
  • 内核可以跟踪被内存映射的底层对象(文件)的大小,进程可以合法的访问在当前文件大小以内又在内存映射区以内的那些字节。也就是说,如果文件的大小一直在扩张,只要在映射区域范围内的数据,进程都可以合法得到,这和映射建立时文件的大小无关。
  • 映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关,同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。
⑦ 映射区域大小如果不是物理页的整倍数的具体情况分析
  • 情形一:一个文件的大小是 5000 字节,mmap 函数从一个文件的起始位置开始,映射 5000 字节到虚拟内存中。
    • 分析:因为单位物理页面的大小是 4096 字节,虽然被映射的文件只有 5000 字节,但是对应到进程虚拟地址区域的大小需要满足整页大小,因此 mmap 函数执行后,实际映射到虚拟内存区域 8192 个字节,5000~8191 的字节部分用零填充,映射后的对应关系如下图所示:

    • 此时:
      • 读/写前 5000 个字节(0~4999),会返回操作文件内容;
      • 读字节 5000 ~ 8191 时,结果全为 0, 写 5000 ~ 8191 时,进程不会报错,但是所写的内容不会写入原文件中 ;
      • 读/写 8192 以外的磁盘部分,会返回一个 SIGSECV 错误。
  • 情形二:一个文件的大小是 5000 字节,mmap 函数从一个文件的起始位置开始,映射 15000 字节到虚拟内存中,即映射大小超过了原始文件的大小。
    • 分析:由于文件的大小是 5000 字节,和情形一一样,其对应的两个物理页。那么这两个物理页都是合法可以读写的,只是超出 5000 的部分不会体现在原文件中。由于程序要求映射 15000 字节,而文件只占两个物理页,因此 8192 字节~15000 字节都不能读写,操作时会返回异常。如下图所示:

    • 此时:
      • 进程可以正常读/写被映射的前 5000 字节(0~4999),写操作的改动会在一定时间后反映在原文件中;
      • 对于 5000~8191 字节,进程可以进行读写过程,不会报错,但是内容在写入前均为 0,另外,写入后不会反映在文件中;
      • 对于 8192~14999 字节,进程不能对其进行读写,会报 SIGBUS 错误;
      • 对于 15000 以外的字节,进程不能对其读写,会引发 SIGSEGV 错误。
  • 情形三:一个文件初始大小为 0,使用 mmap 操作映射了 1000*4K 的大小,即 1000 个物理页大约 4M 字节空间,mmap 返回指针 ptr。
    • 分析:如果在映射建立之初,就对文件进行读写操作,由于文件大小为 0,并没有合法的物理页对应,如同情形二一样,会返回 SIGBUS 错误。
    • 但是如果,每次操作 ptr 读写前,先增加文件的大小,那么 ptr 在文件大小内部的操作就是合法的。例如,文件扩充 4096 字节,ptr 就能操作 ptr ~ [ (char)ptr + 4095] 的空间。只要文件扩充的范围在 1000 个物理页(映射范围)内,ptr 都可以对应操作相同的大小。
    • 这样,方便随时扩充文件空间,随时写入文件,不造成空间浪费。

三、iOS 中的 mmap

  • 官方的demo为例,其它的代码很简明直接,核心就在于 mmap 函数:
	/**
	 *  @param  start  映射开始地址,设置 NULL 则让系统决定映射开始地址
	 *  @param  length  映射区域的长度,单位是 Byte
	 *  @param  prot  映射内存的保护标志,主要是读写相关,是位运算标志;(记得与下面fd对应句柄打开的设置一致)
	 *  @param  flags  映射类型,通常是文件和共享类型
	 *  @param  fd  文件句柄
	 *  @param  off_toffset  被映射对象的起点偏移
	 */
	void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset);
	
	*outDataPtr = mmap(NULL,
	                   size,
	                   PROT_READ|PROT_WRITE,
	                   MAP_FILE|MAP_SHARED,
	                   fileDescriptor,
	                   0);
  • 用官网的代码做参考,实现一个读写的例子:
	#import "ViewController.h"
	#import <sys/mman.h>
	#import <sys/stat.h>
	
	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_key";
	    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);
	    }
	}
	
	@interface ViewController ()
	
	@end
	
	@implementation ViewController
	
	- (void)viewDidLoad {
	    [super viewDidLoad];
	    
	    NSString * path = [NSHomeDirectory() stringByAppendingPathComponent:@"test.data"];
	    NSLog(@"path: %@", path);
	    NSString *str = @"test str";
	    [str writeToFile:path atomically:YES encoding:NSUTF8StringEncoding error:nil];
	    
	    ProcessFile(path.UTF8String);
	    NSString *result = [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil];
	    NSLog(@"result:%@", result);
	}

四、MMKV 和 mmap

① MMKV 简介
  • NSUserDefault 是常见的缓存工具,但是数据有时会同步不及时,比如说在 crash 前保存的值很容易出现保存失败的情况,在 App 重新启动之后读取不到保存的值。
  • MMKV 很好的解决了 NSUserDefault 的局限,但是同样由于其独特设计,在数据量较大、操作频繁的场景下,会产生性能问题。这里的使用给出两个建议:
    • 不要全部用 defaultMMKV,根据业务大的类型做聚合,避免某一个 MMKV 数据过大,特别是对于某些只会出现一次的新手引导、红点之类的逻辑,尽可能按业务聚合,使用多个 MMKV 的对象;
    • 对于需要频繁读写的数据,可以在内存持有一份数据缓存,必要时再更新到 MMKV。
② MMKV 原理
  • 内存准备:通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由 iOS 负责将内存回写到文件,不必担心 crash 导致数据丢失。
  • 数据组织:数据序列化方面选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。考虑到要提供的是通用 KV 组件,key 可以限定是 string 字符串类型,value 则多种多样(int/bool/double 等)。要做到通用的话,考虑将 value 通过 protobuf 协议序列化成统一的内存块(buffer),然后就可以将这些 KV 对象序列化到内存中。
	message KV {
		string key = 1 ;
		buffer value = 2;
	}
	- (B00L)setInt32:(int32 t)value forKey:(NSString*)key {
		auto data = PBEncode(value); 
		return [self setData:data forKey:key];
	}
	- (BO0L)setData: (NSData*)data forKey:(NSString*)key {
		auto kv = KV[key,data];
		auto buf = PBEncode(kv);
		return [self write: buf];
	}
  • 写入优化:标准 protobuf 不提供增量更新的能力,每次写入都必须全量写入。考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力:将增量 kv 对象序列化后,直接 append 到内存末尾;这样同一个 key 会有新旧若干份数据,最新的数据在最后;那么只需在程序启动第一次打开 mmkv 时,不断用后读入的 value 替换之前的值,就可以保证数据是最新有效的。
  • 空间增长:使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。例如同一个 key 不断更新的话,是可能耗尽几百 M 甚至上 G 空间,而事实上整个 KV 文件就这一个 key,不到 1k 空间就存得下,这明显是不可取的。我们需要在性能和空间上做个折中:以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式;当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重结果;排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。
	- (B00L)append: (NSData*)data {
		if (space >= data.length) {
			append(fd, data);
		} else {
			newData = unique(m_allKV);
			if (total_space >= newData.length) {
				write(fd, newData);
			} else {
				while (total_space < newData.length) {
					total_ space *= 2;
				}
				ftruncate(fd, total . space);
				write(fd, newData);
			}
		}
	}
  • 数据有效性:考虑到文件系统、操作系统都有一定的不稳定性,另外增加了 crc 校验,对无效数据进行甄别。
  • MMKV 性能:写个简单的测试,将 MMKV、NSUserDefaults 的性能进行对比(循环写入1w 次数据,测试环境:iPhone X 256G, iOS 11.2.6,单位:ms):

  • 可见 MMKV 性能远远优于 iOS 自带的 NSUserDefaults。另外,在测试中发现,NSUserDefaults 在每 2-3 次测试,就会有 1 次比较耗时的操作,怀疑是触发了数据 synchronize 重整写入。对比之下,MMKV 即使触发数据重整,也保持了性能的稳定高效。

五、NSData 与 mmap

  • NSData 有一个静态方法和 mmap 有关系:
	+ (id)dataWithContentsOfFile:(NSString *)path options:(NSDataReadingOptions)readOptionsMask error:(NSError **)errorPtr;
	
	typedef NS_OPTIONS(NSUInteger, NSDataReadingOptions) {
	
	    // Hint to map the file in if possible and safe. 在保证安全的前提下使用 mmap
	    NSDataReadingMappedIfSafe =   1UL << 0,
	    // Hint to get the file not to be cached in the kernel. 不要缓存。如果该文件只会读取一次,这个设置可以提高性能
	    NSDataReadingUncached = 1UL << 1,
	    // Hint to map the file in if possible. This takes precedence over NSDataReadingMappedIfSafe if both are given.  总使用 mmap
	    NSDataReadingMappedAlways API_AVAILABLE(macos(10.7), ios(5.0), watchos(2.0), tvos(9.0)) = 1UL << 3,
	    ...
	};
  • Mapped 的意思是使用 mmap,那么 ifSafe 是什么意思呢?NSDataReadingMappedIfSafe 和 NSDataReadingMappedAlways 有什么区别?
  • 如果使用 mmap,则在 NSData 的生命周期内,都不能删除对应的文件。
  • 如果文件是在固定磁盘,非可移动磁盘、网络磁盘,则满足 NSDataReadingMappedIfSafe。对 iOS 而言,这个 NSDataReadingMappedIfSafe = NSDataReadingMappedAlways。
  • 那什么情况下应该用对应的参数?
    • 如果文件很大,直接使用 dataWithContentsOfFile 方法,会导致 load 整个文件,出现内存占用过多的情况;此时用 NSDataReadingMappedIfSafe,则会使用 mmap 建立文件映射,减少内存的占用。
    • 使用场景:视频加载。视频文件通常比较大,但是使用的过程中不会同时读取整个视频文件的内容,可以使用 mmap 优化。

六、总结

  • mmap 就是文件的内存映射。通常读取文件是将文件读取到内存,会占用真正的物理内存;而 mmap 是用进程的内存虚拟地址空间去映射实际的文件中,这个过程由操作系统处理。mmap 不会为文件分配物理内存,而是相当于将内存地址指向文件的磁盘地址,后续对这些内存进行的读写操作,会由操作系统同步到磁盘上的文件。
  • iOS 中使用 mmap 可以用 c 方法的 mmap(),也可以使用 NSData 的接口带上NSDataReadingMappedIfSafe 参数。前者自由度更大,后者用于读取数据。
  • mmap 优点:
    • 对文件的读取操作跨过了页缓存,减少了数据的拷贝次数,用内存读写取代 I/O 读写,提高了文件读取效率。
    • 实现了用户空间和内核空间的高效交互方式。两空间的各自修改操作可以直接反映在映射的区域内,从而被对方空间及时捕捉。
    • 提供进程间共享内存及相互通信的方式。不管是父子进程还是无亲缘关系的进程,都可以将自身用户空间映射到同一个文件或匿名映射到同一片区域。从而通过各自对映射区域的改动,达到进程间通信和进程间共享的目的。
    • 如果进程 A 和进程 B 都映射了区域 C,当 A 第一次读取 C 时通过缺页从磁盘复制文件页到内存中;但当 B 再读 C 的相同页面时,虽然也会产生缺页异常,但是不再需要从磁盘中复制文件过来,而可直接使用已经保存在内存中的文件数据。
    • 可用于实现高效的大规模数据传输。内存空间不足,是制约大数据操作的一个方面,解决方案往往是借助硬盘空间协助操作,补充内存的不足。但是进一步会造成大量的文件 I/O 操作,极大影响效率。这个问题可以通过 mmap 映射很好的解决。换句话说,但凡是需要用磁盘空间代替内存的时候,mmap 都可以发挥其功效。

以上是关于iOS之深入解析文件内存映射MMAP的主要内容,如果未能解决你的问题,请参考以下文章

mmap共享内存深入总结

Linux mmap原理

Python之mmap内存映射模块(大文本处理)说明

Python之mmap内存映射模块(大文本处理)说明

Python之mmap内存映射模块(大文本处理)说明

Linux内存管理架构之四(mmap内存映射机制)