一、传统文件访问
unix访问文件的传统方法使用open打开他们,如果有多个进程访问一个文件,则每一个进程在再记得地址空间都包含有该文件的副本,这不必要地浪费了存储空间。下面说明了两个进程同时读一个文件的同一页的情形,系统要将该页从磁盘读到高速缓冲区中,每个进程再执行一个内存期内的复制操作将数据从高速缓冲区读到自己的地址空间。
在工作中偶尔会听到 mmap 这个词。首先从出处上来说,mmap()是在 中定义的一个函数,此函数的作用是创建一个新的 虚拟内存 区域,并将指定的对象映射到此区域。因此,一直以来在工作中聊到的 mmap 其实就是通过 内存映射 的机制来进行文件操作。
由于从定义上来说提到了虚拟内存,接下来先简单说一下 虚拟内存 。
一、虚拟内存
虚拟内存 是一种性能优越的内存管理技术。首先它为程序提供了看似巨大的内存空间,使得一个较大的程序能够运行在较小的内存空间中;同时又为每个进程提供了独立的虚拟地址空间,既简化了内存管理,也保护了每个进程的地址空间。
1.1 局部性
要实现虚拟内存的机制,首先不得不说其基础 局部性 。即在一个较短的时间内,执行的程序指令、访问的数据空间只会局限于某个区域;某条指令、数据在被访问后,不久后可能再次被访问,其附近的指令、数据也可能将被访问。
因此可以 将主存看作磁盘的高速缓存,只在主存中保存当前活动的指令和数据,然后根据需要在主存和磁盘之间来回传输数据 。
1.2 页表
为了在磁盘(虚拟内存)和主存(物理内存)之间更高效地来回传送数据,系统将虚拟内存以及物理内存按照 固定、相同 的大小分割为一个个的页,虚拟内存上的称为 虚拟页 ,而物理内存上的称为 物理页 。
在切分好页后,系统还在主存中用一个 页表 来记录下当前每个虚拟页的情况,包括这个虚拟页是否已被分配使用、是否已被缓存到物理内存中等。简化的页表如下图。
页表中每一项称为 页表项(Page Table Entry) ,PTE 中最重要的两个信息是 有效位 和 地址。
当 有效位 被设置,则表示此虚拟页当前已被缓存到物理内存中,此时 地址 将指向其缓存到的物理页起始位置;
当 有效位 未被设置,如果该虚拟页已分配,则此时 地址 指向其虚拟页在磁盘中的起始位置,否则地址为 null。
1.3 缺页
虚拟内存和物理内存之间的数据传送正是发生于 缺页 。
如上图,当 CPU 首次访问 PTE 1,发现页表中的有效位仍 未被设置 ,即判断出对应的数据(VP1)仍未被缓存到物理内存中,从而触发了 缺页异常 ,将 VP1 从 虚拟内存 拷贝到 物理内存 中。而图中可以发现此时物理内存已经满了,则会通过 LRU 等算法将物理内存中的某个物理页(假设是VP3)替换掉。因此在缺页异常发生后,上面的页表将会更新为下图的样子。
1.4 多进程
上面展示的都是单进程的情况,其实系统为 每个进程 都提供了一个 独立的页表 ,也就是独立的虚拟地址空间,从而能够很好地保护每个进程的数据。
二、mmap
了解完 虚拟内存 ,再回过头来讲一下 mmap ,也就是内存映射 。内存映射是将一个虚拟内存区域与一个磁盘上的对象关联起来,以初始化这个虚拟内存区域的内容的过程。
2.1 基础概念
先讲下内存映射里的一些概念。
映射对象类型
虚拟内存区域可以映射以下两种类型的对象:
- 普通文件:即磁盘文件中的一块 连续 的区域。
- 匿名文件:一个由内核创建的全为 二进制零 的文件。当CPU首次引用此区域时,将以二进制零填充到页表中。
共享对象
在上一节 虚拟内存 可得知,系统为每个进程提供了单独的页表,从而也实现了进程间数据访问权限的管理以及数据的保护。但同时,通过内存映射的机制,将对象作为 共享对象 映射到两个进程的虚拟内存亦可实现数据的共享。
2.2 使用方式
然后先讲下如果我们应该如何通过内存映射的方式来访问文件。 mmap() 的函数定义如下:
| |
其中参数的含义分别是:
- start: 期望的进程虚拟内存起始位置,填 NULL 时由内核来决定起始位置
- length: 需要映射的对象字节大小
- fd: 文件句柄
- offset: 距离文件开始处的偏移量
- prot: 映射对象的访问权限,用于可指定是否可读写、执行。
- flags: 映射对象的类型,例如指定是映射普通文件还是请求二进制零、映射共享对象还是私有的写时复制对象等。
前4项地含义可通过下图更直观地了解:
而在 iOS 开发中,当我们需要的数据类型是 NSData 时,可以更简便地通过调用以下方法
| |
并传入 NSDataReadingMappedIfSafe 来使用内存映射方式读取文件。
2.3 读取过程
当我们通过 mmap 读取文件时,将经历以下步骤:
- 在当前用户虚拟内存空间中分配一片 指定映射大小 的虚拟内存区域。
- 将磁盘中的文件映射到这片内存区域,等待后续 按需 进行页面调度。
- 当CPU真正访问数据时,触发 缺页异常 将所需的数据页从磁盘拷贝到物理内存,并将物理页地址记录到页表。
- 进程通过页表得到的物理页地址访问文件数据。
而作为对比,当通过 标准IO 读取一个文件时,步骤为:
- 将 完整 的文件从磁盘拷贝到物理内存(内核空间)。
- 将完整文件数据从 内核空间 拷贝到 用户空间 以供进程访问。
2.4 优劣
通过上面 mmap 与 标准IO 的对比,不难发现调用mmap具有以下的优势:
- 物理内存占用延后:数据直到真正被使用时才会发生拷贝。
- 物理内存占用减少:对于同一份文件无需在物理内存中存放两份,且文件区被划分成片,缺页异常时只将所需的页拷贝到物理内存。
- 方便实现跨进程数据交互、共享:当映射到虚拟内存的对象被设置为共享对象,则不同进程对映射对象的写操作相互可见。
然而也能发现 mmap 存在以下 劣势 :
- 无法映射变长文件:调用mmap()时需指定要映射的文件位置和需要映射的大小范围。
- 如果需要映射的文件过大,会导致过度占用虚拟内存:在调用mmap()后,虚拟内存空间就创建了,此时虽然不会占用物理内存,但依然会占用虚拟内存。此时可考虑只映射文件中自己需要的部分。
由此,当我们需要访问一个比较大的文件,尤其是当我们只需要访问其中的一小部分数据的时候,我们可以尝试通过 mmap 的方式来进行访问,减少由于该文件过大而对物理内存的过度占用。
image
二、共享内存映射
现在考虑林一种处理方法:进程A和进程B都将该页映射到自己的地址空间,当进程A第一次访问该页中的数据时,它生成一个缺页终端,内核此时读入这一页到内存并更新页表使之指向它,以后,当进程B访问同一页面而出现缺页中断时,该页已经在内存,内核只需要将进程B的页表登记项指向次页即可。
image
三、mmap及其相关系统调用
mmap()系统调用使得进城之间通过映射同一个普通文件实现共享内存。普通文件被映射到进程地址空间后,进程可以像访问普通内存一样对文件进行访问,不必再调用read,write
等操作。
mmap()系统调用形式如下:
mmap的作用是映射文件描述符和指定文件的(off_t off)区域至调用进程的(addr,addr *len)的内存区域,如下图所示:
image
参数:
fd:为即将映射到进程空间的文件描述字,一般由open()返回,同时,fd可以指定为-1,此时须指定flags参数中的MAP_ANON,表明进行的是匿名映射(不涉及具体的文件名,避免了文件的创建及打开,很显然只能用于具有亲缘关系的进程间进行通信)。
len:是映射到调用进程地址空间的字节数,它从被映射文件开头offset个字节开始算起。
prot:指定空想内存的访问权限。可取如下几个值的或:PROT_READ(可读)、PROT_WRITE(可写)、PROT_EXEC(可执行)、PROT_NONE(不可访问)。
flag:由以下几个常值指定:MAP_SHARED、MAP_PRIVATE、MAP_FIXED,其中,MAP_SHARED,MAP_PRIVATE必选其一,而MAP_FIXED则不推荐使用。
offset:一般设为0,表示从文件头开始映射。
addr:指定文件应被映射到进程空间的起始地址,一般被指定一个空指针,此时选择起始地址的任务留给内核来完成。
函数的返回值为最后文件映射到进程空间的地址,进程可直接操作起始地址为该值的有效地址。
四、mmap基础用例
//测试文件 data.txt 后面的程序也要用到
aaaaaaaaa
bbbbbbbbb
cccccccccccc
ddddddddd
1、通过共享内存映射的方式修改文件
#include <sys/mman.h>
#include <sys/stat.h>
#include<fcntl.h>
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<error.h>
int main(int argc, char * argv[])
{
int fd, nread;
struct stat sb;
char *mapped;
//打开文件
if((fd = open(argv[1], O_RDWR)) < 0){
perror("open") ;
}
//获取文件的属性
if((fstat(fd, &sb)) == -1 ){
perror("fstat") ;
}
//将文件映射至进程的地址空间
if((mapped = mmap(NULL, sb.st_size, PROT_READ|\
PROT_WRITE, MAP_SHARED, fd, o)) ==(void*) -1){
perror("mmap") ;
}
//修改一个字符,同步到磁盘文件
mapped[20] = '9';
if((msync((void *)mapped, sb.st_size, MS_SYNC)) == -1){
perror("msync") ;
//释放存储映射区
if((munmap((void *)mapped,sb.st_size)) == -1){
perror("munmap");
}
return 0;
}
2 私有映射无法修改文件
//将文件私有映射到进程的地址空间
if((mapped = (char *)mmap(NULL,sb.st_size,PROT_READ|
PROT_WRITE, MAP_PRIVATE, fd, 0))==(void *)-1){ perror("mmap");
五、使用共享内存映射实现两个进程之间的通信
两个程序映射到同一个文件到自己的地址空间,进程A先运行,每个两秒读取映射区域,看是否发生变化,进程B后运行,它修改映射区域,然后退出,此时进程A能够观察到存储映射区的变化
进程A的代码:
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <error.h>
int main(int argc, char **argv)
{
int fd, nread;
struct stat sb;
char *mapped;
/* 打开文件 */
if ((fd = open(argv[1], O_RDWR)) < 0) {
perror("open");
}
/* 获取文件的属性 */
if ((fstat(fd, &sb)) == -1) {
perror("fstat");
}
/* 将文件映射至进程的地址空间 */
if ((mapped = (char *)mmap(NULL, sb.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)) == (void *)-1) {
perror("mmap");
}
/* 文件已在内存, 关闭文件也可以操纵内存 */
close(fd);
/* 每隔两秒查看存储映射区是否被修改 */
while (1) {
printf("%s\n", mapped);
sleep(2);
}
return 0;
}
进程B的代码
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <error.h>
int main(int argc, char **argv)
{
int fd;
struct stat sb;
char *mapped;
/* 打开文件 */
if ((fd = open(argv[1], O_RDWR)) < 0) {
perror("open");
}
/* 获取文件的属性 */
if ((fstat(fd, &sb)) == -1) {
perror("fstat");
}
/* 私有文件映射将无法修改文件 */
if ((mapped = (char *)mmap(NULL, sb.st_size, PROT_READ
|PROT_WRITE,MAP_PRIVATE, fd, 0)) == (void*)-1) {
perror("mmap");
}
/* 映射完后, 关闭文件也可以操纵内存 */
close(fd);
/* 修改一个字符 */
mapped[20] = '9';
return 0;
}
六、通过匿名映射实现父子进程通信
#include <sys/mman.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#define BUF_SIZE 100
int main(int argc, char** argv)
{
char *p_map;
/* 匿名映射,创建一块内存供父子进程通信 */
p_map = (char *)mmap(NULL, BUF_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if(fork() == 0) {
sleep(1);
printf("child got a message: %s\n", p_map);
sprintf(p_map, "%s", "hi, dad, this is son");
munmap(p_map, BUF_SIZE); //实际上,进程终止时,会自动解除映射。
exit(0);
}
sprintf(p_map, "%s", "hi, this is father");
sleep(2);
printf("parent got a message: %s\n", p_map);
return 0;
}
七、对mmap()返回地址的访问
linux采用的是页式管理机制。对于用mmap()映射普通文件来说,进程会在自己的地址空间新增一块空间,空间大小由mmap()的len参数指定,注意,进程并不一定能够对全部新增空间都能进行有效访问。进程能够访问的有效地址大小取决于文件被映射部分的大小。简单的说,能够容纳文件被映射部分大小的最少页面个数决定了进程从mmap()返回的地址开始,能够有效访问的地址空间大小。超过这个空间大小,内核会根据超过的严重程度返回发送不同的信号给进程。可用如下图示说明:
image
总结一下就是,文件大小,mmap()的参数len都不能决定进程能访问的大小,而是容纳文件被映射部分的最小页面数决定进程能访问的大小,下面看一个实例: