一、概述

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

二、相关API

2.1 mmap()

/* mmap(), munmap() - map or unmap files or devices into memory(将文件或设备映射或取消映射到内存中)
 * 
 * addr/length分别用于指定需要申请的空间的起始地址和长度。
 *      如果addr设置为0/NULL,那么Linux系统会自动挑选合适的起始地址(这是最方便的方式)。
 *    如果addr不为NULL,则内核将其作为关于在何处放置映射的提示;
 *        在Linux上,内核将选择一个附近的页面边界(但总是高于或等于 /proc/sys/vm/mmap_min_addr 指定的值)并尝试在那里创建映射。
 *        如果那里已经存在另一个映射,内核会选择一个新地址,该地址可能依赖于提示,也可能不依赖于提示。
 * prot: 用于设置申请的空间的权限(可读、可写、可执行,并且不能与文件的打开模式冲突)。是以下的某个值,可以通过or运算合理地组合在一起
                  PROT_EXEC :页内容可以被执行
                  PROT_READ :页内容可以被读取
                  PROT_WRITE :页可以被写入
                  PROT_NONE :页不可访问
 * @param flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体
                  MAP_FIXED     // 使用指定的映射起始地址,如果由start和len参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。
                  MAP_SHARED    // 与其它所有映射这个对象的进程共享映射空间。对共享区的写入,相当于输出到文件。直到msync()或者munmap()被调用,文件实际上不会被更新。
                  MAP_PRIVATE   // 建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。
                  MAP_DENYWRITE //这个标志被忽略。
                  MAP_EXECUTABLE//同上
                  MAP_NORESERVE //不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。
                  MAP_LOCKED    //锁定映射区的页面,从而防止页面被交换出内存。
                  MAP_GROWSDOWN //用于堆栈,告诉内核VM系统,映射区可以向下扩展。
                  MAP_ANONYMOUS //匿名映射,映射区不与任何文件关联。
                  MAP_ANON      //MAP_ANONYMOUS的别称,不再被使用。
                  MAP_FILE      //兼容标志,被忽略。
                  MAP_32BIT     //将映射区放在进程地址空间的低2GB,MAP_FIXED指定时会被忽略。当前这个标志只在x86-64平台上得到支持。
                  MAP_POPULATE  //为文件映射通过预读的方式准备好页表。随后对映射区的访问不会被页违例阻塞。
                  MAP_NONBLOCK  //仅和MAP_POPULATE一起使用时才有意义。不执行预读,只为已存在于内存中的页面建立页表入口。
 * flags:用于设置映射类型(文件映射、匿名空间等)。确定映射的更新是否对映射同一区域的其他进程可见,以及是否将更新传递到底层文件。
 * fd:   用于文件映射时,指定文件描述符。在mmap()调用返回后,文件描述符fd可以立即关闭,而不会使映射无效。
 * offset: 用于文件映射时,指定文件偏移。offset 必须是 sysconf(_SC_PAGE_SIZE) 返回的页面大小的倍数。
 * return: 
 *       成功时,mmap() 返回一个指向映射区域的指针。
 *       出错时,返回值 MAP_FAILED(即 (void *) -1),并设置 errno 以指示错误。
                  EACCES:访问出错
                  EAGAIN:文件已被锁定,或者太多的内存已被锁定
                  EBADF:fd不是有效的文件描述词
                  EINVAL:一个或者多个参数无效
                  ENFILE:已达到系统对打开文件的限制
                  ENODEV:指定文件所在的文件系统不支持内存映射
                  ENOMEM:内存不足,或者进程已超出最大内存映射数量
                  EPERM:权能不足,操作不允许
                  ETXTBSY:已写的方式打开文件,同时指定MAP_DENYWRITE标志
                  SIGSEGV:试着向只读区写入
                  SIGBUS:试着访问不属于进程的内存区
 */
void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);

mmap() 的作用就是向操作系统申请一段虚拟地址空间,这段空间:

fdoffsetlength

2.2 munmap()

int munmap(void * addr, size_t len);

成功执行时,munmap()返回0。失败时,munmap返回-1,error返回标志和mmap一致;

该调用在进程地址空间中解除一个映射关系,addr是调用mmap()时返回的地址,len是映射区的大小;

当映射关系解除后,对原来映射地址的访问将导致段错误发生。

2.3 msync()

