一、序
说到内存映射函数mmap大家可能觉得陌生,其实Android中的Binder机制就是mmap来实现的。不仅如此,微信的MMKV key-value组件、美团的 Logan的日志组件 都是基于mmap来实现的。mmap强大的地方在于通过内存映射直接对文件进行读写,减少了对数据的拷贝次数,相较于传统shaferance的key value读写,大大的提高了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所带来的性能提升!
当系统free内存不足时,这时如果有进程申请内存,操作系统会从page cache中回收内存页进行分配,如果page cache也已不足,那么系统会将当期驻留在内存中的数据置换到事先配置在磁盘上的swap空间中
,然后空出来的这部分内存就可以用来分配了。这就是swap交换。swap交换往往会带来磁盘IO的大量消耗,严重影响到系统正常的磁盘io。
注:和虚拟内存的关系,参考什么是物理/虚拟/共享内存
三、用户空间与内核空间
Linux的进程是相互独立的,也叫做沙盒模式
,一个进程是不能直接操作或者访问别一个进程空间的。进程空间分为用户空间
和内核空间
,相当于把Kernel和上层的应用程序抽像的隔离开。
这里有两个隔离,一个进程间是相互隔离的,二是进程内有用户和内核的隔离。进程间的交互就叫进程间通信(IPC,或称跨进程通信),而进程内的用户和内核的交互就是系统调用。
- 进程间,用户空间的数据不可共享,所以
用户空间 = 不可共享空间
- 进程间,内核空间的数据可共享,所以
内核空间 = 可共享空间
- 所有进程共用1个内核空间
- 用户空间访问内核空间的唯一方式就是系统调用;通过这个统一入口接口,所有的资源访问都是在内核的控制下执行,以免导致对用户程序对系统资源的越权访问,从而保障了系统的安全和稳定。
进程内用户空间与 内核空间进行交互需通过系统调用两个函数。
copy_from_user()
:将用户空间的数据拷贝到内核空间
copy_to_user()
:将内核空间的数据拷贝到用户空间
四、Cache Page与Read/Write操作
由于有了Cache Page的存在,read/write系统调用会有以下的操作,我们那Read过程来进行说明:
- 用户进程向内核发起读取文件的请求,这涉及到用户态到内核态的转换。
- 内核读取磁盘文件中的对应数据,并把数据读取到Cache Page中。
- 由于Page Cache处在内核空间,不能被用户进程直接寻址 ,所以需要从Page Cache中拷贝数据到用户进程的堆空间中。
- 注意,这里涉及到了两次拷贝:第一次拷贝磁盘到Page Cache,第二次拷贝Page Cache到用户内存。最后物理内存的内容是这样的,同一个文件内容存在了两份拷贝,一份是页缓存,一份是用户进程的内存空间。
整个流程如下图所示:
可见我们平时所使用的read/write操作作对文件操作的过程中会涉及到两次拷贝的操作
!还有个缺陷,当频繁的调用系统的比方写操作,会导致用户态和内核态的频繁切换(即cpu在内核程序和用户程序不断的切换装载),系统性能会受到限制
。
而我们本章要讲的mmap操作,它读写效率更高,而且只涉及一次拷贝操作,IO读写效率远远高于read/write!
五、mmap的使用
mmap的函数位于 <sys/mman.h> 头文件中,它的函数原型如下:
// 用户进程调用, 函数用于将文件映射到内存
void* mmap(void addr, size_t length, int prot, int flags, int fd, off_t offset);
// 函数用于取消映射,进程在映射空间对共享内容的改变并不直接写回到磁盘文件中,往往在调用munmap() 后才执行该操作。
int munmap(void *addr, size_t length);
// 函数用于实现磁盘文件内容与共享内存区中的内容一致,即同步操作。
// 除了调用munmap取消映射,我们也可以调用msync()实现磁盘上文件内容与内核内存的内容一致
int msync(void * addr, size_t len, int flags);
-
mmap
函数用于将文件映射到内存 。
1. 用户进程调用进程内存映射函数库mmap,当前进程在线程虚拟地址空间中寻找一段空闲的满足要求的虚拟地址
2. 内核同样收到请求后会调用内核的mmap函数,实现地址映射关系配对,`即进程虚拟地址空间<< >>文件磁盘地址关系映射`,
`该映射与内核内存没有任何关联`。
munmap
函数用于取消映射,进程在映射空间的对共享内容的改变并不直接写回到磁盘文件中,往往在调用 munmap() 后才执行该操作。msync
函数用于实现磁盘文件内容与共享内存区中的内容一致,即同步操作,除了调用munmap取消映射,我们也可以调用msync()实现磁盘上文件内容与共享内存区的内容一致。
细节点一: mmap映射区域大小必须是物理页大小(page_size)的整倍数(在Linux中内存页通常是4k)
。原因是,内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。为了匹配内存的操作,mmap从磁盘到虚拟地址空间的映射也必须是页。
例如,有一个文件的大小是5K,mmap函数从文件的起始位置映射5K到虚拟内存中,由于内存物理页是4K,虽然映射的文件只有5K,但是实际上映射到内存区域的内存是8K,以便满足物理页大小的整数倍。映射后对5~8K的内存区域用零填充,对这部分的操作不会报错也不会写入到原文件中。
细节点二 : 映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关。同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。
六、案例:映射文件到内存
#include <sys/mman.h>
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
int main(int argc, char *argv[]){
const char * file = "/home/root/mmap.txt";
//打开文件,fd文件句柄
int fd = open(file, O_RDWR | O_CREAT |O_TRUNC,0644);
if(fd < 0){
printf("Can't open %s\n",file);
exit(0);
}
//修改文件默认大小为20字节
ftruncate(fd,20);
//使用mmap进行映射
char* mapped = (char *)mmap(NULL, 20 , PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
if(mapped== MAP_FAILED){
printf("File mmap failed\n");
exit(0);
}
//映射结束,关闭文件
close(fd);
//对映射内存进行修改,首地址写入Hello World
strcpy(mapped,"Hello World!");
//同步内存与文件
//只要文件映射存在,就可以通过msync将映射空间的内容写入文件,实现空间和文件的同步。
msync(mapped,20, MS_SYNC);
//释放映射区,取消映射
munmap(mapped, 20);
printf("mmap success \n");
return 0;
}
创建mmap.cpp,在里面输入上述代码,然后
touch mmap.cpp
#拷贝代码进去,保存
gcc mmap.cpp -o mmap
./mmap
程序运行成功输出mmap success,然后我们再打开mmap.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进程的代码可以直接访问
。这样便完成了一次进程间的通信。
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函数的使用,有兴趣的小伙伴可以自行查阅。
链接: