iOS中mmap的应用
mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。
mmap较之于系统read/write来说有着速度上的优势,具体原因是为何,我们来探究一下。
一、基础知识
1. 虚拟内存
对于计算机程序来说,当该程序需要运行时,需要把该程序相关的代码、数据等信息全部装入内存中。在早期的计算机中,是没有虚拟内存说法的,这样就会造成以下一些问题:
1) 各个进程间地址不隔离,无法做到权限保护
由于所有进程的信息都直接装入内存中,所有进程都可以对内存进行访问,在该过程中,一个进程就可以访问到其他进程在内存中的数据,同时其他进程也可以访问到该进程在内存中的数据。这种情况下无法保证程序的数据安全。
2) 内存使用效率低
当切换至新的进程时,如果内存不足,需要将其他进程的信息全部拷贝到硬盘中,然后再将新进程的内容拷贝到内存中。如果进程过多,那么会造成大量的数据在内存中的装入装出,使得内存使用效率低下。
3) 程序运行地址不确定
由于每次装入进程数据都是随机的,所以对于程序来说,运行的地址是不确定的。
由于上面一系列缺点,进而引进了虚拟内存
的概念:
系统会为每一个进程创建相等的内存空间(对于32位系统,寻址地址为4字节,对应空间大小为4G)。
注意:该内存空间是虚拟假设的,而不是实际存在的。
同时系统将该内存空间分为两部分:内核空间
、用户空间
。如图:
这样,对于进程来说,它能访问的内存空间就只有属于它的虚拟地址,同时对于进程来说,每次运行的地址都是确定的。至于内存使用效率,则交给操作系统去处理以及优化。
2. 虚拟内存的工作原理
当然,对于进程来说,要实实在在的跑起来,不能照着虚拟内存对着空气运行,必须运行在实际的物理内存上,那么如何由虚拟内存对应至物理内存呢?
页
在现代操作系统中,对内存操作的最小基本单位为页
:虚拟内存被分为多个相等大小的虚拟页,物理内存也被分为多个相等大小的物理页,且虚拟页与物理页也保持相等,通常大小都为4KB。
对应关系
虚拟页需要与物理页进行一一映射,确认映射关系后,操作系统将虚拟页所需数据缓存到物理页中。根据该原理,虚拟页存在以下三种状态:
- 未映射:该虚拟页在实体物理内存中没有对应映射。
- 未缓存:该虚拟页在实体物理内存中有对应的物理页一一映射,但是操作系统还未将物理页中写入虚拟页所需数据。
- 已缓存:该虚拟页在实体物理内存中有对应的物理页一一映射,同时操作系统将虚拟页所需数据写入了对应物理页中。
页表
那么操作系统在执行进程时,如何知道虚拟内存中某个虚拟页所在状态呢?此时就需要页表来保存虚拟页状态了。
页表本质上是一个数组结构,其中:
- 每一项对应一个虚拟页状态,数组的索引对应虚拟页号。
- 对应值若为null,则表示该虚拟页未进行映射,即该虚拟页处于未映射状态。
- 对应值若不为null,则表示该虚拟页进行了映射;此时,若该值第一位为0,则表示对应物理页还未缓存数据,即虚拟页处于未缓存状态;若该值第一位为1,则表示对应物理页已经缓存入数据,即虚拟页处于已缓存状态,同时剩余位存储对应物理页的页号。
如图:
工作流程
进程执行时,当访问某个虚拟地址的数据时,工作流程如下:
- 系统根据虚拟地址找到该地址所在的虚拟页。
- 系统根据页表,找到该虚拟页页号对应在页表中的值。
- 判断页表中该值的有效位,若为1,则找到对应物理页,读出其中内容,进入第6步;若为0,则发生缺页异常,调起内核缺页异常处理程序,进入第4步。
- 内核会将对应物理页的数据刷新到磁盘文件,然后将虚拟页所需的数据从磁盘这哪个装入物理页中,同时改变也变中有效位为1。
- 缺页异常处理完毕,返回中断前指令,即跳至第3步。
- 数据读取完毕。
二、 系统read/write方法调用
常见的读写文件的方式是调用read/write方法来完成。下面我们来介绍一下该方法的工作流程。
前面我们讲到了系统会将虚拟空间分为内核空间和用户空间,这样做的原因是什么呢?
操作系统的主要功能是为管理硬件资源和为应用程序开发人员提供良好的环境,但是计算机系统的各种硬件资源是有限的,因此为了保证每一个进程都能安全的执行。处理器设有两种模式:用户模式
与内核模式
。一些容易发生安全问题的操作都被限制在只有内核模式下才可以执行,例如I/O操作,修改基址寄存器内容等。而连接用户模式和内核模式的接口称之为系统调用。
应用程序代码运行在用户模式下,当应用程序需要实现内核模式下的指令时,先向操作系统发送调用请求。操作系统收到请求后,执行系统调用接口,使处理器进入内核模式。当处理器处理完系统调用操作后,操作系统会让处理器返回用户模式,继续执行用户代码。
对应的,用户模式可以操作用户空间,而内核空间则只能在内核模式下操作。
我们知道了只有在内核模式下才能进行I/O操作,那么流程是如何呢?
读文件
- 用户模式调用库函数进行文件读入。
- 系统切换至内核模式进行文件读入。
- 系统将文件读入至内核空间。
- 系统由内核模式切换为用户模式,同时将文件内容读入用户空间。
- 进程操作用户空间中文件数据。
示意图如下:
由示意图我们可知,在调用系统库读取文件时,需要经历两次文件的拷贝:将访问文件拷贝至进程的内核空间;将内核空间的数据拷贝至用户空间。
同理,当调用系统库的write时,需要先将用户空间的数据拷贝至内核空间,然后将内核空间的数据写入到物理磁盘中。
那么,mmap为何要比调用系统read/write要快呢?我们来看一下mmap的工作流程。
三、 mmap
mmap通过建立用户空间与文件地址的映射关系,从而到达通过操作用户空间地址来操作文件数据。具体流程为:
- 进程在用户模式下启动映射过程,系统在用户空间中寻找一段满足文件大小要求的连续的虚拟空间。
- 系统由用户模式切换至内核模式,从"已打开的文件集"中该文件的文件结构体(该结构体维护该文件的相关信息)找到对应的文件地址,并将文件地址与虚拟地址进行映射。
- 进程对该文件进行访问,即对这段连续的虚拟地址进行访问,由于此时只有虚拟地址与文件地址的映射关系,而没有虚拟地址与物理内存的映射,所以会引发缺页异常,此时内核将文件数据拷贝至对应缺页的物理内存中,至此进程可以完成对文件的读写操作了。
示意图如下:
由该示意图我们可知,通过mmap来访问文件数据,只会存在一次拷贝:将文件数据拷贝至用户空间的一段连续地址中,且该拷贝不会发生在打开文件时,只会在真正访问文件数据时才会发生。
另外,mmap打开的文件在写入数据时,被修改过的脏数据并不会立刻更新至文件中,内核会在固定刷新时间将脏页面进行更新。我们可以调用msync
来强制将脏页面的数据刷新至文件中。
除此之外,mmap也可以实现文件共享的功能。
在进程建立好虚拟地址与文件地址的映射后,当进程访问数据时,由于虚拟地址没有对应的物理内存映射,所以会造成缺页异常,内核此时会为该虚拟地址映射对应的物理内存。内核在给不同进程映射系统文件虚拟内存地址时,会映射至同一物理内存地址,这样就达到了不同进程通过mmap访问同一文件时,访问物理内存空间一致的效果,进而实现了文件共享功能。
四、 iOS中使用mmap读写文件
其实在Apple官方文档中已经给出了关于mmap在iOS中使用的注意事项以及方式。具体内容可见 Apple关于mmap文档
其中给出了mmap适用场景:
- 需要多次随机访问一个大文件时。
- 需要将一个小文件一次性读入内存并且经常性的访问其内容。该文件大小最好不要超过几个虚拟内存页。
- 需要将一个文件的特定部分进行缓存。mmap消除了缓存所有数据的需求,可以让系统由更多空间去缓存其余数据。
同时需要注意的是,当随机访问一个大文件时,最好在同一时间只映射其中一部分数据,因为全部映射文件会消耗大量内存。当文件足够大时,系统会强制使用其他内存页来加载文件。如果映射多个文件的话,会让内存加载更加混乱。所以不适合的场景如下:
- 需要从开始到结束顺序的访问一个文件,且只访问一次。
- 当文件足够大时,mmap可能会导致分页。对于大文件的顺序读取,应当禁止磁盘缓存以及使用小内存区域读取文件。
- 如果一个文件大小超过虚拟空间,那么无法正常映射。
- 文件在可移动驱动器上。
- 文件在网络驱动器上。
以上就是相关的一些注意事项,接下来介绍一下有关API。
1. mmap
/**
将所需文件进行映射
@param addr 指定映射起始位,通常为NULL,由系统指定
@param length 指定将文件中多少长度的数据映射至内存,不能超过文件结尾
@param port 映射区的保护方式,可通过|来指定多种选项,PROT_EXEC:映射区可被执行;PROT_READ:映射区可被读取;PROT_WRITE:映射区可被写入;PROT_NONE:映射区不能存取
@param flags 映射区特性,可通过|来指定多种选项,MAP_FILE:默认;MAP_SHARED:对映射区域的写入数据会复制回文件(由守护进程自动完成), 且允许其他映射该文件的进程共享;MAP_PRIVATE: 对映射区域的写入操作会产生一个映射的复制(copy-on-write), 对此区域所做的修改不会写回原文件
@param fd 文件描述符,由open函数返回
@param offset 映射开始偏移量,必须是分页大小的整数倍
@return 成功则返回映射区起始地址,失败则返回MAP_FAILED(-1)
*/
void * mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
2. munmap
/**
解除映射关系
@param addr 需要解除映射关系的映射位置,由mmap函数返回
@param len 需要解除映射关系的长度
@return 解除是否成功
*/
int munmap(void *addr, size_t len);
3. msync
/**
手动强制刷新数据
@param addr 需要刷新的文件地址,由mmap函数返回
@param len 需要刷新的文件长度
@param flags 刷新参数,MS_ASYNC:调用会立即返回,不等到更新的完成;MS_SYNC:调用会等到更新完成之后返回;MS_INVALIDATE:在共享内容更改之后,使得文件的其他映射失效,从而使得共享该文件的其他进程去重新获取最新值
@return 刷新是否成功
*/
int msync(void *addr, size_t len, int flags);
以下例子是将为一个文本文件追加特定字符的例子:
///映射文件
int MapFile(const char *inPathName, void **outDataPtr, size_t *outDataLength)
{
int outError;
int fileDescriptor;
struct stat statInfo;
outError = 0;
*outDataPtr = NULL;
*outDataLength = 0;
fileDescriptor = open(inPathName, O_RDWR, 0);
if (fileDescriptor < 0) {
outError = errno;
} else {
//获取文件信息
if (fstat(fileDescriptor, &statInfo) != 0) {
outError = errno;
} else {
//修改文件信息,将文件长度增加6个字节
ftruncate(fileDescriptor, statInfo.st_size + 6);
fsync(fileDescriptor);
//映射文件,映射大小为增加6个字节之后的大小
*outDataPtr = mmap(NULL,
statInfo.st_size + 6,
PROT_READ | PROT_WRITE,
MAP_FILE | MAP_SHARED,
fileDescriptor,
0);
if (*outDataPtr == MAP_FAILED) {
outError = errno;
} else {
*outDataLength = statInfo.st_size;
}
}
close(fileDescriptor);
}
return outError;
}
//追加字符
void ProcessFile(const char *inPathName)
{
size_t dataLength;
void *dataPtr;
void *start;
if (MapFile(inPathName, &dataPtr, &dataLength) == 0) {
start = dataPtr;
//移动至原先文本original末尾
dataPtr = dataPtr + 8;
memcpy(dataPtr, "append", 6);
//解除映射,根据需求可以调用msync函数
munmap(start, 14);
}
}
int main(int argc, const char * argv[]) {
NSString *path = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject;
NSString *str = @"original";
NSError *error;
NSString *filePath = [NSString stringWithFormat:@"%@/text.txt",path];
[str writeToFile:filePath atomically:YES encoding:NSUTF8StringEncoding error:&error];
if (error) {
NSLog(@"%@",error);
}
return 0;
}
关于mmap在iOS中文件读写的应用,可以参考腾讯开源的MMKV框架,该框架使用mmap实现了快速的对数据进行本地化的功能。