int msync(void *addr, size_t len, int flags);

一般说来,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往往在调用munmap()后才执行该操作。

可以通过调用msync()实现磁盘上文件内容与共享内存区的内容一致。

三、mmap和常规文件操作的区别

3.1 页缓存(page cache)

维基百科:Page cache

在计算中,页面缓存(page cache),有时也称为磁盘缓存(disk cache),[1] 是一种透明缓存,用于存储源自二级存储设备(如硬盘驱动器 (HDD) 或固态驱动器 (SSD))的页面。操作系统在主内存 (RAM) 的其他未使用部分中保留页面缓存,从而更快地访问缓存页面的内容并提高整体性能。页面缓存在内核中通过分页内存管理实现,并且对应用程序几乎是透明的。

通常,所有不直接分配给应用程序的物理内存都被操作系统用于页面缓存。出于充分利用内存不使其处于空闲状态,以及此类内存在应用程序请求时很容易回收,因此通常没有相关的性能损失。操作系统甚至可能将此类内存报告为“空闲(free)”或“可用(available)”。

与主存相比,硬盘驱动器的读/写速度很慢,随机访问需要昂贵的磁盘寻道;因此,随着更多数据可以缓存在内存中,大容量的主内存能带来明显的性能提升。

页缓存中被引入后修改的页称为脏页(dirty pages)。

3.1.1 补充 — 文件系统、文件、磁盘

文件系统:是一种存储和组织计算机数据的方法,它使得对其访问和查找变得容易,文件系统使用文件和树形目录的抽象逻辑概念代替了硬盘和光盘等物理设备使用数据块的概念,用户使用文件系统来保存数据不必关心数据实际保存在硬盘(或者光盘)的地址为多少的数据块上,只需要记住这个文件的所属目录和文件名。在写入新数据之前,用户不必关心硬盘上的哪个块地址没有被使用,硬盘上的存储空间管理(分配和释放)功能由文件系统自动完成,用户只需要记住数据被写入到了哪个文件中。

现代OS几乎都是通过文件系统来组织和管理在计算机中所存储的大量程序和数据的。文件系统的管理功能是通过把它所管理的程序和数据组织成一系列文件的方法来实现的。而文件则是指具有文件名的若干相关元素的集合元素通常是记录,而记录是一组有意义的数据项的集合。可以把数据组成分为数据项、记录、文件。

  • 数据项,数据项是最低级数据组织形式。分为基本数据项(用于描述一个对象某种属性的字符集,是数据组织中可以明明的最小逻辑数据单位,即原子数据,又称为数据元素或字段)和组合数据项(由若干个基本数据项组成)

  • 记录,是一组相关数据项的集合,用于描述一个对象在某方面的属性,为了能够唯一标识一个记录,需要在记录中确定一个或集合数据项,把他们的集合称为关键字,关键字是能够唯一标识一个记录的数据项。

  • 文件,文件是具有文件名的一组相关元素的集合,分为有结构文件和无结构文件。有结构文件由若干个相关记录组成,无结构文件则被看成一个字符流。文件是文件系统的最大数据单位。

    文件应该具有自己的属性,包括:

    • 文件类型:如源文件、目标文件、可执行文件等。
    • 文件长度:文件的当前长度,也可能是最大允许长度。
    • 文件的物理位置:指示文件在哪一个设备上及在该设备的哪个位置的指针。

一个文件可对应若干个记录,一个记录可对应若干个数据项。

文件系统管理的对象有:

  • 文件:作为文件管理的直接对象
  • 目录:为了方便用户对文件的存取和检索,在文件系统中配置目录,每个目录项中,必须含有文件名及该文件所在的物理地址,对目录的组织和管理是方便和提高对文件存取速度的关键。
  • 磁盘存储空间:文件和目录必定占用存储空间,对这部分空间的有效管理,不仅能提高外存的利用率,而且能提高对文件的存取速度。

3.1.2 补充 — 字符设备文件和块设备文件

在Unix的/dev 目录下,有许多文件,其中有两个大类:字符设备文件和块设备文件。

  • 字符设备特殊文件进行I/O操作不经过操作系统的缓冲区,与外设进行I/O操作时每次只传输一个字符。

  • 块设备特殊文件使用了cache机制,在外设和内存之间一次可以传送一整块数据(定长的包传输)。

  • 裸设备是一种特殊类型的块设备文件,允许以直接访问硬盘的方式访问一个存储设备,而不经过操作系统的高速缓存和缓冲器(尽管可能仍旧使用硬件高速缓存)。应用程序如数据库管理系统可能直接使用裸设备,使它们能自行管理数据的缓存方式,而不将这些任务交给操作系统。

