mmap是零拷贝技术的一种,大量应用于Android的机制中,例如Binder,以及一些高性能存储方案MMKV、高性能日志写入方案mars-xlog均使用了mmap来实现。在这里将从两种角度来解析mmap,一是零拷贝技术是什么,二是mmap的概念原理。
一、零拷贝技术§
零拷贝是一种高性能服务器方案的关键技术,在了解零拷贝技术之前需要先了解用户空间和内核空间。
1.用户空间和内核空间§
操作系统将虚拟地址空间划分为两部分,一部分是内核空间,另一部分是用户控件,在Linux这边,最高的1G字节(0xC0000000 ~ 0xFFFFFFFF)被内核使用,称作为内核空间,而较低的3G字节(0x00000000 ~ 0xBFFFFFFF)由各自的进程去使用,称作为用户控件,如此来保证内核的安全。
当进程运行在内核空间时,就处于内核态;当进程运行在用户控件时,它就处于用户态。
当应用程序需要进行IO操作时,进程需要通过上下文的切换来切换到内核态,再后通过切换上下文切换到用户态,再对获取到的数据进行操作。
2.文件传输§
介绍完用户态和内核态后,来讲一下,服务端发送一个文件给客户端需要经历什么?其中大致的步骤如下:
- 调用read读取文件的数据到用户空间缓冲区中
- 调用write把缓冲区的数据发送给客户端的socket
这个复制过程,调用read时会先从文件中读取数据到内核的页缓存(Page Cache)中,再从页缓存中复制到用户控件的缓冲区中。调用write时,把用户空间缓冲区中的数据发送到客户端的Socket时,会率先把缓冲区的数据复制到内核的Socket缓冲区中,硬件会把Socket缓冲区的数据做进一步处理。

如下图的可以看出,服务端发送文件给客户端的过程中需要进行两次数据的复制,第一次是从内核空间的页缓存复制到用户空间的缓冲区,第二次是从用户空间的缓冲区复制到内核空间的Socket缓冲区。

而且是这个过程中页缓存其实是可以直接复制到Socket缓冲区,不需要复制到用户空间缓冲区。完全脱离用户空间作为中转站的技术叫做零拷贝技术。在Linux操作系统中可以调用sendfile来实现这种能力。

/* sendfile.c
Implement sendfile() in terms of read(), write(), and lseek().
@params out_fd 数据接收方文件句柄
@params in_fd 数据提供方文件句柄
@params offset 发送数据的偏移量
@params count 需要发送多少字节的数据
*/
#include "tlpi_hdr.h"
#define BUF_SIZE 8192
ssize_t
sendfile(int out_fd, int in_fd, off_t *offset, size_t count)
{
off_t orig;
if (offset != NULL) {
/* Save current file offset and set offset to value in '*offset' */
orig = lseek(in_fd, 0, SEEK_CUR);
if (orig == -1)
return -1;
if (lseek(in_fd, *offset, SEEK_SET) == -1)
return -1;
}
size_t totSent = 0;
while (count > 0) {
size_t toRead = min(BUF_SIZE, count);
char buf[BUF_SIZE];
ssize_t numRead = read(in_fd, buf, toRead);
if (numRead == -1)
return -1;
if (numRead == 0)
break; /* EOF */
ssize_t numSent = write(out_fd, buf, numRead);
if (numSent == -1)
return -1;
if (numSent == 0) /* Should never happen */
fatal("sendfile: write() transferred 0 bytes");
count -= numSent;
totSent += numSent;
}
if (offset != NULL) {
/* Return updated file offset in '*offset', and reset the file offset
to the value it had when we were called. */
*offset = lseek(in_fd, 0, SEEK_CUR);
if (*offset == -1)
return -1;
if (lseek(in_fd, orig, SEEK_SET) == -1)
return -1;
}
return totSent;
}
对比起之前的流程,使用sendfile可以减少一次系统调用,减少一次数据拷贝的过程。现有的零拷贝技术不只是sendfile还有mmap、splice等。
二、mmap概念§

