iOS mmap 洪荒之力

1,967 阅读14分钟

mmap是文件映射,此为系统函数中一个加载文件的函数。它比常规使用readwrite函数来读写文件的效率要高出很多。其主要的应用场景是保证日志的完整性。 一般的,由系统操作readwrite函数,将内存中数据写入至磁盘上,在内存不足、程序crash等异常情况下会使得内存数据丢失。但如果使用mmap方式保存数据,其效果相当于数据直接被保存至磁盘上,减少了数据从内存写入磁盘的这步操作,这样,程序在异常情况下,数据就不会丢失。

一 Apple的解释

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

具体内容
定义 文件映射是将磁盘上的文件映射到进程的虚拟内存中,文件一旦映射,程序访问文件该文件,就好像该文件完全驻留在内存中一样。当根据映射文件指针读取数据时,将会返回内核中的数据。
适用场景 (1)有一个large file,你需要随时或者多次访问其内容。
(2)有一个small file,你需要一次读入,频繁访问。这最适合大小不超过几个虚拟内存页面的文件。
(3)缓存一个文件的特定部分,那就不需要映射全部的文件,这样可以为其他数据留出更大的磁盘空间。
不适场景 (1)映射文件后,只读取了一次。
(2)文件是几百万字节或是更多(映射一个大文件会快读填充虚拟内存空间,另外的,如果程序运行了一段时间,其内存空间是碎片化的,或是没有可用的连续的虚拟内存地址空间)。
(3)是一个移动磁盘上的文件。
(4)是一个网络驱动上的文件
注意要点 (1)当随机访问一个非常大的文件的时候,最好只映射文件的一小部分,因为映射大文件可能会占用巨大的虚拟地址空间。单个进程的虚拟地址空间目前限制为4千兆字节,这些空间的部分被系统库所占用着。如果尝试映射一个大文件,可能会映射不成功,因为没有充足的空间去映射。
(2)在iOS系统,磁盘都是焊在手机上的,所有不会出现移动硬盘和使用云磁盘的情况。这里一句话概括:苹果不建议我们映射移动磁盘或者云驱动上的文件,因为映射失败会出现很多的问题(阻塞线程、系统奔溃等)。

二 基础理论

主要参考:www.cnblogs.com/huxiao-tee/…

2.1 常规文件操作、mmap操作文件

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

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

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

2.2 mmap操作文件

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

磁盘文件映射到内存.png

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

三 Linux C函数

主要使用了三类函数:内存控制函数文件操作函数文件权限控制。其具体功能如下: 如需知道具体的参数、返回值、error code等信息,进入http://net.pku.edu.cn/~yhf/linux_c/

3.1 内存控制

函数 定义 说明
mmap void *mmap(void *start,size_t length,int prot,int flags,int fd,off_t offsize); 用来将某个文件内容映射到内存中,对该内存区域的存取即是直接对该文件内容的读写。
munmap int munmap(void *start,size_t length); 进程结束或利用exec相关函数来执行其他程序时,映射内存会自动解除,但关闭对应的文件描述符时不会解除映射。
memcpy void * memcpy (void * dest ,const void *src, size_t n); 用来拷贝src所指的内存内容前n个字节到dest所指的内存地址上。

3.2 文件操作

函数 定义 说明
open struct stat statInfo; 参数pathname 指向欲打开的文件路径字符串
close int close(int fd); 当使用完文件后若已不再需要则可使用close()关闭该文件,二close()会让数据写回磁盘,并释放该文件所占用的资源。
fsync int fsync(int fd); 负责将参数fd所指的文件数据,由系统缓冲区写回磁盘,以确保数据同步。

3.3 文件权限控制

函数 定义 说明
stat结构体 struct stat { } 包含了文件的各种信息。
fstat int fstat(int fildes,struct stat *buf); 根据fd获取文件状态,并将文件状态复制到参数buf所指的结构体中。通常的,获取文件大小可以通过这个属性进行获取。
ftruncate int ftruncate(int fd,off_t length); ftruncate()会将参数fd指定的文件大小改为参数length指定的大小。如果原来的文件大小比参数length大,则超过的部分会被删去。

