iOS mmap 洪荒之力

4,081 阅读19分钟
原文链接: www.jianshu.com

一 Apple的解释

File-System Performance Guidelines官方在文件系统操作中也简单介绍了mmap,提及了其定义、适用场景、说明、示例。

1. 1 定义

文件映射是将磁盘上的文件映射到进程的虚拟内存中,文件一旦映射,程序访问文件该文件,就好像该文件完全驻留在内存中一样。当根据映射文件指针读取数据时,将会返回内核中的数据。

1.2 适用场景和说明

即使文件映射具有极大的性能优势,它也不能在任何场景下使用。

需要记住的是,被映射的文件是和系统库、应用程序的代码、分配的内存 共享进程空间的。大部分程序大约有2G的虚拟内存(这根据他们的加载库的数量),为了映射文件,就必须有足够大的可用的地址区域来装载这个文件,如果应用程序的虚拟内存空间是碎片化的,或映射的是一个巨大的文件,那这个可用地址的寻找是非常困难的。

在映射文件前,确保理解文件的使用模式。可以使用Shark和fs_usage等工具可以帮忙找到访问文件的位置、这些操作需要的时间。对于操作花费的时间超过预期,那就需要查看代码,并决定是否可以使用文件映射。

在这些情况下建议使用文件映射:

  • 有一个large file,你需要随时或者多次访问其内容。
  • 有一个small file,你需要一次读入,频繁访问。这最适合大小不超过几个虚拟内存页面的文件。
  • 你希望缓存一个文件的特定部分,那就不需要映射全部的文件,这样可以为其他数据留出更大的磁盘空间。

在这些情况下不建议使用:

  • 映射文件后,只读取了一次。
  • 文件是几百万字节或是更多(映射一个大文件会快读填充虚拟内存空间,另外的,如果程序运行了一段时间,其内存空间是碎片化的,或是没有可用的连续的虚拟内存地址空间)
  • 是一个移动磁盘上的文件
  • 是一个网络驱动上的文件

当随机访问一个非常大的文件的时候,最好只映射文件的一小部分,因为映射大文件可能会占用巨大的虚拟地址空间。单个进程的虚拟地址空间目前限制为4千兆字节,这些空间的部分被系统库所占用着。如果尝试映射一个大文件,可能会映射不成功,因为没有充足的空间去映射。

因为是在iOS系统,磁盘都是焊在手机上的,所有不会出现移动硬盘和使用云磁盘的情况。这里一句话概括:苹果不建议我们映射移动磁盘或者云驱动上的文件,因为映射失败会出现很多的问题(阻塞线程、系统奔溃等)。

二 基础理论

文件读写基本流程,APP在进行文件的磁盘读写操作,是需要通过系统调用用户空间切换至内核空间,操作完成后从内核空间回到用户空间,再进行用户代码。所以mmap可以减少磁盘和内存之间的转换,提高了往磁盘读写的性能。
以下是文件mmap图:

磁盘文件映射到内存.png

总结下:通过 mmap 内存映射磁盘上的文件,提供一段可供随时写入的内存块,App 只管往里面写数据,由 iOS 负责将内存回写到文件,不必担心 crash 导致数据丢失,大大降低了程序异常带来的数据丢失率。

三 相关Linux C函数

主要分析了两类函数:内存控制函数、文件操作函数、文件权限控制、部分错误code。

3.1 内存控制函数

3.1.1 mmap函数

  • 函数定义
void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offsize);
  • 函数说明