裸设备少了操作系统这一层,I/O效率更高。不少数据库都能通过使用裸设备作为存储介质来提高I/O效率。

2021年6月10日,IT之家消息,RAW 设备正式退出了历史舞台。

3.1.3 Buffer Cache与Page Cache(Linux)

在我们进行数据持久化,对文件内容进行落盘处理时,我们时常会使用fsync操作(file sync,即文件同步),该操作会将文件关联的脏页(dirty page)数据(实际文件内容及元数据信息)一同写回磁盘。这里提到的脏页(dirty page)即为页缓存(page cache,也叫页高速缓存)。

块缓存(buffer cache),则是内核为了加速对底层存储介质的访问速度,而构建的一层缓存。它缓存部分磁盘数据,当有磁盘读取请求时,会首先查看块缓存中是否有对应的数据,如果有的话,则直接将对应数据返回,从而减少对磁盘的访问。

两层缓存各有自己的缓存目标,我好奇的是,这两者到底是什么关系?

两类缓存各自的作用:

  • Page Cache

    Page Cache以Page为单位,缓存文件内容。缓存在Page Cache中的文件数据,能够更快的被用户读取。同时对于带buffer的写入操作,数据在写入到Page Cache中即可立即返回,而不需等待数据被实际持久化到磁盘,进而提高了上层应用读写文件的整体性能。

  • Buffer Cache

    磁盘的最小数据单位为sector,每次读写磁盘都是以sector为单位对磁盘进行操作。sector大小跟具体的磁盘类型有关,有的为512Byte, 有的为4K Bytes。无论用户是希望读取1个byte,还是10个byte,最终访问磁盘时,都必须以sector为单位读取,如果裸读磁盘,那意味着数据读取的效率会非常低。同样,如果用户希望向磁盘某个位置写入(更新)1个byte的数据,他也必须整个刷新一个sector,言下之意,则是在写入这1个byte之前,我们需要先将该1byte所在的磁盘sector数据全部读出来,在内存中,修改对应的这1个byte数据,然后再将整个修改后的sector数据,一口气写入磁盘。

    为了降低这类低效访问,尽可能的提升磁盘访问性能,内核会在磁盘sector上构建一层缓存,它以sector的整数倍粒度为单位(block),缓存部分sector数据在内存中,当有数据读取请求时,它能够直接从内存中将对应数据读出。当有数据写入时,它可以直接在内存中直接更新指定部分的数据,然后再通过异步方式,把更新后的数据写回到对应磁盘的sector中。

    这层缓存则是块缓存Buffer Cache。

虽然,目前Linux Kernel(linux-2.6.18)代码中,Page Cache和Buffer Cache实际上是统一的,无论是文件的Page Cache还是Block的Buffer Cache最终都统一到Page上。但是,在阅读较老代码时,我们能够看出,这两块缓存的实现,原本是完全分开的。是什么原因使得最终这两类缓存“走到了一起”?

1. 第一阶段:仅有Buffer Cache

在Linux-0.11版本的代码中,我们会看到,buffer cache是完全独立的实现,甚至都还没有基于page作为内存单元,而是以原始指针的系形式出现。每一个block sector,在kernel内部对应一个独立的buffer cache单元,这个buffer cache单元通过buffer head来描述。

2. 第二阶段:Page Cache、Buffer Cache并存

到Linux-2.2版本时,磁盘文件访问的高速缓冲仍然是缓冲区高速缓冲(Buffer Cache)。其访问模式与上面Linux-0.11版本的访问逻辑基本类似。但此时,Buffer Cache已基于page来分配内存:

struct buffer_head *b_this_page;

此时的buffer cache基于page来分配内存,但是与Page Cache完全独立,一点关系都没有。

在Linux-2.2版本中,Page Cache此时用来干什么的?

  • 用于文件的mmap
  • 用于network-based filesytems

此时,Page Cache和Buffer Cache的关系如下图所示:

