前言
上一篇解释了RMQ为了提高大文件的读写效率,使用了内存映射的方法,将磁盘上的文件与进程中的进程虚拟空间进行了映射,减少一次内核空间到用户空间的一次复制。看到这里我就有了疑惑,既然内存映射有这么好的特性,为什么还需要传统的IO呢?看下文的分析。
进程中的虚拟内存
mmap是将文件与进程虚拟空间进行了映射,所以你需要先明白进程虚拟空间是什么概念。下图的左边就是一个进程地址空间可视图。
你可以看到进程地址空间有分成好多一段段的,比如text数据段、初始数据段等。我们把这个段也称为个虚拟内存区域。可以看到内存映射的内存区域位于堆栈之间的空余部分。
Linux通过下图的方式来组织虚拟内存。这里其他先不看,重点关注以下vm_area_struct。
在Linux内核,我们使用vm_area_struct结构来表示一个虚拟内存区域,一个具体的vm_area_struct包含以下字段:
- vm_start:指向这个区域的起始处。
- vm_end:指向这个区域的结束处。
- vm_port:描述这个区域包含的所有页的读写权限。
- vm_flags:描述这个区域是否是私有的还是共享的。
- vm_next:指向链表中下一个区域结构。
为了解释清楚这里说一下上图中与vm_area_struct有关联的task_strcut 和 mm_strcut。
内核系统为每个进程维护一个单独的任务结构在内核源码中就是task_strcut ,该结构中的元素包含内核运行该进程所需要的所有信息,(如PID、执行用户栈的指针、程序计数器)。
任务结构中的一个条目指向mm_struct,它描述了虚拟内存的当前状态。其中有两个字段是我们感兴趣的,pgd 和 mmap。pgd指向第一级页表的基址,mmap指向vm_area_struct的链表。
mmap内存映射原理
mmap内存映射的实现过程,总的来说可以分为三个阶段:
(一)进程启动映射过程,并在虚拟地址空间中为映射创建虚拟映射区域
1、进程在用户空间调用库函数mmap,原型:void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
2、在当前进程的虚拟地址空间中,寻找一段空闲的满足要求的连续的虚拟地址
3、为此虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行了初始化
4、将新建的虚拟区结构(vm_area_struct)插入进程的虚拟地址区域链表或树中
(二)调用内核空间的系统调用函数mmap(不同于用户空间函数),实现文件物理地址和进程虚拟地址的一一映射关系
5、为映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核“已打开文件集”中该文件的文件结构体(struct file),每个文件结构体维护着和这个已打开文件相关各项信息。
6、通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap,其原型为:int mmap(struct file *filp, struct vm_area_struct *vma),不同于用户空间库函数。
7、内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址。
8、通过remap_pfn_range函数建立页表,即实现了文件地址和虚拟地址区域的映射关系。此时,这片虚拟地址并没有任何数据关联到主存中。
(三)进程发起对这片映射空间的访问,引发缺页异常,实现文件内容到物理内存(主存)的拷贝
注:前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。
9、进程的读或写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页异常。
10、缺页异常进行一系列判断,确定无非法操作后,内核发起请求调页过程。
11、调页过程先在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用nopage函数把所缺的页从磁盘装入到主存中。
12、之后进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。
注:修改过的脏页面并不会立即更新回文件中,而是有一段时间的延迟,可以调用msync()来强制同步, 这样所写的内容就能立即保存到文件里了。
mmap使用细节
1、使用mmap需要注意的一个关键点是,mmap映射区域大小必须是物理页大小(page_size)的整倍数(32位系统中通常是4k字节)。原因是,内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。为了匹配内存的操作,mmap从磁盘到虚拟地址空间的映射也必须是页。
2、内核可以跟踪被内存映射的底层对象(文件)的大小,进程可以合法的访问在当前文件大小以内又在内存映射区以内的那些字节。也就是说,如果文件的大小一直在扩张,只要在映射区域范围内的数据,进程都可以合法得到,这和映射建立时文件的大小无关。具体情形参见“情形三”。
3、映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关。同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。
在上面的知识前提下,我们下面看看如果大小不是页的整倍数的具体情况:
情形一:一个文件的大小是5000字节,mmap函数从一个文件的起始位置开始,映射5000字节到虚拟内存中。
分析:因为单位物理页面的大小是4096字节,虽然被映射的文件只有5000字节,但是对应到进程虚拟地址区域的大小需要满足整页大小,因此mmap函数执行后,实际映射到虚拟内存区域8192个 字节,5000~8191的字节部分用零填充。映射后的对应关系如下图所示:
此时:
(1)读/写前5000个字节(0~4999),会返回操作文件内容。
(2)读字节50008191时,结果全为0。写50008191时,进程不会报错,但是所写的内容不会写入原文件中 。
(3)读/写8192以外的磁盘部分,会返回一个SIGSECV错误。
情形二:一个文件的大小是5000字节,mmap函数从一个文件的起始位置开始,映射15000字节到虚拟内存中,即映射大小超过了原始文件的大小。
分析:由于文件的大小是5000字节,和情形一一样,其对应的两个物理页。那么这两个物理页都是合法可以读写的,只是超出5000的部分不会体现在原文件中。由于程序要求映射15000字节,而文件只占两个物理页,因此8192字节~15000字节都不能读写,操作时会返回异常。如下图所示:
此时:
(1)进程可以正常读/写被映射的前5000字节(0~4999),写操作的改动会在一定时间后反映在原文件中。
(2)对于5000~8191字节,进程可以进行读写过程,不会报错。但是内容在写入前均为0,另外,写入后不会反映在文件中。
(3)对于8192~14999字节,进程不能对其进行读写,会报SIGBUS错误。
(4)对于15000以外的字节,进程不能对其读写,会引发SIGSEGV错误。
情形三:一个文件初始大小为0,使用mmap操作映射了1000*4K的大小,即1000个物理页大约4M字节空间,mmap返回指针ptr。
分析:如果在映射建立之初,就对文件进行读写操作,由于文件大小为0,并没有合法的物理页对应,如同情形二一样,会返回SIGBUS错误。
但是如果,每次操作ptr读写前,先增加文件的大小,那么ptr在文件大小内部的操作就是合法的。例如,文件扩充4096字节,ptr就能操作ptr ~ [ (char)ptr + 4095]的空间。只要文件扩充的范围在1000个物理页(映射范围)内,ptr都可以对应操作相同的大小。
这样,方便随时扩充文件空间,随时写入文件,不造成空间浪费。
为什么不直接用内存映射代替IO?
现在回到一开始的问题,既然内存映射可以提高文件的读取效率,为什么还要使用IO呢?
首先你要明白一点,直接将文件映射到虚拟内存,意味着没有数据没有缓存在内核缓存空间,而是直接读到了用户空间,回想一下系统的IO和内核缓存搭配可以是的部分的文件使用效率更高。而且从mmap的细节我们可以看到,映射的文件最好是大于4k的(一个内存页的大小),并且最好是4k的倍数。也就是说两个方式都是有优缺点的,所以不存在代替这个说法,只能通过分析其场景而选择不同的方式。
而RMQ的commitlog刚好1G,是符合内存映射的高效率的特点,所以RMQ可以使用内存映射加快文件的读写效率。