用来将某个文件内容映射到内存中,对该内存区域的存取即是直接对该文件内容的读写。由于内存只能以页为单位进行映射,若要映射非PAGE_SIZE整数倍的地址范围,要先进行内存对齐,强行以PAGE_SIZE的倍数大小进行映射。

  • 参数说明
    start指向欲对应的内存起始地址,通常设为NULL,代表让系统自动选定地址,对应成功后该地址会返回。
    length代表将文件中多大的部分对应到内存。以字节为单位,不足一内存页按一内存页处理。
    prot代表映射区域的保护方式,有以下组合:
    PROT_EXEC 映射区域可被执行
    PROT_READ 映射区域可被读取
    PROT_WRITE 映射区域可被写入
    PROT_NONE 映射区域不能存取
    flags会影响映射区域的各种特性
    MAP_FIXED如果参数start所指的地址无法成功建立映射时,则放弃映射,不对地址做修正。通常不鼓励用此旗标。
    MAP_SHARED对映射区域的写入数据会复制回文件内,而且允许其他映射该文件的进程共享。
    MAP_PRIVATE 对映射区域的写入操作会产生一个映射文件的复制,即私人的“写入时复制”(copy on write)对此区域作的任何修改都不会写回原来的文件内容。
    MAP_ANONYMOUS建立匿名映射。此时会忽略参数fd,不涉及文件,而且映射区域无法和其他进程共享。
    MAP_DENYWRITE只允许对映射区域的写入操作,其他对文件直接写入的操作将会被拒绝。
    MAP_LOCKED 将映射区域锁定住,这表示该区域不会被置换(swap)。
    在调用mmap()时必须要指定MAP_SHAREDMAP_PRIVATE
    fdopen()返回的文件描述词,代表欲映射到内存的文件。
    offset为文件映射的偏移量,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍。可以简单理解为被映射对象内容的起点。
    返回值
    若映射成功则返回映射区的内存起始地址,否则返回MAP_FAILED(-1),错误原因存于errno:
    EBADF 参数fd 不是有效的文件描述词
    EACCES 存取权限有误。如果是MAP_PRIVATE 情况下文件必须可读,使用MAP_SHARED则要有PROT_WRITE以及该文件要能写入。
    EINVAL 参数startlengthoffset有一个不合法。
    EAGAIN 文件被锁住,或是有太多内存被锁住。
    ENOMEM 内存不足。

3.1.2 munmap函数

  • 函数定义
int munmap(void *start,size_t length);
  • 函数说明
    进程结束或利用exec相关函数来执行其他程序时,映射内存会自动解除,但关闭对应的文件描述符时不会解除映射。

  • 参数说明
    start用来取消映射的内存起始地址。
    length是欲取消的内存大小。
    返回值 如果解除映射成功则返回0,否则返回-1,错误原因存于errno中错误代码EINVAL

#define EINVAL          22      /* Invalid argument */

startlength参数不合法。

3.1.3 memcpy函数

  • 函数定义
void * memcpy (void * dest ,const void *src, size_t n);
  • 函数说明
    用来拷贝src所指的内存内容前n个字节到dest所指的内存地址上。

  • 参数说明
    dest 为所要复制到的内存地址上。
    src 所指的内容。
    n 所指内容的前n个字节。

3.2 文件操作

3.2.1 open函数

  • 函数定义
int open( const char * pathname, int flags);
int open( const char * pathname,int flags, mode_t mode);
  • 函数说明
    参数pathname 指向欲打开的文件路径字符串

  • 参数说明
    pathname 指向欲打开的文件路径字符串。
    flags选项:
    O_RDONLY 以只读方式打开文件
    O_WRONLY 以只写方式打开文件
    O_RDWR 以可读写方式打开文件。上述三种旗标是互斥的,也就是不可同时使用,但可与下列的旗标利用OR(|)运算符组合。
    O_CREAT 若欲打开的文件不存在则自动建立该文件。
    O_EXCL 如果O_CREAT 也被设置,此指令会去检查文件是否存在。文件若不存在则建立该文件,否则将导致打开文件错误。此外,若O_CREATO_EXCL同时设置,并且欲打开的文件为符号连接,则会打开文件失败。
    O_NOCTTY 如果欲打开的文件为终端机设备时,则不会将该终端机当成进程控制终端机。
    O_TRUNC 若文件存在并且以可写的方式打开时,此旗标会令文件长度清为0,而原来存于该文件的资料也会消失。
    O_APPEND 当读写文件时会从文件尾开始移动,也就是所写入的数据会以附加的方式加入到文件后面。
    O_NONBLOCK 以不可阻断的方式打开文件,也就是无论有无数据读取或等待,都会立即返回进程之中。
    O_NDELAYO_NONBLOCK
    O_SYNC 以同步的方式打开文件。
    O_NOFOLLOW 如果参数pathname 所指的文件为一符号连接,则会令打开文件失败。
    O_DIRECTORY 如果参数pathname 所指的文件并非为一目录,则会令打开文件失败。(Linux2.2以后特有的旗标,以避免一些系统安全问题。)