Page Cache仅负责其中mmap部分的处理,而Buffer Cache实际上负责所有对磁盘的IO访问。从上面图中,我们也可看出其中一个问题:write绕过了Page Cache,这里导致了一个同步问题。当write发生时,有效数据是在Buffer Cache中,而不是在Page Cache中。这就导致mmap访问的文件数据可能存在不一致问题。

为了解决这个问题,所有基于磁盘文件系统的write,都需要调用 update_vm_cache() 函数,该操作会修改write相关Buffer Cache对应的Page Cache。从代码中我们可以看到,上述sysv_file_write中,在调用完copy_from_user之后,会调用update_vm_cache。

同样,正是这样Page Cache、Buffer Cache分离的设计,导致基于磁盘的文件,同一份数据,可能在Page Cache中有一份,而同时,却还在Buffer Cache中有一份。

3. 第三阶段:Page Cache、Buffer Cache融合

介于上述Page Cache、Buffer Cache分离设计的弊端,Linux-2.4版本中对Page Cache、Buffer Cache的实现进行了融合,融合后的Buffer Cache不再以独立的形式存在:

  • Buffer Cache的内容,直接存在于Page Cache中,同时,保留了对Buffer Cache的描述符单元:buffer_head。
  • page结构中,通过buffers字段是否为空,来判定这个Page是否与一组Buffer Cache关联(在后续的演进过程中,这个判断,转变为由 private 字段来判定)。
  • 而对应的,buffer_head则增加了字段 b_page ,直接指向对应的page。

至此,两者的关系已经相互融合如下图所示:

将Page Cache与Buffer Cache(buffer_head)相互绑定。对上,在文件读写时,以page为单位进行处理。而对下,在数据向device进行刷新时,则可以以buffer_head(block)为单位进行处理。

在后续的linux-2.5版本中,引入了bio结构来替换基于buffer_head的块设备IO操作。

注意:

  • 这里的Page Cache与Buffer Cache的融合,是针对文件这一层面的Page Cache与Buffer Cache的融合。

  • 对于跨层的:File层面的Page Cache和裸设备Buffer Cache,虽然都统一到了基于Page的实现,但File的Page Cache和该文件对应的Block在裸设备层访问的Buffer Cache,这两个是完全独立的Page。

    即:这种情况下,一个物理磁盘Block上的数据,仍然会对应Linux内核中的两份Page:一个是下图右侧,通过文件层访问的File的Page Cache(Page Cache);一个是下图左侧,通过裸设备层访问的Page Cache(Buffer Cache)。

3.1.4 总结

早期的Linux内核版本中,同时存在PageCache和BufferCache,由于这两种缓存处于半独立的状态,缺乏集成导致整体性能下降和缺乏灵活性。在内核版本2.4之后,对Page Cache、Buffer Cache的实现进行了融合。融合后的Buffer Cache不再以独立的形式存在,Buffer Cache的内容,直接存在于Page Cache中,同时,保留了对Buffer Cache的描述符单元:buffer_head。

简单说来,磁盘的操作有逻辑级(文件系统)和物理级(磁盘块),这两种Cache就是分别缓存逻辑和物理级数据的。page cache用来缓存文件数据,buffer cache用来缓存磁盘数据。

  • 在有文件系统的情况下,对文件操作,那么数据会缓存到page cache,如果直接采用dd等工具对磁盘进行读写,那么数据会缓存到buffer cache。

  • Cache(Page Cache)以页面形式缓存了文件系统的文件,给需要使用的程序读取,它是为了给读操作提供缓冲,避免频繁读硬盘,提高读取效率。

  • Buffer(Buffer Cache)以块形式缓冲了块设备的操作,定时或手动的同步到硬盘,它是为了缓冲写操作然后一次性将很多改动写入硬盘,避免频繁写硬盘,提高写入效率。

3.2 常规文件操作

我们首先简单的回顾一下常规linux文件系统操作(调用read/fread等类函数)中,函数的调用过程:

  1. 进程发起读文件请求。

  2. 内核通过查找进程文件符表,定位到内核已打开文件集上的文件信息,从而找到此文件的inode。

  3. inode在address_space上查找要请求的文件页是否已经缓存在页缓存中。如果存在,则直接返回这片文件页的内容。

  4. 如果不存在,则通过inode定位到文件磁盘地址,将数据从磁盘复制到页缓存。之后再次发起读页面过程,进而将页缓存中的数据发给用户进程。

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

