iOS中的mmap及相关知识

3,711 阅读19分钟

最近基于二进制重排的冷启动优化非常热门,其中涉及到了mmap相关知识。早就想系统研究一下mmap,正好近期项目计划开发一套APM监控,在记录相关数据时需要频繁进行写文件操作。就想到了是否可以使用mmap进行高性能的文件读写。于是系统性的研究了一下mmap相关知识。不看不知道,一看发现虽然mmap有很多优点,但是也没有想象中的那么完美。本文就来简单说一下mmap究竟是什么、它的原理、怎么使用以及何时该使用。

mmap基础介绍

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

在iOS中我们可以使用的mmap应该仅是将文件映射进入内存中。他的原理就是通过虚拟内存技术,将部分进程内的虚拟内存地址,与本地磁盘中的一个文件进行映射关联。通过mmap映射文件后,对我们程序员来说,对文件的读写就是操作一段内存的读写。系统会自动将修改的内容同步到本地文件中。

我们通过代码来举个例子,看看mmap映射文件后的效果:

- (void)testRead
{
    NSString *filePath = [@"~/Library/testFile" stringByExpandingTildeInPath];
    int i = 0;
    int fd = open(filePath.UTF8String, O_RDWR);
    void *m_ptr = NULL;
    //映射1GB的一个大文件
    m_ptr = mmap(m_ptr, k1Gb, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    close(fd);
    //每次读取1KB,准备1KB的buffer
    uint8_t bytes[k1Kb];
    int left = 0;
    int right = k1Mb-k1Kb;
    while (i < k1Mb) {
        size_t offSet = (k1Kb * i);
        //从文件中拷贝1KB的内容到我们准备的buff中
        memcpy(bytes, m_ptr + offSet, k1Kb);
        //do something with 1kb buffer data ...
        i++;
    }
}

在上述例子中,我们映射了1个1GB大小的文件,每次读取其中的1KB,然后使用读取的数据做一些事情。

mmap函数为我们返回了一个void *类型的指针,这里我们可以将其看做是一个data数组,其完全与我们平时通过malloc函数分配在内存中的数组一模一样,对其的操作也可以通过memcpy等一系列内存操作相关函数进行。

你可能注意到,上述代码映射了1G大小的文件进入内存中。在iOS这样内存受限的系统上,这种操作会导致我们的App内存占用过高被系统强行杀掉吗?答案是不会的。因为mmap仅会将文件与内存建立映射关系,并不会一次性将文件所有内容加载到物理内存中。如果使用XCode查看此时App的内存使用情况,你会发现通过mmap加载1G文件并不会造成太多的内存占用(实际上可能仅占用kb级别的内存,这与虚拟内存页加载机制有关,后面我们会详细讲述该机制)。

基本原理

在上一节mmap基础介绍中我们有提到mmap可以加载大文件进入内存而不占用过多的实际物理内存,那mmap是如何做到这一点的呢?我们来探究一下他的基本原理。

虚拟内存

前文中我们有提及到一个虚拟内存的概念,这个概念是mmap的基础。什么是虚拟内存呢?

百科中对于虚拟内存的解释为:

虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。目前,大多数操作系统都使用了虚拟内存,如Windows家族的“虚拟内存”;Linux的“交换空间”等。

翻译成我们iOS开发比较熟悉的语言来描述就是:

系统为我们每个iOS的App(进程)都创建了一套连续的内存地址,但这些内存地址并不是真实的物理内存地址。我们访问这些地址时系统会翻译成对应的物理内存(或其他存储位置)的地址。这样我们在开发App时不用关心这些内存地址是怎么存储的,只要拿来用就可以了。系统会帮助我们去真实的物理存储中读写我们需要的内容。

mmap映射内存

基于虚拟内存的技术,对于mmap来说,其实就是系统帮我们虚拟出了一套连续的内存地址,而这些内存实际对应的存储是磁盘上的某个文件。当我们访问这部分内存,需要进行加载实际内容时,系统会通过缺页中断进行内存页的加载。所以,我们加载了1G大小的文件,并没有占用实际的物理内存,在内存占用上也就不会占用太多的内存了

mmap读写内存

我们通过调用mmap函数,系统为我们返回了一个void *类型的内存地址。这部分内存就是由mmap映射出来的虚拟内存地址。我们可以对这部分内存内容进行读写操作。下面针对读和写2个操作分别介绍一下系统是如何加载文件内容和写入内容到文件的。

- 读:

对于读操作来说,系统首先会判断当前所需要读取的内容是否已经从文件加载到物理内存中。如果已经加载,则会直接返回物理内存中的内容。如果未加载,则会触发缺页中断,系统会以内存页大小的单位进行文件读取(进行文件IO操作),并将内存页进行缓存。以便减少整体IO操作次数。

- 写:

对于写操作来说,系统会将写入内容的内存页标记为脏页(dirty),并在合适的时机将脏页批量写入到映射的文件中(IO)。

注意:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步。

优缺点

了解过mmap的基础和原理以后,我们来细数一下它的优缺点:

优点

  • 可以加载大文件进入内存而基本不占用物理内存(特别是对于内存稀缺的移动开发场景来说意义重大)
  • 可以对文件直接进行数据拷贝到用户空间,对比常规的read/write操作省略了一些额外的步骤(内核态、用户态内容的多次拷贝、页缓存等,关于read/wirte的简单介绍详见延伸阅读)
  • 可以通过mmap共享机制进行进程间共享操作

缺点

  • mmap的API相对比较晦涩难懂
    • mmap相关函数均为较底层的c函数,且返回指针,后续直接操作指针,容易出错。而文件操作OC有封装好的NSFileHandle,使用较简单。(如果仅读取,NSData也支持mmap的方式,参考NSDataReadingOptionsNSDataReadingMappedIfSafe
    • 由于直接操作内存,对于文件大小和映射内存大小不一致的情况,mmap函数并没有太多提示,操作错误的内存会导致触发内存异常访问崩溃(参见细节部分)
    • API难度高导致实施复杂度高,应用与生产环境需要进行完备的测试
  • 如果更新文件的操作很多,mmap避免两态拷贝的优势就被摊还,最终还是落在了大量的脏页回写及由此引发的随机IO上. 所以在随机写很多的情况下,mmap方式在效率上不一定会比带缓冲区的一般写快.

使用教程

前文说了这么多,现在我们来介绍一下mmap相关API如何使用。

映射文件

使用mmap函数进行文件映射,需要先通过open这个c函数打开一个已经存在的文件,mmap需要使用open获取到的文件句柄。

mmap函数在系统的<sys/mman.h>头文件中声明:
void *mmap(void *start, size_t length, int prot, int flags,int fd, off_t offset)

参数含义为:

  • start:映射区的开始地址(从哪个地址开始映射内存),如果传入NULL,则系统会自动选择合适的地址开始进行映射(通常情况下传入NULL
  • length:映射的长度
  • prot:映射内存保护方式,且不能与open文件打开方式冲突,可以通过|操作符指定多个类型。在iOS中通常选择PROT_READPROT_WRITE
  • flags:映射与其他进程的共享方式,iOS中通常使用MAP_SHARED
  • fd:使用open函数获取的文件句柄,指定对那个文件进行操作
  • offset:从文件的何处开始进行映射

返回值为void *类型的指针,为映射成功后的内存地址。如果返回NULL表示映射失败,可以通过errno宏获取到对应的错误码。

简单的代码例子为:

- (void)testRead
{
    NSString *filePath = [@"~/Library/testFile" stringByExpandingTildeInPath];
    int fd = open(filePath.UTF8String, O_RDWR);
    void *m_ptr = NULL;
    NSUInteger k1Gb = 1024 * 1024 * 1024;
    m_ptr = mmap(m_ptr, k1Gb, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    close(fd); //映射完成后文件就可以关闭了

    //read
    uint8_t buffer[1024];
    memcpy(buffer, m_ptr, 1024);
    //do something...
    
    //write
    *(uint8_t *)m_prt = 10;
}

在映射成功后,可以对返回的指针进行常规的读写操作,跟操作分配在内存中的内容一模一样。

解除映射

munmap函数用于对已映射文件的内存进行解除,函数声明为int munmap(void * addr, size_t len),参数含义为:

  • addr:使用mmap获取到的映射内存地址
  • len:映射的长度

返回值为int,0代表成功,-1代表失败。使用errno获取对应的失败信息。

同步磁盘数据

通常来说,对映射内存的更改不会同步写入到磁盘文件,而是有系统决定一个合适时机进行写入。使用msync函数可以立即将更改同步到磁盘。其函数声明为:int msync(void *addr, size_t len, int flags),参数含义为:

  • addrlen:与mmapmunmap类似,分别为映射内存地址和指定的长度
  • flags:可选MS_ASYNCMS_SYNCMS_INVALIDATE,使用标志MS_ASYNC函数会计划一次同步,但其立即返回,

!细节问题

  • mmap函数中有2个参数lengthoffset,其大小在文档中推荐为内存页大小的整数倍(数据对齐),如果不是整数倍,在iOS系统会自动延伸到合适的整数倍大小,并将额外的空间全部填入0。其原因是,内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。为了匹配内存的操作,mmap从磁盘到虚拟地址空间的映射也必须是页
  • 映射完成后,文件就可以被关闭。因为映射的是文件实际的磁盘地址,而不是文件本身,所以文件句柄可以关闭
  • 映射时指定的length可以与文件大小不一致。如果比文件大,且被映射的文件在映射后又被扩展,我们也可以合法的访问在当前文件大小以内又在内存映射区以内的那些字节。也就是说,如果文件的大小一直在扩张,只要在映射区域范围内的数据,进程都可以合法得到,这和映射建立时文件的大小无关。详细说明一下:
    • 前提:我们假设在64位的iOS13系统上,一个内存页大小为是16k大小。(内存对齐方式测试过iOS13和12,两者有些不同,我们以13为例)
    • 有三种比较特殊的情况:
      1. 文件长度等于映射长度,但未进行内存页对齐: 假设文件为10000字节大小,我们映射10000字节大小(也就是length参数传入10000)。此时,由于10000字节不足1个内存页大小(16k),此时映射系统会自动进行内存对齐,映射16k大小的内存。10000-16384字节的部分系统会自动填入0。访问这部分内存不会报错,但相关的改动不会同步到文件。
      2. 文件长度小于映射长度: 假设文件为10000字节大小,映射32k大小。此时,由于10000字节不足1内存页,系统会自动映射16k内存与文件关联,此时与情况1相同,但由于我们映射了32k的内存,如果访问多出的16k内存,则会造成EXC_BAD_ACCESS崩溃
      3. 文件长度在变化:
      • 变长:假设文件长度为0,我们映射32k大小,如果这时候直接访问,由于没有对应的物理文件,则会触发EXC_BAD_ACCESS崩溃。但如果我们在访问内存前通过ftruncate等手段将文件扩大,则访问文件大小范围内的部分都是可以同步到文件中的
      • 变短:如果文件长度为32k,我们映射32k,当访问到10000字节时,同时也将文件变短为10000字节,然后继续访问。此时,在16k内存对齐范围内的访问还是能够不崩溃,读取时系统会自动全部填入0,写入无效。如果继续访问16-32k的范围,则会触发EXC_BAD_ACCESS崩溃。
    • 总结:当mmap映射的长度与文件大小不一致时,访问超出文件大小且在内存对齐范围内的部分,不会导致崩溃,但是读取全部为0,写入无效。访问超出文件大小且在内存对齐外的部分会导致内存的错误访问崩溃。

性能分析

下面我们通过内存和读写耗时2个方面来分析mmap的性能。测试的手机为iPhoneX,系统是iOS13.3.1。

内存占用

前文提到过mmap有一个优势就是不占用过多的物理内存。下面我们通过读一个1G的大文件来看一下在读取过程中mmap的内存占用情况。

  • 读:

我们使用如下测试代码测试读取内存占用:

- (void)testRead
{
    NSString *filePath = [@"~/Library/testFile" stringByExpandingTildeInPath];
    int i = 0;
    int fd = open(filePath.UTF8String, O_RDWR);
    ftruncate(fd, k1Gb);  //修改大小为1Gb
    void *m_ptr = mmap(NULL, k1Gb, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    close(fd);
    uint8_t bytes[k1Kb]; //1kb大小的buffer
    while (i < k1Mb) {
        size_t offSet = (k1Kb * i);
        //每次读取1kb内容到buffer中
        memcpy(bytes, m_ptr + offSet, k1Kb);
        i++;
    }
}

运行App,内存占用为2.45MB, 2秒后执行testRead函数,内存占用为2.51MB,内存只增长了不到1K。

  • 写:

将上述测试代码while循环中的memcpy的源地址和目标地址颠倒:memcpy(m_ptr + offSet, bytes, k1Kb);进行写入测试。所得到的的结果为:
运行App,内存占用为2.55MB, 2秒后执行测试函数,内存占用为2.63MB,内存也只是增长了不到1K。

通过上面的测试可以发现,通过mmap无论是进行读还是写,所需要占用的内存都是非常少的。

读写耗时

另外一个值得关注的性能就是速度了。说起速度,由于mmap也是读写文件的一种方式,通常情况下我们与readwrite常规读写文件方式进行对比。这里先简单介绍一下mmap和常规的readwirte有何不同。

readwirte等常规文件操作为了提高读写效率和保护磁盘,使用了页缓存机制。这样造成读文件时需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核态空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。写操作也是一样,待写入的buffer在内核空间不能直接访问,必须要先拷贝至内核空间对应的主存,再写回磁盘中(延迟写回),也是需要两次数据拷贝。

而使用mmap操作文件中,创建新的虚拟内存区域和建立文件磁盘地址和虚拟内存区域映射这两步,没有任何文件拷贝操作。而之后访问数据时发现内存中并无数据而发起的缺页异常过程,可以通过已经建立好的映射关系,只使用一次数据拷贝,就从磁盘中将数据传入内存的用户空间中,供进程使用。

从上面的描述中,看起来mmap效率应该会高于普通的常规文件读写操作。而且现在网上有不少文章也在说mmap的效率会很高。那么,真实情况是如此吗?我们通过读/写相同的文件,对比一下mmapread/write的速度。

  • 读: 我们对readmmap分别进行读取测试,对1Gb的文件,每次读取1kb的数据拷贝到buffer中,记录从头到尾的顺序读取和头尾随机读取,测试的结果如下:
函数 操作 耗时
read 顺序读 276ms
mmap 顺序读 366ms
read 随机读取 549ms
mmap 随机读取 416ms

可以看出,常规的read在顺序读取的情况下,效率比mmap更高;对于随机读取,则是mmap更有优势。

  • 写: 我们对writemmap分别进行读取测试,每次写入1kb的数据到文件中,共写入1G的数据进入文件,记录从头到尾的顺序写和头尾随机写入,测试的结果如下:
函数 操作 耗时
write 顺序写 8.95s
mmap 顺序写 4.26s
write 随机写 20.4s
mmap 随机写 17.6s

在写操作上,无论是随机写还是顺序写,mmap都要比write速度更快。

所以我们可以看出,基本上mmap效率更高的说法是正确的,只是在读取的情况下,mmap更擅长读随机位置的数据,而顺序读取数据则还是read速度更快。

应用场景

前文我们分析了mmap的基本原理与性能后,我们来看看对于iOS开发来说mmap应该在什么场景下进行应用

  1. mmap有个很大的优势在于映射文件不占用物理内存空间,因此可以用来读取大文件
  2. 对于读写效率上与常规的read/write比较,适用于需要随机读写的场景,及写入文件的场景
  3. 对于需要长时间持有某个文件或者需要与其他进程共享某个文件,及进程间通讯需要传递大量数据时

如果我们要在mmap和常规读写文件操作中进行取舍,引用Stack Overflow上某位大神的结论:

Use memory maps if you access data randomly, keep it around for a long time, or if you know you can share it with other processes. Read files normally if you access data sequentially or discard it after reading. And if either method makes your program less complex, do that. For many real world cases there's no sure way to show one is faster without testing your actual application and NOT a benchmark.

我自己的观点:如果不是特别需要或者性能上确实有巨大提升,否则还是老实的用常规文件读写吧。如果要使用mmap,那么进行充分的测试,否则可能有你意想不到的问题。

终极应用:不占用内存展示图片

iOS内存受限,而图片又是占用内存的大户。特别是在输入法、today等extension中,系统限制了所能申请的最大内存量。那么,如果有较多的图片或者较大的图片需要展示,很有可能把我们的程序搞崩溃。

由于图片渲染需要先将图片解码为位图,图片展示后系统会持有位图,造成内存的占用。有一个简单的公式计算图片展示所需要的位图内存大小:width * height * 4,比如一个100×100宽高的图片,展示后所需要的位图数据将占用39k。如果图片越大,占用内存就越多。这个是跟图片文件的压缩格式(pngjpg等)无关的。

既然学习了mmap,我们是否可以使用mmap映射一段文件内存,让系统把位图存放在这部分映射内存中呢?答案是可以的!而且这样做不会占用物理内存

怎么做呢?这里卖个关子,我的下一篇文章将详细讲述。

知识延伸:

探索mmap过程中遇到很多相关知识点,研究清楚后有助于更好的理解mmap。我们不能只学习一个知识点,要拓宽整个相关知识面。我罗列了一下与mmap相关的知识点及参考资料,方便大家进行知识的延伸。

虚拟内存

read/write

内核态、用户态

无论是mmapread/write都会涉及到系统调用,及内核态和用户态之间的转换,以下几个文章有助于理解用户态、内核态: