社区微信群开通啦,扫一扫抢先加入社区官方微信群
社区微信群
声明:本文绝大多数内容来自于深度分析mmap:是什么 为什么 怎么用 性能总结
mmap是一种内存映射文件的方法,即将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。如下图:
由上图可以看出,进程的虚拟地址空间,由多个虚拟内存区域构成。虚拟内存区域是进程的虚拟地址空间中的一个同质区间,即具有同样特性的连续地址范围。上图中所示的text数据段(代码段)、初始数据段、BSS数据段、堆、栈和内存映射,都是一个独立的虚拟内存区域。而为内存映射服务的地址空间处在堆栈之间的空余部分。
linux内核使用vm_area_struct结构来表示一个独立的虚拟内存区域,由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。各个vm_area_struct结构使用链表或者树形结构链接,方便进程快速访问,如下图所示:
vm_area_struct结构中包含区域起始和终止地址以及其他相关信息,同时也包含一个vm_ops指针,其内部可引出所有针对这个区域可以使用的系统调用函数。这样,进程对某一虚拟内存区域的任何操作需要用要的信息,都可以从vm_area_struct中获得。mmap函数就是要创建一个新的vm_area_struct结构,并将其与文件的物理磁盘地址相连。
mmap内存映射的实现过程,总的来说可以分为三个阶段:
(一)进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
(二)调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表(每个进程有自己的文件描述符表)中找到对应的文件描述符(由在调用用户空间mmap前的open操作完成映射文件的打开),通过文件描述符,链接到内核“已打开文件集”(文件集是存储在内核空间的由系统里的所有进程共享的数据结构)中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。
struct file_operations是一个字符设备把驱动的操作和设备号联系在一起的纽带,是一系列指针的集合,每个被打开的文件都对应于一系列的操作,这就是file_operations,用来执行一系列的系统调用。
内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。
通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。
Linux内核提供了remap_pfn_range函数来实现将内核空间的内存映射到用户空间:
/**
* remap_pfn_range - remap kernel memory to userspace
* @vma: user vma to map to
* @addr: target user address to start at
* @pfn: physical address of kernel memory
* @size: size of map area
* @prot: page protection flags for this mapping
*
* Note: this is only safe if the mm semaphore is held when called.
*/
int remap_pfn_range(struct vm_area_struct *vma, unsigned long addr,
unsigned long pfn, unsigned long size, pgprot_t prot);
上面的注释对参数进行了说明。当用户调用mmap时,驱动中的file_operations->mmap会被调用,可以在mmap中调用remap_pfn_range,它的大部分参数的值都由VMA提供
(三)进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。
进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。
之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。
注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。
常规文件系统操作(调用read/fread等类函数)中,函数的调用过程:
总结来说,常规文件操作为了提高读写效率和保护磁盘,使用了页缓存机制。这样造成读文件时需要先将文件页从磁盘拷贝到页缓存中,由于页缓存处在内核空间,不能被用户进程直接寻址,所以还需要将页缓存中数据页再次拷贝到内存对应的用户空间中。这样,通过了两次数据拷贝过程,才能完成进程对文件内容的获取任务。写操作也是一样,待写入的buffer在内核空间不能直接访问,必须要先拷贝至内核空间对应的主存,再写回磁盘中(延迟写回),也是需要两次数据拷贝。
而使用mmap操作文件中,创建新的虚拟内存区域和建立文件磁盘地址与虚拟内存区域映射这两步,没有任何文件拷贝操作。而之后访问数据时发现内存中并无数据而发起的缺页异常过程,可以通过已经建立好的映射关系,只使用一次数据拷贝,就从磁盘中将数据传入内存的用户空间中,供进程使用。
总而言之,常规文件操作需要从磁盘到页缓存再到用户主存的两次数据拷贝。而mmap操控文件,只需要从磁盘到用户主存的一次数据拷贝过程。说白了,mmap的关键点是实现了用户空间和内核空间的数据直接交互而省去了空间不同数据不通的繁琐过程。因此mmap效率更高。
void *mmap(void *start, size_t length, int prot, int flags,int fd, off_t offset);
int munmap( void * addr, size_t len )
int msync( void *addr, size_t len, int flags )
一般说来,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往往在调用munmap()后才执行该操作。
可以通过调用msync()实现磁盘上文件内容与共享内存区的内容一致。
使用mmap需要注意的一个关键点是,mmap映射区域大小必须是物理页大小(page_size)的整倍数(32位系统中通常是4k字节)。原因是,内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。为了匹配内存的操作,mmap从磁盘到虚拟地址空间的映射也必须是页。
内核可以跟踪被内存映射的底层对象(文件)的大小,进程可以合法的访问在当前文件大小以内又在内存映射区以内的那些字节。也就是说,如果文件的大小一直在扩张,只要在映射区域范围内的数据,进程都可以合法得到,这和映射建立时文件的大小无关。具体情形参见“情形三”。
映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关。同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。
linux采用的是页式管理机制。对于用mmap()映射普通文件来说,进程会在自己的地址空间新增一块空间,空间大小由mmap()的len参数指定,注意,进程并不一定能够对全部新增空间都能进行有效访问。进程能够访问的有效地址大小取决于文件被映射部分的大小。简单的说,容纳文件被映射部分大小的最少页面个数,决定了进程从mmap()返回的地址开始能够有效访问的地址空间大小。超过这个空间大小,内核会根据超过的严重程度返回发送不同的信号给进程。可用如下图示说明:
注意:文件被映射部分而不是整个文件决定了进程能够访问的空间大小,另外,如果指定文件的偏移部分,一定要注意为页面大小的整数倍。
分析:因为单位物理页面的大小是4096字节,虽然被映射的文件只有5000字节,但是对应到进程虚拟地址区域的大小需要满足整页大小,因此mmap函数执行后,实际映射到虚拟内存区域8192个 字节,5000~8191的字节部分用零填充。映射后的对应关系如下图所示:
此时:
(1)读/写前5000个字节(0~4999),会返回操作文件内容。
(2)读字节5000~8191时,结果全为0。写5000~8191时,进程不会报错,但是所写的内容不会写入原文件(磁盘文件)中 。
(3)读/写8192以外的磁盘部分,会返回一个SIGSECV错误。
实际代码发现读写整页区域都不会报错,写超过最后一页的内存时会有段错误。
#include<sys/types.h>
#include<fcntl.h>
#include<unistd.h>
#include<stdio.h>
#include<string.h>
typedef struct{
char name[4];
int age;
}people;
int main(int argc, char** argv)
{
int fd,i;
people* p_map;
char temp;
fd = open(argv[1], O_CREAT|O_RDWR|O_TRUNC, 00777);
//物理文件
lseek(fd, sizeof(people)*5-1, SEEK_SET);
write(fd, "", 1);
//内存一页4096B,people结构体8B。一页可以放4096/8=512 people结构体。10个peoplez在一页内。
p_map = (people*)mmap(NULL, sizeof(people)*10, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);
temp = 'a';
//here, we can write 10 people info in memory, but 6-10 people info can not write to file.
for(int i=0; i<10; i++)
{
temp+=1;
memcpy((*(p_map+i)).name, &temp, sizeof(temp));
(*(p_map+i)).age = 20+i;
}
//we can access 6-10 people's memory in the same process. not generate error.
for(int i=0; i<10; i++)
{
printf("name: %s, age: %dn", (*(p_map+i)).name, (*(p_map+i)).age);
}
temp+=1;
//为mmap并未映射但是一页范围内的区域赋值,不会报错
memcpy((*(p_map+11)).name, &temp, sizeof(temp));
(*(p_map+11)).age = 20+11;
//为mmap并未映射超出一页范围内的区域赋值,将产生段错误
temp+=1;
memcpy((*(p_map+513)).name, &temp, sizeof(temp));
(*(p_map+513)).age = 20+12;
printf("initialize over n");
sleep(10);
munmap(p_map, sizeof(people)*10);
printf("ummap okn");
}
虽然上边代码向6-10个people所在的内存写了值,但是关联同一个文件的另一个程序读不到,6-10个people的信息。
#include<sys/mman.h>
#include<sys/types.h>
#include<fcntl.h>
#include<unistd.h>
#include<stdio.h>
#include<string.h>
typedef struct{
char name[4];
int age;
}people;
int main(int argc, char** argv)
{
int fd,i;
people* p_map;
fd = open(argv[1], O_CREAT|O_RDWR, 00777);
p_map = (people*)mmap(NULL, sizeof(people)*10, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);
close(fd);
for(int i=0; i<10; i++)
{
printf("name: %s age: %dn", (*(p_map+i)).name, (*(p_map+i)).age);
}
//1.可以读一页内未与文件建立映射的内存
//2.一页可以放512个people结构体,513people结构体的name会读到一个EOF
//3.可以读取4096个people结构体不报错
printf("name: %s, age: %dn", (*(p_map+11)).name, (*(p_map+11)).age);
printf("name: %s, age: %dn", (*(p_map+511)).name, (*(p_map+511)).age);
printf("name: %s, age: %dn", (*(p_map+512)).name, (*(p_map+512)).age);//out one page-frame memory adress
printf("name: %s, age: %dn", (*(p_map+513)).name, (*(p_map+513)).age);//out one ...
printf("name: %s, age: %dn", (*(p_map+4096)).name, (*(p_map+4096)).age);//out one ...
munmap(p_map, sizeof(people)*10);
}
分析:由于文件的大小是5000字节,和情形一一样,其对应的两个物理页。那么这两个物理页都是合法可以读写的,只是超出5000的部分不会体现在原文件中。由于程序要求映射15000字节,而文件只占两个物理页,因此8192字节~15000字节都不能读写,操作时会返回异常。如下图所示:
此时:
(1)进程可以正常读/写被映射的前5000字节(0~4999),写操作的改动会在一定时间后反映在原文件中。
(2)对于5000~8191字节,进程可以进行读写过程,不会报错。但是内容在写入前均为0,另外,写入后不会反映在文件中。
(3)对于8192~14999字节,进程不能对其进行读写,会报SIGBUS错误。
(4)对于15000以外的字节,进程不能对其读写,会引发SIGSEGV错误。
分析:如果在映射建立之初,就对文件进行读写操作,由于文件大小为0,并没有合法的物理页对应,如同情形二一样,会返回SIGBUS错误。
但是如果,每次操作ptr读写前,先增加文件的大小,那么ptr在文件大小内部的操作就是合法的。例如,文件扩充4096字节,ptr就能操作ptr ~ [ (char)ptr + 4095]的空间。只要文件扩充的范围在1000个物理页(映射范围)内,ptr都可以对应操作相同的大小。
这样,方便随时扩充文件空间,随时写入文件,不造成空间浪费
大家关于“mmap()”更快的认识来自于 read() 是需要内存拷贝的;当今硬件技术的发展,使得内存拷贝消耗的时间已经极大降低了;但“mmap()”的开销在于一次 pagefault,这个开销相比而言已经更高了,而且 pagefault 的处理任务现在比以前还更多了;而且,mmap之后,再有读操作不会经过系统调用,在 LRU 比较最近使用的页的时候不占优势;于是,普通读情况下(排除反复读之类的文艺与2B读操作),read() 通常会比 mmap() 来得更快。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!