2020-10-20
转载于:https://blog.****.net/mcryeasy/article/details/86741781
一、引言
说到内存映射函数mmap大家可能觉得陌生,其实Android中的Binder机制就是mmap来实现的。不仅如此,微信的MMKV key-value组件、美团的 Logan的日志组件 都是基于mmap来实现的。mmap强大的地方在于通过内存映射直接对文件进行读写,减少了对数据的拷贝次数,大大的提高了IO读写的效率。
二、Linux文件系统
由于Android是基于Linux系统,因此在介绍mmap之前,不得不先介绍下Linux的文件系统。
类似于网络的分层结构,下图显示了 Linux 系统中对于磁盘的一次读请求在核心空间中所要经历的层次模型:
- 虚拟文件系统层:作用是屏蔽下层具体文件系统操作的差异,为上层的操作提供一个统一的接口。
- 文件系统层 :具体的文件系统层,一个文件系统一般使用块设备上一个独立的逻辑分区。
- Page Cache (层页高速缓存层):引入 Cache 层的目的是为了提高 Linux 操作系统对磁盘访问的性能。
- 通用块层:作用是接收上层发出的磁盘请求,并最终发出 I/O 请求。
- I/O 调度层:作用是管理块设备的请求队列。
- 块设备驱动层 :利用驱动程序,驱动具体的物理块设备。
- 物理块设备层:具体的物理磁盘块。
其他层暂不细讲,主要说说Page Cache层 (页高速缓存)这一层。引入 Cache 层的目的是为了提高 Linux 操作系统对磁盘访问的性能。Cache 层在内存中缓存了磁盘上的部分数据。当数据的请求到达时,如果在 Cache 中存在该数据且是的,则直接将数据传递给用户程序,免除了对底层磁盘的操作,提高了性能。
Page Cache层实际上是内核中的物理内存,在磁盘和用户空间之间多了一层缓存层,由内核负责管理控制。由于物理内存的速度远远快于磁盘的速度,有了这一层的存在,数据放入Page Cache中可以更快的进行访问。而且数据一旦被访问后,短时间内有极大会再一次被访问,短时间内集中访问同一数据的原理就叫做局部性原理。因此经常需要被访问的数据,如果将其放入缓存中,那就有可能再次被页高速缓存命中,这也是Page Cache所带来的性能提升!
在Linux上我们可以通过/proc/meminfo文件查看Cache的大小 :
Cache是可以被回收的,尤其当系统内存空间不足的时候,会把Cache中脏数据写入到磁盘中。所以在统计Linux空闲内存大小的时候通常是 MemFree+ Cached的总和!
Android系统中通过ActivityManager#getMemoryInfo#availMem查看可用内存的大小的时候也是这么计算的,在/frameworks/base/core/jni/android_util_Process.cpp中可以看到其计算方式:
三、Cache Page与Read/Write操作
由于有了Cache Page的存在,read/write系统调用会有以下的操作,我们那Read来进行说明:
- 用户进程向内核发起读取文件的请求,这涉及到用户态到内核态的转换。
- 内核读取磁盘文件中的对应数据,并把数据读取到Cache Page中。
- 由于Page Cache处在内核空间,不能被用户进程直接寻址 ,所以需要从Page Cache中拷贝数据到用户进程的堆空间中。
注意,这里涉及到了两次拷贝:第一次拷贝磁盘到Page Cache,第二次拷贝Page Cache到用户内存。最后物理内存的内容是这样的,同一个文件内容存在了两份拷贝,一份是页缓存,一份是用户进程的内存空间。
整个流程如下图所示:
可见我们平时所使用的read/write操作作对文件操作的过程中会涉及到两次拷贝的操作!这是因为有了Cache Page的存在为了提高读写效率和保护磁盘。而我们本章要讲的mmap操作,它读写效率更高,而且只涉及一次拷贝操作,IO读写效率远远高于read/write!
四、mmap内存映射原理
mmap是一种内存映射文件的方法,它将一个文件映射到进程的地址空间中,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一段内存,而系统会自动回写脏页面到对应的文件磁盘上,即完成了对文件的操作而不必再调用read,write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,从而可以实现不同进程间的文件共享。如下图所示:
mmap内存映射具体流程如下:
1、用户进程调用内存映射函数库mmap,当前进程在虚拟地址空间中,寻找一段空闲的满足要求的虚拟地址。
2、此时内核收到相关请求后会调用内核的mmap函数,注意,不同于用户空间库函数。内核mmap函数通过虚拟文件系统定位到文件磁盘物理地址,既实现了文件地址和虚拟地址区域的映射关系。 此时,这片虚拟地址并没有任何数据关联到主存中。
注意,前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据的拷贝至主存。真正的文件读取是当进程发起读或写操作时。
3、进程的读或写操作访问虚拟地址空间这一段映射地址,现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页中断。
4、由于引发了缺页中断,内核则调用nopage函数把所缺的页从磁盘装入到主存中。
5、之后用户进程即可对这片主存进行读或者写的操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。
注意:这里拷贝磁盘内容到主存,这里的主存是指处于内核空间的Page Cache,而不是用户空间的内存。用户地址要访问内核空间中的数据,需使用MMU把虚拟地址映射到内核的内存地址中,即可对数据进行操作。整个mmap工作流程大体如下:
这里我们可以看出mmap系统调用与read/write调用的区别在于:
- mmap只需要一次系统调用(一次拷贝),后续操作不需要系统调用。
- 访问的数据不需要在page cache和用户缓冲区之间拷贝。 访问的数据不需要在page cache和用户缓冲区之间拷贝。
从上所述,当频繁对一个文件进行读取操作时,mmap会比read/write更高效。
五、mmap的使用
mmap的函数位于 <sys/mman.h> 头文件中,它的函数原型如下:
void* mmap(void addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
int msync(void * addr, size_t len, int flags);
- mmap 函数用于将文件映射到内存 。
- munmap 函数用于取消映射,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往往在调用 munmap() 后才执行该操作。
- msync 函数用于实现磁盘文件内容与共享内存区中的内容一致,即同步操作,除了调用munmap取消映射,我们也可以调用msync()实现磁盘上文件内容与共享内存区的内容一致。
mmap的函数的使用网上有很多教程,这里每个参数的作用就不再细讲,主要讲讲mmap使用过程中的几个细节点:
细节点一: mmap映射区域大小必须是物理页大小(page_size)的整倍数(在Linux中内存页通常是4k)。原因是,内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。为了匹配内存的操作,mmap从磁盘到虚拟地址空间的映射也必须是页。
例如,有一个文件的大小是5K,mmap函数从文件的起始位置映射5K到虚拟内存中,由于内存物理页是4K,虽然映射的文件只有5K,但是实际上映射到内存区域的内存是8K,以便满足物理页大小的整数倍。映射后对5~8K的内存区域用零填充,对这部分的操作不会报错也不会写入到原文件中。
细节点二 : 映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关。同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。
案例:映射文件到内存
下面我们通过一个简单的例子来熟悉mmap的用法,首先我们先建立一个测试用文件,建立了一个大小为 1KB 的文本文件 cry.txt:
mkfile 1k cry.txt
- 1
文件内容如下:
之后我们通过mmap函数对cry.txt的内容进行修改,在首地址中添加Hello World:
#include <sys/mman.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/stat.h>
int main(){
const char * file = "/Users/chenrongyi/Desktop/cry.txt";
//打开文件,fd文件句柄
int fd = open(file, O_RDWR );
if(fd < 0){
printf("Can't open %s\n",file);
exit(-1);
}
//获取文件信息(此处获取大小信息)
struct stat sb;
if((fstat(fd, &sb)) == -1){
printf("Can't file status failed\n");
exit(-1);
}
//使用mmap进行映射,映射整个文件大小sb.st_size
char* mapped = mmap(NULL, sb.st_size , PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if(mapped== MAP_FAILED){
printf("File mmap failed\n");
exit(-1);
}
//映射结束,关闭文件
close(fd);
//对映射内存进行修改,首地址写入Hello World
strcpy(mapped,"Hello World!");
//同步内存与文件
//只要文件映射存在,就可以通过msync将映射空间的内容写入文件,实现空间和文件的同步。
if(msync ((void *) mapped, sb.st_size, MS_SYNC)== MAP_FAILED){
printf("msync failed \n");
exit(-1);
}
//释放映射区,取消映射
if ((munmap ((void *) mapped, sb.st_size)) == MAP_FAILED) {
printf("munmap failed \n");
exit(-1);
}
printf("mmap success \n");
}
程序运行成功输出mmap success,然后我们再打开cry.txt,Hello World写入成功:
六、mmap的应用场景
mmap在Linux、Android系统上有非常多的应用场景。
1、Linux进程的创建
Linux执行一个程序,这个程序在磁盘上,为了执行这个程序,需要把程序加载到内存中,这时也是采用的是mmap。你可以从/proc/pid/maps看到每个进程的mmap状态。
2、内存分配
我们使用c库的malloc申请内存,malloc的分配内存有两个系统调用,一个brk,另一个就是mmap。其实mmap不仅可以映射文件,也可以映射内存,当mmap使用的flag是MAP_ANONYMOUS,称为建立匿名映射,此时会忽略参数fd,不涉及文件,而且映射区域无法和其他进程共享。匿名映射存储的数据就是在物理内存上,不属于任何文件。malloc分配内存底层就是用mmap的匿名映射来操作的。
3、Binder进程间通信
了解进程间通信的人都知道Android使用的是Binder进行进程间通信,它的效率高于Linux其他传统的进程间通信,因为它只要一次拷贝,而之所以只需要进行一次拷贝的原因就在于使用了mmap!
一次完整的 Binder IPC 通信过程通常是这样:
- Server端在启动之后,调用对/dev/binder设备调用mmap。
- 内核中的binder_mmap函数进行对应的处理:申请一块物理内存,然后在Server端的用户空间和内核空间同时进行映射。内核中的binder_mmap函数进行对应的处理:申请一块物理内存,然后在Server端的用户空间和内核空间同时进行映射
- Client发送请求,这个请求将先到驱动中,同时需要将数据从Client进程的用户空间拷贝(Client发送请求,这个请求将先到驱动中,同时需要将数据从Client进程的用户空间拷贝(copy_from_user)到内核空间。
- 驱动通过请求通知Server端有人发出请求,Server进行处理。由于内核空间和Server端进程的用户空间存在内存映射,因此Server进程的代码可以直接访问。这样便完成了一次进程间的通信。
4、IO读写效率
mmap最主要的功能就是提高了IO读写的效率,微信的MMKV key-value组件、美团的 Logan的日志组件 都是基于mmap来实现的。在微信的 MMKV/Android/MMKV/mmkv/src/main/cpp/MMKV.cpp 和美团的 Meituan-Dianping/Logan/blob/master/Logan/Clogan/mmap_util.c 的这两个文件中你都可以看到对mmap函数的使用,有兴趣的小伙伴可以自行查阅。
上一篇: element UI修改组件的默认样式
下一篇: MyBatis——06三种查询方式