3.3 mmap映射操作文件

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

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

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

<font color='red'>总而言之,常规文件操作需要从磁盘到页缓存再到用户主存的两次数据拷贝。而mmap操控文件,只需要从磁盘到用户主存的一次数据拷贝过程。</font>说白了,mmap的关键点是实现了用户空间和内核空间的数据直接交互而省去了空间不同数据不通的繁琐过程。因此mmap效率更高。

四、mmap优点总结

由上文讨论可知,mmap优点共有一下几点:

  • 对文件的读取操作跨过了页缓存,减少了数据的拷贝次数,用内存读写取代I/O读写,提高了文件读取效率。

  • 实现了用户空间和内核空间的高效交互方式。两空间的各自修改操作可以直接反映在映射的区域内,从而被对方空间及时捕捉。

  • 提供进程间共享内存及相互通信的方式。不管是父子进程还是无亲缘关系的进程,都可以将自身用户空间映射到同一个文件或匿名映射到同一片区域。从而通过各自对映射区域的改动,达到进程间通信和进程间共享的目的。

    同时,如果进程A和进程B都映射了区域C,当A第一次读取C时通过缺页从磁盘复制文件页到内存中;但当B再读C的相同页面时,虽然也会产生缺页异常,但是不再需要从磁盘中复制文件过来,而可直接使用已经保存在内存中的文件数据。

  • 可用于实现高效的大规模数据传输。内存空间不足,是制约大数据操作的一个方面,解决方案往往是借助硬盘空间协助操作,补充内存的不足。但是进一步会造成大量的文件I/O操作,极大影响效率。这个问题可以通过mmap映射很好的解决。换句话说,但凡是需要用磁盘空间代替内存的时候,mmap都可以发挥其功效。

五、mmap使用细节

5.1 注意点

1、使用mmap需要注意的一个关键点是,mmap映射区域大小必须是物理页大小(page_size)的整倍数(32位系统中通常是4k字节)。原因是,内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。为了匹配内存的操作,mmap从磁盘到虚拟地址空间的映射也必须是页。

2、内核可以跟踪被内存映射的底层对象(文件)的大小,进程可以合法的访问在当前文件大小以内又在内存映射区以内的那些字节。也就是说,如果文件的大小一直在扩张,只要在映射区域范围内的数据,进程都可以合法得到,这和映射建立时文件的大小无关。具体情形参见“情形三”。

3、映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关。同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。

5.2 示例:文件大小、映射大小

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

情形一:一个文件的大小是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操作映射了1000*4K的大小,即1000个物理页大约4M字节空间,mmap返回指针ptr。

分析:如果在映射建立之初,就对文件进行读写操作,由于文件大小为0,并没有合法的物理页对应,如同情形二一样,会返回SIGBUS错误。

但是如果,每次操作ptr读写前,先增加文件的大小,那么ptr在文件大小内部的操作就是合法的。例如,文件扩充4096字节,ptr就能操作ptr ~ [ (char)ptr + 4095]的空间。只要文件扩充的范围在1000个物理页(映射范围)内,ptr都可以对应操作相同的大小。

这样,方便随时扩充文件空间,随时写入文件,不造成空间浪费。

ftruncate
/*
 * 将path指定的文件、由fd引用的常规文件截断为精确长度字节的大小。
 *    如果文件以前大于此大小,则多余的数据将丢失。
 *    如果文件先前较短,则将其扩展,扩展部分读取为空字节 ('\0')。
 * 文件偏移量没有改变。
 * 如果大小发生变化,则更新文件的 st_ctime 和 st_mtime 字段(分别为上次状态更改的时间和上次修改的时间),以及set-user-ID 和 set-group-ID权限位可以被清除。
 */
int truncate(const char *path, off_t length); // 文件必须是可写的
int ftruncate(int fd, off_t length);  // 文件必须打开才能写入

使用示例:

@discardableResult
private func increaseFileLength(length: size_t) -> Bool {

    lock.lock()

    // cancel map first
    self.munmap()

    // change file length
    let result = ftruncate(fileDescriptor, fileLength + off_t(length))
    if result < 0 {
        print("can't truncate data file")
        lock.unlock()
        return false
    }

    // remap
    fileLength = lseek(fileDescriptor, 0, SEEK_END)
    self.mmap()

    lock.unlock()
    return true
}

