虚拟内存系统通过将虚拟内存分割为称作虚拟页(Virtual Page,VP)大小固定的块,一般情况下,每个虚拟页的大小默认是4096字节。
同样的,物理内存也被分割为物理页(Physical Page,PP),也为4096字节。
背景
在研发分布式日志存储系统,基于Raft协议的自研分布式日志存储系统,Logstore则是底层存储引擎。
Logstore中,使用mmap对数据文件进行读写。Logstore的存储结构简化如下图:
Logstore使用了Segments Files + Index Files的方式存储Log,Segment File是存储主体,用于存储Log数据,使用定长的方式,默认每个512M,Index File主要用于Segment File的内容检索。
Logstore使用mmap的方式读写Segment File,Segments Files的个数,主要取决于磁盘空间或者业务需求,一般情况下,Logstore会存储1T~5T的数据。
在Logstore中,mapping的对象是普通文件(Segment File)。
1. mmap() 的定义与原理
1.0 mmap 的定义
在<<深入理解计算机系统>>这本书中,mmap定义为:Linux通过将一个虚拟内存区域与一个磁盘上的对象(object)关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)。
在LINUX中我们可以使用mmap用来在进程虚拟内存地址空间中分配地址空间,创建和物理内存的映射关系。
- mmap() 系统调用的原型
函数原型:void *mmap(void *addr, size_t length, int prot, int flags,int fd, off_t offset);
配套函数原型:int munmap(void *addr, size_t length);
头文件:#include <sys/mman.h>
返回值:成功返回创建的映射区的首地址;失败返回宏 MAP_FAILED。
参数介绍:
addr: 建立映射区的首地址,由 Linux 内核指定。使用时,直接传递 NULL。
length: 欲创建映射区的大小。
prot: 映射区权限 PROT_READ、PROT_WRITE、PROT_READ|PROT_WRITE。
flags: 标志位参数(常用于设定更新物理区域、设置共享、创建匿名映射区);
MAP_SHARED: 会将映射区所做的操作反映到物理设备(磁盘)上。
MAP_PRIVATE: 映射区所做的修改不会反映到物理设备。
fd: 用来建立映射区的文件描述符。
offset: 映射文件的偏移(4k 的整数倍)。
munmap 函数:同 malloc 函数申请内存空间类似的,mmap 建立的映射区在使用结束后也应调用类似 free 的函数来释放。
返回值:成功:0; 失败:-1
1.1调用 malloc() 时,内核做了什么
当我们在用户程序申请内存时调用 malloc(),内核做了什么,实际上,它负责为进程动态的申请一块内存,操作系统从堆中分配一块内存,并把首地址返回给用户。malloc()申请内存大小不一样,最终调用的系统调用也不一样,当申请的内存大小小于 128kb 时,调用 brk 系统调用,并从堆栈分配内存,当大于 128kb 时调用 mmap,从映射区分配,如图所示,那么,内核是不是立即为进程分配了物理内存?答案是否定的,依然是通过请页机制。
1.2 映射方式
- 文件映射
磁盘文件映射进程的虚拟地址空间,使用文件内容初始化物理内存。 - 匿名映射
初始化全为0的内存空间。
而对于映射方式是否共享又分为
-
私有映射(MAP_PRIVATE)
多进程间数据共享,修改不反应到磁盘实际文件,是一个copy-on-write(写时复制)的映射方式。 -
共享映射(MAP_SHARED)
多进程间数据共享,修改反应到磁盘实际文件中。
因此总结起来有4种组合
1、私有文件映射
多个进程使用同样的物理内存页进行初始化,但是各个进程对内存文件的修改不会共享,也不会反应到物理文件中
2、私有匿名映射
mmap会创建一个新的映射,各个进程不共享,这种使用主要用于分配内存(malloc分配大内存会调用mmap)。
例如开辟新进程时,会为每个进程分配虚拟的地址空间,这些虚拟地址映射的物理内存空间各个进程间读的时候共享,写的时候会copy-on-write。
3、共享文件映射
多个进程通过虚拟内存技术共享同样的物理内存空间,对内存文件的修改会反应到实际物理文件中,他也是进程间通信(IPC)的一种机制。
4、共享匿名映射
这种机制在进行fork的时候不会采用写时复制,父子进程完全共享同样的物理内存页,这也就实现了父子进程通信(IPC).
这里值得注意的是,mmap只是在虚拟内存分配了地址空间,只有在第一次访问虚拟内存的时候才分配物理内存。
在mmap之后,并没有将文件内容加载到物理页上,只上在虚拟内存中分配了地址空间。当进程在访问这段地址时,通过查找页表,发现虚拟内存对应的页没有在物理内存中缓存,则产生"缺页",由内核的缺页异常处理程序处理,将文件对应内容,以页为单位(4096)加载到物理内存,注意是只加载缺页,但也会受操作系统一些调度策略影响,加载的比所需的多。
1.3 mmap() 在 read() 和 write 时的运作
- write
- 进程(用户态)将需要写入的数据直接copy到对应的mmap地址(内存copy)
- 若mmap地址未对应物理内存,则产生缺页异常,由内核处理
- 若已对应,则直接copy到对应的物理内存
- 由操作系统调用,将脏页回写到磁盘(通常是异步的)
因为物理内存是有限的,mmap在写入数据超过物理内存时,操作系统会进行页置换,根据淘汰算法,将需要淘汰的页置换成所需的新页,所以mmap对应的内存是可以被淘汰的(若内存页是"脏"的,则操作系统会先将数据回写磁盘再淘汰)。这样,就算mmap的数据远大于物理内存,操作系统也能很好地处理,不会产生功能上的问题。
- read
读的过程对比:
从图中可以看出,mmap要比普通的read系统调用少了一次copy的过程。
因为read调用,进程是无法直接访问kernel space的,所以在read系统调用返回前,内核需要将数据从内核复制到进程指定的buffer。但mmap之后,进程可以直接访问mmap的数据(page cache)。
2. mmap() 与虚拟内存和物理内存
2.1mmap() 在进程虚拟地址空间的 运作机制
我们先来简单看一下mapping一个文件,mmap做了什么事情。如下图所示:
假设我们mmap的文件是FileA,在调用mmap之后,会在进程的虚拟内存分配地址空间,创建映射关系。
这里值得注意的是,mmap只是在虚拟内存分配了地址空间,举个例子,假设上述的FileA是2G大小
[dragon@xxx.xxx] ls -lat FileA
2147483648 Apr 25 10:22 FileA
在mmap之后,查看mmap所在进程的maps描述,可以看到
[dragon@xxx.xxx] ls -lat FileA
2147483648 Apr 25 10:22 FileA
由上可以看到,在mmap之后,进程的地址空间7f35eea8d000-7f366ea8d000
被分配,并且map到FileA,7f366ea8d000
减去7f35eea8d000
,
刚好是2147483648(ps: 这里是整个文件做mapping)
2.2mmap() 在物理内存的 运作机制
在Linux中,VM系统通过将虚拟内存分割为称作虚拟页(Virtual Page,VP)大小固定的块来处理磁盘(较低层)与上层数据的传输,
一般情况下,每个页的大小默认是4096字节。
同样的,物理内存也被分割为物理页(Physical Page,PP),也为4096字节。
上述例子,在mmap之后,如下图:
在mmap之后,并没有在将文件内容加载到物理页上,只上在虚拟内存中分配了地址空间。当进程在访问这段地址时(通过mmap在写入或读取时FileA),若虚拟内存对应的page没有在物理内存中缓存,则产生"缺页",由内核的缺页异常处理程序处理,将文件对应内容,以页为单位(4096)加载到物理内存,注意是只加载缺页,但也会受操作系统一些调度策略影响,加载的比所需的多,这里就不展开了。
(PS: 再具体一些,进程在访问7f35eea8d000这个进程虚拟地址时,MMU通过查找页表,发现对应内容未缓存在物理内存中,则产生"缺页")
缺页处理后,如下图:
3. mmap() 的分类
个人观点: 从原理上,mmap有两种类型,
一种是有backend,
一种是没有backend。
3.1 有backend
这种模式将普通文件做memory mapping(非MAP_ANONYMOUS),所以在mmap系统调用时,需要传入文件的fd。
这种模式常见的有两个常用的方式,MAP_SHARED
与MAP_PRIVATE
,但它们的行为却不相同。
- MAP_SHARED
可以从两个角度去看:
- 进程间可见:这个被提及太多,就不展开讨论了
- 写入/更新数据会回写backend,也就是回写文件:这个是很关键的特性,是在Logstore设计实现时,需要考虑的重点。Logstore的一个基本功能就是不断地写入数据,从实现上看就是不断地mmap文件,往内存写入/更新数据以达到写入文件的目的。但物理内存是有限的,在写入数据超过物理内存时,操作系统会进行页置换,根据淘汰算法,将需要淘汰的页置换成所需的新页,而恰恰因为是有backend的,所以mmap对应的内存是可以被淘汰的(若内存页是"脏"的,则操作系统会先将数据回写磁盘再淘汰)。这样,就算mmap的数据远大于物理内存,操作系统也能很好地处理,不会产生功能上的问题。
- MAP_PRIVATE
这是一个copy-on-write的映射方式。虽然他也是有backend的,但在写入数据时,他会在物理内存copy一份数据出来(以页为单位),而且这些数据是不会被回写到文件的。这里就要注意,因为更新的数据是一个副本,而且不会被回写,这就意味着如果程序运行时不主动释放,若更新的数据超过可用物理内存+swap space,就会遇到OOM Killer。
3.2 没有backend
无backend通常是MAP_ANONYMOUS
,就是将一个区域映射到一个匿名文件,匿名文件是由内核创建的。
因为没有backend,写入/更新的数据之后,若不主动释放,这些占用的物理内存是不能被释放的,同样会出现OOM Killer。
3.3 mmap比内存+swap空间大情况下,是否有问题
到这里,这个问题就比较好解析了。我们可以将此问题分离为:
- 虚拟内存是否会出问题
- 物理内存是否会出问题
- 虚拟内存是否会出问题:
回到上述的"mmap在进程虚拟内存做了什么",我们知道mmap会在进程的虚拟内存中分配地址空间,比如1G的文件,则分配1G的连续地址空间。
那究竟可以maping多少呢?在64位操作系统,寻址范围是2^64 ,除去一些内核、进程数据等地址段之外,基本上可以认为可以mapping无限大的数据(不太严谨的说法)。
- 物理内存是否会出问题
回到上述"mmap的分类",对于有backend的mmap,而且是能回写到文件的,映射比内存+swap空间大是没有问题的。但无法回写到文件的,需要非常注意,主动释放。
MAP_NORESERVE
MAP_NORESERVE是mmap的一个参数,MAN的说明是"Do not reserve swap space for this mapping. When swap space is reserved, one has the guarantee that it is possible to modify the mapping."。
我们做个测试:
-
场景A:物理内存+swap space: 16G,映射文件30G,使用一个进程进行mmap,成功后映射后持续写入数据
-
场景B:物理内存+swap space: 16G,映射文件15G,使用两个进程进行mmap,成功后映射后持续写入数据
场景 | 序列 | 映射类型 | 结果 |
A | 1 | MAP_PRIVATE | mmap报错 |
A | 2 | MAP_PRIVATE + MAP_NORESERVE | mmap成功,在持续写入情况下,遇到OOM Killer |
A | 3 | MAP_SHARED | mmap成功,在持续写入正常 |
B | 4 | MAP_PRIVATE | mmap成功,在持续写入情况下,有一个进程会遇到OOM Killer |
B | 5 | MAP_PRIVATE + MAP_NORESERVE | mmap成功,在持续写入情况下,有一个进程会遇到OOM Killer |
B | 6 | MAP_SHARED | mmap成功,在持续写入正常 |
从上述测试可以看出,从现象上看,NORESERVE是绕过mmap的校验,让其可以mmap成功。但其实在RESERVE的情况下(序列4),从测试结果看,也没有保障。
reference
https://www.jianshu.com/p/755338d11865
https://www.jianshu.com/p/eece39beee20
https://www.cnblogs.com/yanlingyin/archive/2012/08/04/2617209.html