以下整理了部分C函数返回值: 0,代表函数执行成功;-1,代表函数执行失败。当执行失败时,会将错误信息存储在errno中。 以下罗列了一些函数执行失败时候errno信息。

函数 errno
open EEXIST 参数pathname 所指的文件已存在,ENOMEM核心内存不足 ,等等
close EBADF 参数fd 非有效的文件描述词或该文件已关闭。
mmap EBADF 参数fd 不是有效的文件描述词,EACCES 存取权限有误,等等
munmap EINVAL参数输入有误,等等
ftruncate EBADF 参数fd文件描述词为无效的或该文件已关闭, 等等
fsync 具体查看errno信息

进入文件路径:/sys/errno.h,可以了解更多errno。

四 实践

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

假设场景:针对磁盘上的text.txt文件,进行文本(字符串)的读写。 第一种方式:疯狂地往磁盘文本文件中写入数据。 第二种方式:mmap磁盘文本文件,再疯狂地执行写入操作。

其测试结果:在单线程下,分别使用磁盘读写和文件映射的方式来读写5000条数据:
2018-12-03 15:15:24.916802+0800 mmap[21155:4210019] start 
2018-12-03 15:15:31.893068+0800 mmap[21155:4210019] diskFile 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时间,使用mmap文件的方式只用了约1.5s。demo地址

4.2 sqlite和mmap

通过官方文档中文文档,做出以下分析:

  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()

需要注意的是:

  1. SQLite从版本3.7.17(2013-05-20)开始,选择使用内存映射I/O和sqlite3_io_methods上的新xFetch()和xUnfetch()方法直接访问磁盘内容。
  2. 早期的iOS版本的存在一些bug,SQLite在编译层就关闭了在iOS上对mmap的支持,并且在16年1月才重新打开。
  3. 参数N是使用内存映射I / O访问的数据库文件的最大字节数。

sqlite使用mmap,进行读操作是调用xFetch()方法,返回需要映射DB文件的指针,如果文件已经映射,就直接返回文件指针,就避免了向磁盘进行拷贝操作。进行 写、更新操作是在数据更改后使用xWrite()方法将数据写回磁盘。

可以得出结论:sqlite和mmap结合,在iOS上并没有多大卵用,内存映射I/O不会显著改善insert、update的性能,而只是改善了query的性能。

4.3 高效读写的keyvalue

使用键值对进行存储时,通常会将key直接认为是String类型,value可能有以下几种情况:

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

通过几个开源的库,通过对MMKV库和FastKV库源码的分析 ,得到的结论: 不管写入数据是整形、浮点型、字符串还是Data类型,都会将数据转成字符串或是data的形式,最终都将调用memcpy函数将数据拷贝到内存的指定位置上,进行字节的拼接操作。

和系统提供的键值存储方法进行比较,使用映射方式的效率远远高于系统提供的方法,而且还可以降低数据的丢失率。

4.4 和sqlite的读写效率的比较

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

测试环境是在单线程下,分别往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] diskFile 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] diskFile 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对象等),其效率方面有进一步提高的可能。demo地址

五 代码分析

根据自身对mmap理论和应用场景的理解,使用mmap的主要流程:

映射文件写数据
                ↘ 读数据

使用系统函数,封装了三个函数,分别为文件映射函数写函数读函数

文件映射函数: 根据文件描述,映射文件到内存。 写函数:写入数据。 读函数:读取文件所有数据。

导入头文件:

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

函数声明:

// MapFile 文件映射

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


// WriteFile  写操作

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


// ReadFile  读操作

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

函数实现:

// ReadFile  读操作

// Param:   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;
}

// WriteFile  写操作

// Param:   inPathName      文件路径
//          string          需要写入的字符串
//          return value    返回值为0时,代表映射文件成功
//
int WriteFile( 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 文件映射

// Param:   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;
    
    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;
}

mmap读写demo

微博:@王大吉Rock
简书:王大吉Rock