六、使用场景

这一部分来自苹果官方开发文档Mapping Files Into Memory

尽管映射文件可以提供巨大的性能优势,但并不适用于所有情况。

文件映射的目标是减少磁盘和内存之间的传输。在某些情况下,文件映射可以帮助您,但不是全部。映射到内存中的文件越多,文件映射的用处就越少

在将任何文件映射到内存之前,请确保您了解典型的文件使用模式。使用工具来帮助您确定应用程序访问文件的位置以及这些操作需要多长时间。

6.1 适合的场景

  • 您有一个很大的文件,希望随机访问其内容一次或多次。
  • 您有一个小文件,您想一次将其内容全部读入内存并经常访问。这种技术最适用于大小不超过几个虚拟内存页的文件。(页是地址空间的最小单位,虚拟页和物理页的大小是一样的,通常为4KB。)
  • 您想在内存中缓存文件的特定部分。文件映射完全消除了缓存数据的需要,从而在系统磁盘缓存中为其他数据留出了更多空间。

当随机访问一个非常大的文件时,最好是一次只映射文件的一小部分。映射大文件的问题是文件消耗活动内存。如果文件足够大,系统可能会被迫调出内存的其他部分来加载文件。将多个文件映射到内存中会加剧这个问题。

6.2 不适合的场景

在以下情况下不要使用文件映射:

  • 您只想从头到尾按顺序读取文件一次。

  • 该文件的大小为数百兆字节或更大。将大文件映射到内存中会很快填满内存,并可能导致分页,这首先抵消了映射文件的好处。对于大型顺序读取操作,你应该禁用磁盘缓存(disk cache/page cache)并将文件读入一个小的内存缓冲区。

  • 该文件大于可用的连续虚拟内存地址空间。这对于 64 位应用程序来说不是问题,但对于 32 位应用程序来说可能是一个问题。

  • 该文件位于可移动驱动器上。

  • 该文件位于网络驱动器上。

如果您在可移动驱动器或网络驱动器上映射文件,并且该驱动器被卸载或由于其他原因消失,则访问映射内存可能会导致总线错误并使您的程序崩溃。

七、iOS 中的 mmap

7.1 性能测试用例

下面这个程序,通过read和mmap两种方法分别对硬盘上一个名为“mmap_test”的文件进行操作,文件中存有10000个整数,程序两次使用不同的方法将它们读出,加1,再写回硬盘。通过对比可以看出,read消耗的时间将近是mmap的两到三倍。

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/time.h>
#include <fcntl.h>
#include <sys/mman.h>

#define MAX 10000

int main()
{
    int i = 0;
    int count = 0, fd = 0;
    struct timeval tv1, tv2;
    int *array = (int *)malloc(sizeof(int) * MAX);

    /*read*/

    gettimeofday(&tv1, NULL);
    fd = open("mmap_test", O_RDWR);
    if (sizeof(int) * MAX != read(fd, (void *)array, sizeof(int) * MAX)){
        printf("Reading data failed.../n");
        return -1;
    }
    for (i = 0; i < MAX; ++i)
        ++array[i];

    if (sizeof(int) * MAX != write(fd, (void *)array, sizeof(int) * MAX)){
        printf("Writing data failed.../n");
        return -1;
    }
    free(array);
    close(fd);
    gettimeofday(&tv2, NULL);
    printf("Time of read/write: %dms/n", tv2.tv_usec - tv1.tv_usec);

    /*mmap*/

    gettimeofday(&tv1, NULL);
    fd = open("mmap_test", O_RDWR);
    array = mmap(NULL, sizeof(int) * MAX, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
    for (i = 0; i < MAX; ++i)
        ++array[i];
        
    munmap(array, sizeof(int) * MAX);
    msync(array, sizeof(int) * MAX, MS_SYNC);
    free(array);
    close(fd);
    gettimeofday(&tv2, NULL);
    printf("Time of mmap: %dms/n", tv2.tv_usec - tv1.tv_usec);

    return 0;
}

输出结果:

Time of read/write: 154ms
Time of mmap: 68ms

7.2 自实现Demo

用官方文档 — Mapping Files Into Memory的代码做参考,实现一个读写的例子:

#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);
}

7.3 MMKV 和 mmap