mode 有下列数种组合,只有在建立新文件时才会生效,此外真正建文件时的权限会受到umask值所影响,因此该文件权限应该为(mode-umaks)。
S_IRWXU 00700权限,代表该文件所有者具有可读、可写及可执行的权限。
S_IRUSRS_IREAD,00400权限,代表该文件所有者具有可读取的权限。
S_IWUSRS_IWRITE,00200 权限,代表该文件所有者具有可写入的权限。
S_IXUSRS_IEXEC,00100 权限,代表该文件所有者具有可执行的权限。
S_IRWXG 00070权限,代表该文件用户组具有可读、可写及可执行的权限。
S_IRGRP 00040 权限,代表该文件用户组具有可读的权限。
S_IWGRP 00020权限,代表该文件用户组具有可写入的权限。
S_IXGRP 00010 权限,代表该文件用户组具有可执行的权限。
S_IRWXO 00007权限,代表其他用户具有可读、可写及可执行的权限。
S_IROTH 00004 权限,代表其他用户具有可读的权限。
S_IWOTH 00002权限,代表其他用户具有可写入的权限。
S_IXOTH 00001 权限,代表其他用户具有可执行的权限。
返回值 若所有欲核查的权限都通过了检查则返回0 值,表示成功,只要有一个权限被禁止则返回-1。
EEXIST 参数pathname所指的文件已存在,却使用了O_CREATO_EXCLflag。
EACCESS 参数pathname所指的文件不符合所要求测试的权限。
EROFS 欲测试写入权限的文件存在于只读文件系统内。
EFAULT 参数pathname指针超出可存取内存空间。
EINVAL 参数mode 不正确。
ENAMETOOLONG 参数pathname太长。
ENOTDIR 参数pathname不是目录。
ENOMEM 核心内存不足。
ELOOP 参数pathname有过多符号连接问题。
EIO I/O 存取错误。

3.2.2 close函数

  • 函数定义
int close(int fd);
  • 函数说明
    当使用完文件后若已不再需要则可使用close()关闭该文件,二close()会让数据写回磁盘,并释放该文件所占用的资源。

  • 参数说明
    fd 为先前由open()或creat()所返回的文件描述词。
    返回值 若文件顺利关闭则返回0,发生错误时返回-1。

3.2.3 fsync函数

  • 函数定义
int fsync(int fd);
  • 函数说明
    fsync()负责将参数fd所指的文件数据,由系统缓冲区写回磁盘,以确保数据同步。

  • 参数说明
    fdopen()返回的文件描述词,代表欲映射到内存的文件。
    返回值 成功则返回0,失败返回-1,errno为错误代码

3.3 文件权限控制

3.3.1 stat结构体

是用来保存文件状态的结构体(包含了文件的各种信息)

struct stat
{
dev_t st_dev; /*文件的设备编号*/
ino_t st_ino; /*文件的i-node*/
mode_t st_mode; /*文件的类型和存取的权限*/
nlink_t st_nlink; /*连到该文件的硬连接数目,刚建立的文件值为1。 */
uid_t st_uid; /*文件所有者的用户识别码*/
gid_t st_gid; /*文件所有者的组识别码*/
dev_t st_rdev; /*若此文件为装置设备文件,则为其设备编号*/
off_t st_size; /*文件大小,以字节计算*/
unsigned long st_blksize; /*文件系统的I/O 缓冲区大小。 */
unsigned long st_blocks; /*占用文件区块的个数,每一区块大小为512 个字节*/
time_t st_atime; /* 文件最近一次被存取或被执行的时间,一般只有在用mknod、utime、read、write与tructate时改变。*/
time_t st_mtime; /* 文件最后一次被修改的时间,一般只有在用mknod、utime和write时才会改变*/
...
};

