iOS中的mmap及相关知识

2020-05-15  本文已影响0人  newself1886

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

mmap基础介绍

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


image

在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的基础和原理以后,我们来细数一下它的优缺点:

优点

缺点

使用教程

前文说了这么多,现在我们来介绍一下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)

参数含义为:

返回值为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),参数含义为:

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

同步磁盘数据

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

!细节问题

性能分析

下面我们通过内存和读写耗时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的速度。

函数 操作 耗时
read 顺序读 276ms
mmap 顺序读 366ms
read 随机读取 549ms
mmap 随机读取 416ms

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

函数 操作 耗时
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都会涉及到系统调用,及内核态和用户态之间的转换,以下几个文章有助于理解用户态、内核态:

上一篇下一篇

猜你喜欢

热点阅读