7.3.1 MMKV 简介

  • NSUserDefault 是常见的缓存工具,但是数据有时会同步不及时,比如说在 crash 前保存的值很容易出现保存失败的情况,在 App 重新启动之后读取不到保存的值。
  • MMKV 很好的解决了 NSUserDefault 的局限,但是同样由于其独特设计,在数据量较大、操作频繁的场景下,会产生性能问题。这里的使用给出两个建议:
    • 不要全部用 defaultMMKV,根据业务大的类型做聚合,避免某一个 MMKV 数据过大,特别是对于某些只会出现一次的新手引导、红点之类的逻辑,尽可能按业务聚合,使用多个 MMKV 的对象;
    • 对于需要频繁读写的数据,可以在内存持有一份数据缓存,必要时再更新到 MMKV。

7.3.2 MMKV 原理

1. 内存准备

通过 mmap 内存映射文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由 iOS 负责将内存回写到文件,不必担心 crash 导致数据丢失。

2. 数据组织

数据序列化方面选用 protobuf 协议,pb 在性能和空间占用上都有不错的表现。考虑到要提供的是通用 KV 组件,key 可以限定是 string 字符串类型,value 则多种多样(int/bool/double 等)。要做到通用的话,考虑将 value 通过 protobuf 协议序列化成统一的内存块(buffer),然后就可以将这些 KV 对象序列化到内存中。

message KV {
  string key = 1 ;
  buffer value = 2;
}
- (BOOL)setInt32:(int32 t)value forKey:(NSString*)key {
  auto data = PBEncode(value); 
  return [self setData:data forKey:key];
}
- (BOOL)setData: (NSData*)data forKey:(NSString*)key {
  auto kv = KV[key,data];
  auto buf = PBEncode(kv);
  return [self write: buf];
}
3. 写入优化

标准 protobuf 不提供增量更新的能力,每次写入都必须全量写入。考虑到主要使用场景是频繁地进行写入更新,我们需要有增量更新的能力:将增量 kv 对象序列化后,直接 append 到内存末尾;这样同一个 key 会有新旧若干份数据,最新的数据在最后;那么只需在程序启动第一次打开 mmkv 时,不断用后读入的 value 替换之前的值,就可以保证数据是最新有效的。

4. 空间增长

使用 append 实现增量更新带来了一个新的问题,就是不断 append 的话,文件大小会增长得不可控。例如同一个 key 不断更新的话,是可能耗尽几百 M 甚至上 G 空间,而事实上整个 KV 文件就这一个 key,不到 1k 空间就存得下,这明显是不可取的。我们需要在性能和空间上做个折中:以内存 pagesize 为单位申请空间,在空间用尽之前都是 append 模式;当 append 到文件末尾时,进行文件重整、key 排重,尝试序列化保存排重结果;排重后空间还是不够用的话,将文件扩大一倍,直到空间足够。

- (BOOL)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);
    }
  }
}
5. 数据有效性

考虑到文件系统、操作系统都有一定的不稳定性,另外增加了 crc 校验,对无效数据进行甄别。

6. MMKV 性能

写个简单的测试,将 MMKV、NSUserDefaults 的性能进行对比(循环写入1w 次数据,测试环境:iPhone X 256G, iOS 11.2.6,单位:ms):

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

7.4 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 优化。

7.5 FastImageCache

FastImageCache是Path团队开发的一个开源库,提供了一种在 iOS 应用程序中存储和检索图像的高效、持久且最重要的快速方法。

iOS从磁盘加载一张图片,使用UIImageVIew显示在屏幕上,需要经过以下步骤:

  1. 从磁盘拷贝数据到内核缓冲区
  2. 从内核缓冲区复制数据到用户空间
  3. 生成UIImageView,把图像数据赋值给UIImageView
  4. 如果图像数据为未解码的PNG/JPG,解码为位图数据
  5. CATransaction捕获到UIImageView layer树的变化
  6. 主线程Runloop提交CATransaction,开始进行图像渲染
    • 6.1 如果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐。
    • 6.2 GPU处理位图数据,进行渲染。

FastImageCache分别优化了2,4,6.1三个步骤:

  1. 使用mmap内存映射,省去了上述第2步数据从内核空间拷贝到用户空间的操作。
  2. 缓存解码后的位图数据到磁盘,下次从磁盘读取时省去第4步解码的操作。

八、参考链接