3.3.2 fstat函数

  • 函数定义
int fstat(int fildes,struct stat *buf);
  • 函数说明
    根据fd获取文件状态,并将文件状态复制到参数buf所指的结构体中。通常的,获取文件大小可以通过这个属性进行获取。

  • 参数说明
    fildesopen()返回的文件描述词,代表欲映射到内存的文件。
    buf 将文件状态复制到参数buf所指的结构体中。

3.3.3 ftruncate函数

  • 函数定义
int ftruncate(int fd,off_t length);
  • 函数说明
    ftruncate()会将参数fd指定的文件大小改为参数length指定的大小。如果原来的文件大小比参数length大,则超过的部分会被删去。

  • 参数说明
    fd为已打开的文件描述词,而且必须是以写入模式打开的文件。

3.4 错误的code列表

整理了下所有的错误情况,方便应对以后出错的各种情况,文件路径:../sys/errno.h。

#define EPERM       1       /* 操作没有权限 */
#define ENOENT      2       /* 没有文件路径 */
...
#define E2BIG       7       /* 参数太长了 */
...
#define EMFILE      24      /* 打开的文件数太多了 */
...

四 实践

4.1 提高文本文件的读写效率

对磁盘上的text.txt文本文件中字符串的读写。

demo已上传至GitHub

测试环境是在单线程下,分别使用磁盘读写、文件映射的方式来读写5000条数据消耗的时间:

2018-12-03 15:15:24.916802+0800 mmap[21155:4210019] start

2018-12-03 15:15:31.893068+0800 mmap[21155:4210019] File writeFile 6.975802595s

2018-12-03 15:15:33.301879+0800 mmap[21155:4210019] mapFile writeFile 1.408519262s

2018-12-03 15:15:33.302069+0800 mmap[21155:4210019] end

使用磁盘读写方式用了约7s时间,使用文件映射的方式只用了约1.5s。

文件映射来读写数据的方式具有巨大的优势。

4.2 mmap i/o 提高sqlite的读效率

中文文档

  1. sqlite访问和更新数据库的方式默认是调用系统函数read()write()

    在版本3.7.17开始就可以使用memory-mapped I/O来直接访问磁盘内容,调用xFetch()xUnfetch()方法直接访问磁盘内容。当然,这是sqlite内部调用的方法,如果想使用mmap来提高数据库的读写效率,那只需配置PRAGMA mmap_size=N即可开启mmap,sqlite内部就已经帮我们做好了。如果超出了mmap_size,sqlite会使用xRead()

SQLite从版本3.7.17(2013-05-20)开始,选择使用内存映射I/O和sqlite3_io_methods上的新xFetch()和xUnfetch()方法直接访问磁盘内容。

早期的iOS版本的存在一些bug,SQLite在编译层就关闭了在iOS上对mmap的支持,并且在16年1月才重新打开。

参数N是使用内存映射I / O访问的数据库文件的最大字节数

  1. Memory-Mapped I/O 是如何工作的?

:调用xFetch()方法,返回需要映射DB文件的指针,如果文件已经映射,就直接返回文件指针,就避免了向磁盘进行拷贝操作。

写、更新sqlite的内存映射是只读的,所以SQLite总是在修改页面之前将页面内容拷贝到堆内存中,在数据更改后使用xWrite()方法将数据写回磁盘。

内存映射I/O不会显著改善insert、update的性能,而只是改善了query的性能。

  1. 检测当前使用的sqlite是否支持mmap
#define MWSqliteCanUseMMAPVersion @"3.7.17"

