一 Apple的解释
File-System Performance Guidelines官方在文件系统操作中也简单介绍了mmap,提及了其定义、适用场景、说明、示例。
1. 1 定义
文件映射是将磁盘上的文件映射到进程的虚拟内存中,文件一旦映射,程序访问文件该文件,就好像该文件完全驻留在内存中一样。当根据映射文件指针读取数据时,将会返回内核中的数据。
1.2 适用场景和说明
即使文件映射具有极大的性能优势,它也不能在任何场景下使用。
需要记住的是,被映射的文件是和系统库、应用程序的代码、分配的内存 共享进程空间的。大部分程序大约有2G的虚拟内存(这根据他们的加载库的数量),为了映射文件,就必须有足够大的可用的地址区域来装载这个文件,如果应用程序的虚拟内存空间是碎片化的,或映射的是一个巨大的文件,那这个可用地址的寻找是非常困难的。
在映射文件前,确保理解文件的使用模式。可以使用Shark和fs_usage等工具可以帮忙找到访问文件的位置、这些操作需要的时间。对于操作花费的时间超过预期,那就需要查看代码,并决定是否可以使用文件映射。
在这些情况下建议使用文件映射:
- 有一个large file,你需要随时或者多次访问其内容。
- 有一个small file,你需要一次读入,频繁访问。这最适合大小不超过几个虚拟内存页面的文件。
- 你希望缓存一个文件的特定部分,那就不需要映射全部的文件,这样可以为其他数据留出更大的磁盘空间。
在这些情况下不建议使用:
- 映射文件后,只读取了一次。
- 文件是几百万字节或是更多(映射一个大文件会快读填充虚拟内存空间,另外的,如果程序运行了一段时间,其内存空间是碎片化的,或是没有可用的连续的虚拟内存地址空间)
- 是一个移动磁盘上的文件
- 是一个网络驱动上的文件
当随机访问一个非常大的文件的时候,最好只映射文件的一小部分,因为映射大文件可能会占用巨大的虚拟地址空间。单个进程的虚拟地址空间目前限制为4千兆字节,这些空间的部分被系统库所占用着。如果尝试映射一个大文件,可能会映射不成功,因为没有充足的空间去映射。
因为是在iOS系统,磁盘都是焊在手机上的,所有不会出现移动硬盘和使用云磁盘的情况。这里一句话概括:苹果不建议我们映射移动磁盘或者云驱动上的文件,因为映射失败会出现很多的问题(阻塞线程、系统奔溃等)。
二 基础理论
文件读写基本流程,APP在进行文件的磁盘读写操作,是需要通过系统调用从用户空间切换至内核空间,操作完成后从内核空间回到用户空间,再进行用户代码。所以mmap可以减少磁盘和内存之间的转换,提高了往磁盘读写的性能。
以下是文件mmap图:

总结下:通过 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_SHARED或MAP_PRIVATE。
fd为open()返回的文件描述词,代表欲映射到内存的文件。
offset为文件映射的偏移量,通常设置为0,代表从文件最前方开始对应,offset必须是分页大小的整数倍。可以简单理解为被映射对象内容的起点。
返回值
若映射成功则返回映射区的内存起始地址,否则返回MAP_FAILED(-1),错误原因存于errno:
EBADF参数fd 不是有效的文件描述词
EACCES存取权限有误。如果是MAP_PRIVATE 情况下文件必须可读,使用MAP_SHARED则要有PROT_WRITE以及该文件要能写入。
EINVAL参数start、length或offset有一个不合法。
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 */
start或length参数不合法。
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_CREAT与O_EXCL同时设置,并且欲打开的文件为符号连接,则会打开文件失败。
O_NOCTTY如果欲打开的文件为终端机设备时,则不会将该终端机当成进程控制终端机。
O_TRUNC若文件存在并且以可写的方式打开时,此旗标会令文件长度清为0,而原来存于该文件的资料也会消失。
O_APPEND当读写文件时会从文件尾开始移动,也就是所写入的数据会以附加的方式加入到文件后面。
O_NONBLOCK以不可阻断的方式打开文件,也就是无论有无数据读取或等待,都会立即返回进程之中。
O_NDELAY同O_NONBLOCK。
O_SYNC以同步的方式打开文件。
O_NOFOLLOW如果参数pathname 所指的文件为一符号连接,则会令打开文件失败。
O_DIRECTORY如果参数pathname所指的文件并非为一目录,则会令打开文件失败。(Linux2.2以后特有的旗标,以避免一些系统安全问题。)
mode 有下列数种组合,只有在建立新文件时才会生效,此外真正建文件时的权限会受到umask值所影响,因此该文件权限应该为(mode-umaks)。
S_IRWXU 00700权限,代表该文件所有者具有可读、可写及可执行的权限。
S_IRUSR 或S_IREAD,00400权限,代表该文件所有者具有可读取的权限。
S_IWUSR 或S_IWRITE,00200 权限,代表该文件所有者具有可写入的权限。
S_IXUSR 或S_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_CREAT和O_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所指的文件数据,由系统缓冲区写回磁盘,以确保数据同步。参数说明
fd为open()返回的文件描述词,代表欲映射到内存的文件。
返回值 成功则返回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所指的结构体中。通常的,获取文件大小可以通过这个属性进行获取。参数说明
fildes为open()返回的文件描述词,代表欲映射到内存的文件。
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文本文件中字符串的读写。
测试环境是在单线程下,分别使用磁盘读写、文件映射的方式来读写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的读效率
- 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访问的数据库文件的最大字节数
读:调用xFetch()方法,返回需要映射DB文件的指针,如果文件已经映射,就直接返回文件指针,就避免了向磁盘进行拷贝操作。
写、更新:sqlite的内存映射是只读的,所以SQLite总是在修改页面之前将页面内容拷贝到堆内存中,在数据更改后使用xWrite()方法将数据写回磁盘。
内存映射I/O不会显著改善insert、update的性能,而只是改善了query的性能。
- 检测当前使用的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
- keyvalue的内部建构就是一张散列表(hash表)。
散列表保证了数据的唯一性,查询操作只消耗一个操作时间。
通常使用
散列表进行缓存操作。
C函数-
memcpy
通过对MMKV库和
FastKV库源码的分析,得到的结论:
不管写入数据是整形、浮点型、字符串还是Data类型,都会将数据转成Data的形式,最终都将调用memcpy函数将数据拷贝到内存的指定位置上。和系统方法的性能比较
使用映射方式的效率远远高于系统提供的方法,而且还大大降低了数据的丢失率。
4.4 和sqlite的读写效率的比较
假设一个场景:记录用户的所有操作
第一种方式:疯狂地往本地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