mmap是一种内存映射文件的方法,讲一个文件对象或者其他对象映射到晋城的地址空间,实现稳健磁盘地址和晋城虚拟地址空间中的一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针来读写操作这一段内存,系统会自动回写脏页面到对应的文件磁盘上,完成了绕过read和write函数操作文件的能力。反之,内核空间对这段区域的修改也可以直接反向映射到用户空间,实现不同进程间的文件共享的目的。
上图可见进程的虚拟地址空间,是由多个虚拟内存区域构成,虚拟内存区域是进程的虚拟地址空间的一个具有同样特性的连续地址范围。
Linux kernel使用vm_area_struct结构来表示一个独立的虚拟内存区域,由于每个不同质的虚拟内存区域功能和内部机制都不同,因此一个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域,各个vm_area_struct结构使用链表 / 树形结构来链接,方便进程快速访问。
vm_area_struct中包含区域起始和终止地址以及其他相关信息,同时也包含一个vm_ops指针,其内部可引出所有针对这个区域可以使用的系统调用函数。这样,进程对某一虚拟内存区域的任何操作需要用要的信息,都可以从vm_area_struct中获得。mmap函数就是要创建一个新的vm_area_struct结构,并将其与文件的物理磁盘地址相连。具体步骤请看下一节。
三、mmap内存映射原理§
1.mmap函数§
#include <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);
2.创建虚拟映射区域§
- 进程在用户控件调用mmap函数库,在当前进程的虚拟地址空间寻找一段满足要求的连续空间
- 为这个虚拟区分配一个vm_area_struct结构,接着对这个结构的各个域进行初始化
- 将新建的虚拟区结构插入进程的虚拟地址区域的链表 / 树中
3.实现物理地址和虚拟地址的映射关系§
- 在映射分配了新的虚拟地址区域后,通过待映射的文件指针,在文件描述符表中找到对应的文件描述符,通过文件描述符,链接到内核中该文件的文件结构体(struct file | 用来维护这个已打开文件相关的信息)
- 通过该文件的文件结构体,链接到file_operations模块,调用内核函数mmap
- 内核mmap函数通过虚拟文件系统inode模块定位到文件磁盘物理地址
- 通过remap_pfn_range函数建立页表,实现文件地址和虚拟地址区域映射关系
4.实现文件内容到主存的拷贝§
- 进程的读写操作访问虚拟地址空间这一段映射地址,通过查询页表,发现这一段地址并不在物理页面上,因为目前只建立了地址映射,真正的硬盘数据还没拷贝到内存中,引发了缺页异常
- 缺页异常经过判断,确定无非法操作后,内核发起请求调页过程
- 调页过程现在交换缓存空间(swap cache)中寻找需要访问的内存页,如果没有则调用的nopage函数把所缺的页从磁盘存入主存
- 此后,进程就可以对这片主存进行读写操作,如果改变了内容,一定时间后系统会自动地回写脏页面到对应的磁盘地址上,完成文件写入流程
四、mmap操作文件§
Linux文件系统中常规的文件操作流程:
- 进程发起文件操作请求
- 内核通过查找进程文件符表,定位到内核已打开的文件集上的文件信息,从而找到文件的inode
- inode在address_space上查找要请求的文件页是否已经换存在页缓存中
- 如果存在在页缓存中直接返回这片文件页的内容
- 如果不存在,通过inode定位到文件磁盘地址,从磁盘中复制到页缓存,之后再次发起读取页面过程,将页缓存的数据发给用户进程
这么设计的原因是为了提高读写效率和保护磁盘,使用了页缓存,但是这样导致了无论读写都需要经历两次拷贝流程。
mmap操作流程:
- 用户进程调用mmap(),从用户态陷入内核态,将内核缓冲区映射到用户缓冲区
- DMA使用页缓存机制将数据从主存拷贝到内核缓冲区
- mmap()返回,上下文从内核态切换到用户态
- 用户进程调用write(),把文件数据写到内核的套接字缓冲区,进而陷入内核态
- CPU将内核缓冲区的数据拷贝到套接字缓冲区
- DMA将数据从套接字缓冲区拷贝到硬件设备上进程进一步处理
- write()返回,上下文从内核态切换到用户态
使用mmap操作文件的过程中,创建新的虚拟内存区域、建立文件磁盘地址和虚拟内存区域映射,没有任何的文件拷贝操作,而此后访问数据时发现内存中并没有数据而发起缺页异常过程,通过一句建立好的映射关系,只使用一次数据拷贝即可从磁盘中将数据传入内存的用户空间中,给进程使用,这样只经历一次数据拷贝过程的mmap效率更高。
五、mmap优势§
1.简化进程编程§
使用mmap机制,磁盘上的文件就像是直接存入在内存中,把访问磁盘上的文件简化成为安迪之访问内存,这样以来,进程就不需要使用文件系统的write、read、fsync等系统调用,只需要面向内存的虚拟空间进行开发。但这不意味着不再需要进行这些系统调用,这些系统调用流程其实是在mmap中做了一套独立的封装。
缺页异常§
为了节约物理内存同时mmap方法快速返回的目的,mmap映射采用懒加载机制。具体来说,通过 mmap 申请 1000G 内存可能仅仅占用了 100MB 的虚拟内存空间,甚至没有分配实际的物理内存空间。当你访问相关内存地址时,才会进行真正的 write、read 等系统调用。CPU 会通过陷入缺页异常的方式来将磁盘上的数据加载到物理内存中,此时才会发生真正的物理内存分配。
数据一致性由OS确保§
当发生数据修改时,内存出现脏页,与磁盘文件出现不一致。mmap 机制下由操作系统自动完成内存数据落盘(脏页回刷),用户进程通常并不需要手动管理数据落盘。
2.避免内核空间到用户空间的数据拷贝§
mmap 被认为快的原因是因为建立了页到用户进程的虚地址空间映射,以读取文件为例,避免了页从内核空间拷贝到用户空间。
3.避免只读操作时的swap操作§
虚拟内存带来了种种好处,但是一个最大的问题在于所有进程的虚拟内存大小总和可能大于物理内存总大小,因此当操作系统物理内存不够用时,就会把一部分内存 swap 到磁盘上。
在 mmap 下,如果虚拟空间没有发生写操作,那么由于通过 mmap 操作得到的内存数据完全可以通过再次调用 mmap 操作映射文件得到。但是,通过其他方式分配的内存,在没有发生写操作的情况下,操作系统并不知道如何简单地从现有文件中(除非其重新执行一遍应用程序,但是代价很大)恢复内存数据,因此必须将内存 swap 到磁盘上。
4.节约内存§
由于用户空间与内核空间实际上共用同一份数据,因此在大文件场景下在实际物理内存占用上有优势。
六、mmap的缺陷§
- mmap使用时必须实现指定好内存映射的大小,因此 mmap 并不适合变长文件
- 更新文件的操作很多,mmap避免两态拷贝的优势就被摊还,最终还是落在了大量的脏页回写及由此引发的随机 I/O 上,所以在随机写很多的情况下,mmap方式在效率上不一定会比带缓冲区的一般写快
- 读/写小文件(例如 16K 以下的文件),mmap 与通过 read 系统调用相比有着更高的开销与延迟;同时 mmap 的刷盘由系统全权控制,但是在小数据量的情况下由应用本身手动控制更好
- mmap 受限于操作系统内存大小:例如在 32-bits 的操作系统上,虚拟内存总大小也就 2GB,但由于 mmap 必须要在内存中找到一块连续的地址块,此时你就无法对 4GB 大小的文件完全进行 mmap,在这种情况下你必须分多块分别进行 mmap,但是此时地址内存地址已经不再连续,使用 mmap 的意义大打折扣,而且引入了额外的复杂性
七、mmap适用场景§
- 多个线程以只读的方式同时访问一个文件,这是因为mmap机制下多线程共享了同一物理内存空间,因此节约了内存
- mmap非常适合用于进程间通信,这是因为对同一文件对应的mmap分配的物理内存天然多线程共享,并可以依赖于操作系统的同步原语
- mmap虽然比sendfile等机制多了一次CPU全程参与的内存拷贝,但是用户空间与内核空间并不需要数据拷贝,因此在正确使用情况下并不比sendfile效率差