- (BOOL)canUseMMAP {
    NSString *version = [NSString stringWithFormat:@"%s", sqlite3_libversion()];
    NSComparisonResult result = [version compare:MWSqliteCanUseMMAPVersion options:NSNumericSearch];
    if (result == NSOrderedDescending || result == NSOrderedSame) {
        return YES;
    }
    return NO;
}

4.3 高效读写的keyvalue

存储键值对,一般情况下,可以将key直接认为是String类型,value可能有以下几种情况:

typedef NS_ENUM(NSUInteger, PairValueType) {
    FKVPairTypeInt32,
    FKVPairTypeInt64,
    FKVPairTypeFloat,
    FKVPairTypeDouble,
    FKVPairTypeString,
    FKVPairTypeData,
};

iOS中只要遵守<NSCoding>协议的对象都可以作为key

  1. keyvalue的内部建构就是一张散列表(hash表)。
    散列表保证了数据的唯一性,查询操作只消耗一个操作时间。

通常使用散列表进行缓存操作。

  1. C函数- memcpy
    通过对MMKV库和
    FastKV库源码的分析,得到的结论:
    不管写入数据是整形、浮点型、字符串还是Data类型,都会将数据转成Data的形式,最终都将调用memcpy函数将数据拷贝到内存的指定位置上

  2. 和系统方法的性能比较

使用映射方式的效率远远高于系统提供的方法,而且还大大降低了数据的丢失率。

4.4 和sqlite的读写效率的比较

demo已上传至GitHub

假设一个场景:记录用户的所有操作
第一种方式:疯狂地往本地DB中写行为数据。
第二种方式:疯狂地往map文件中写行为数据。

这里使用了sqlite数据库作为,第一种方式的DB。

测试环境是在单线程下,分别往sqlite、文件写入5000条数据消耗的时间:
2018-12-06 23:49:57.168307+0800 mmap[13461:9673080] start
2018-12-06 23:50:03.989115+0800 mmap[13461:9673080] File writeFile 6.820322083s
2018-12-06 23:50:04.793308+0800 mmap[13461:9673080] mapFile writeFile 0.803984248s
2018-12-06 23:50:04.793472+0800 mmap[13461:9673080] end

测试环境是在单线程下,分别读取sqlite、文件的5000条数据消耗的时间:
2018-12-07 00:25:50.003176+0800 mmap[16894:9745111] sqlArr: 5000
2018-12-07 00:25:50.004946+0800 mmap[16894:9745111] File readFile 0.060107278s
2018-12-07 00:25:50.028473+0800 mmap[16894:9745111] mapArr: 5000
2018-12-07 00:25:50.028690+0800 mmap[16894:9745111] mapFile readFile 0.023476192s

可以看出写入速度,文件映射更“快”一点;在读取速度方面,因为文件映射方式在读取文件数据后进行了很多复杂操作(字符串分割、将字符串转成NSDictionary对象等),其效率方面有进一步提高的可能。

五 具体代码分析

疯狂地往map文件中进行读写行为。其核心代码如下:

为了通用,使用C语言写了三个函数。

#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<sys/mman.h>
#import <mach/mach_time.h>


// MapFile 文件映射

// Exit:    fd              代表文件
//          outDataPtr      映射文件的起始位置
//          mapSize         映射的size
//          stat            文件信息
//          return value    返回值为0时,代表映射文件成功
//
int MapFile( int fd , void ** outDataPtr, size_t mapSize , struct stat * stat);
// ProcessFile  写操作

// Exit:    inPathName      文件路径
//          string          需要写入的字符串
//          return value    返回值为0时,代表映射文件成功
//
int ProcessFile( char * inPathName , char * string);
// ReadFile  读操作

// Exit:    inPathName      文件路径
//          outDataPtr      映射文件的起始位置
//          mapSize         映射的size
//          stat            文件信息
//          return value    返回值为0时,代表映射文件成功
//
int ReadFile( char * inPathName , void ** outDataPtr, struct stat * stat);



// ReadFile  读操作

// Exit:    inPathName      文件路径
//          outDataPtr      映射文件的起始位置
//          mapSize         映射的size
//          stat            文件信息
//          return value    返回值为0时,代表映射文件成功
//
int ReadFile( char * inPathName , void ** outDataPtr, struct stat * stat)
{
    size_t originLength;  // 原数据字节数
    int fd;               // 文件
    int outError;         // 错误信息
    
    // 打开文件
    fd = open( inPathName, O_RDWR | O_CREAT, 0 );
    
    if( fd < 0 )
    {
        outError = errno;
        return 1;
    }
    
    // 获取文件状态
    int fsta = fstat( fd, stat );
    if( fsta != 0 )
    {
        outError = errno;
        return 1;
    }
    
    // 需要映射的文件大小
    originLength = (* stat).st_size;
    size_t mapsize = originLength;
    
    // 文件映射到内存
    int result = MapFile(fd, outDataPtr, mapsize ,stat);
    
    // 文件映射成功
    if( result == 0 )
    {
        // 关闭文件
//        close( fd );
    }
    else
    {
        // 映射失败
        outError = errno;
        return 1;
    }
    return 0;
}

// ProcessFile  写操作

// Exit:    inPathName      文件路径
//          string          需要写入的字符串
//          return value    返回值为0时,代表映射文件成功
//
int ProcessFile( char * inPathName , char * string)
{
    size_t originLength;  // 原数据字节数
    size_t dataLength;    // 数据字节数
    void * dataPtr;       // 文件写入起始地址
    void * start;         // 文件起始地址
    struct stat statInfo; // 文件状态
    int fd;               // 文件
    int outError;         // 错误信息
    
    // 打开文件
    fd = open( inPathName, O_RDWR | O_CREAT, 0 );
    
    if( fd < 0 )
    {
        outError = errno;
        return 1;
    }
    
    // 获取文件状态
    int fsta = fstat( fd, &statInfo );
    if( fsta != 0 )
    {
        outError = errno;
        return 1;
    }
    
    // 需要映射的文件大小
    dataLength = strlen(string);
    originLength = statInfo.st_size;
    size_t mapsize = originLength + dataLength;
    
    
    // 文件映射到内存
    int result = MapFile(fd, &dataPtr, mapsize ,&statInfo);
    
    // 文件映射成功
    if( result == 0 )
    {
        start = dataPtr;
        dataPtr = dataPtr + statInfo.st_size;
        
        memcpy(dataPtr, string, dataLength);

        
        //        fsync(fd);
        // 关闭映射,将修改同步到磁盘上,可能会出现延迟
        //        munmap(start, mapsize);
        // 关闭文件
        close( fd );
    }
    else
    {
        // 映射失败
        NSLog(@"映射失败");
    }
    return 0;
}

// MapFile 文件映射

// Exit:    fd              代表文件
//          outDataPtr      映射文件的起始位置
//          mapSize         映射的size
//          stat            文件信息
//          return value    返回值为0时,代表映射文件成功
//
int MapFile( int fd, void ** outDataPtr, size_t mapSize , struct stat * stat)
{
    int outError;         // 错误信息
    struct stat statInfo; // 文件状态
    
    statInfo = * stat;
    
    // Return safe values on error.
    outError = 0;
    *outDataPtr = NULL;
    
    *outDataPtr = mmap(NULL,
                       mapSize,
                       PROT_READ|PROT_WRITE,
                       MAP_FILE|MAP_SHARED,
                       fd,
                       0);
    
    if( *outDataPtr == MAP_FAILED )
    {
        outError = errno;
    }
    else
    {
        // 调整文件的大小
        ftruncate(fd, mapSize);
        fsync(fd);//刷新文件
    }
    return outError;
}

参考文档
官方文档:developer.apple.com/library/arc…

从内核文件系统看文件读写过程:www.cnblogs.com/huxiao-tee/…

Linux C:net.pku.edu.cn/~yhf/linux_…

Memory-Mapped I/O:www.sqlite.org/mmap.html

MMKV:github.com/Tencent/MMK…