前言
在阅读kafka和RocketMQ相关的文章时,提到了通过使用mmap的方式提升写入速度。
那么什么是mmap,它又是怎么帮助MQ程序提升写入速度的呢?
抱着这个疑问,进行了资料的查阅整理,总结出了这篇文章。如果你也有这个疑问,希望能够对你有一些帮助。
1. IO方式对比
提升速度是一个相对的概念,指新的方式相对于原有方式的速度提升,所以我们先来对比一下两种方式的执行流程差异。
1.1 常规IO方式
在当前场景下,原有方式就是常规的write/read操作文件的方式,先来看下它的整体流程。
- 进行发起读文件请求
- 进入内核态,查找进程文件符表,定位文件的inode
- 通过inode结构中的radix tree确定请求的文件页是否在page cache中
- 存在则直接返回,不存在则通过缺页中断,定位磁盘位置,将磁盘内容加载到page cache中在进行返回
- 将page cache内容copy到进程的内存空间当中
从上述流程中可以看到,不论是read还是write都是与page cache交互。
read操作需要进行用户态和内核态切换两次,数据拷贝流程是「磁盘」->「page cache」->「进程内存」,共计两次拷贝。
假设页数据不在page cache中
完整的文件read+write操作,需要进行用户态和内核态切换四次,数据拷贝流程是「磁盘」->「page cache」->「进程内存」->「page cache」->「磁盘」,共计四次拷贝
write的目标源可以是文件,也可以是通过网卡发送数据。
1.2 mmap的IO方式
了解了传统方式的io流程后,再来看下mmap方式是如何在这个基础上进行改进的。
由于read/write操作都需要和page cache进行打交道,而page cache属于内核态的内存,进程无法操作。
也就需要不断将数据在内核态的page cach与用户态的进程内存之间进行来回拷贝,这样不但多出了数据拷贝的内存占用,更多出了拷贝数据时用户态和内核态的上下文切换损耗。
针对这点mmap使用物理内存做媒介,建立了进程虚拟内存与文件的映射关系,如下图所示:
也就是说,建立了映射以后,在程序中操作内存就几乎等同于在操作文件。 这样的方式,避免了两态之间「数据的拷贝」和「上下文切换」的资源消耗,从而达到高效率io的目的。
2 使用方式
介绍完了mmap优化的点,来看下它的api怎么调用、以及调用后的内部流程逻辑是怎么样的。
2.1 API
#include <unistd.h>
#include <sys/mman.h>
void* mmap(void* addr, size_t length, int prot, int flags, int fd, off_t offset);
2.1.1 参数说明
- addr:该区域的起始地址
- length:代表区域的长度
- prot:这块内存区域的访问权限
- flags:描述内存区域的类型
- fd:文件描述符
- offset:代表文件内的偏移值
常用的prot如下:
- PROT_EXEC 可执行
- PROT_READ 可读
- PROT_WRITE 可写
- PROT_NONE 不可访问
常用的flags如下:
- MAP_SHARED:共享映射 进程间交互数据
- MAP_PRIVATE:私有映射
- MAP_ANONYMOUS:匿名映射 fd必须为空
- MAP_FIXED:强制指定起始地址
从上述参数,我们可以看出来,api的作用是将一段区域映射到内存中,并设置该内存的类型和权限。
从内存的权限上分为:
- 私有
- 共享
从内存的映射方式上来说
- 匿名映射
- 文件映射
那么即有2 x 2四种方式进行组合使用,每种方式后面会结合其使用场景进行详细说明。
2.1.2 调用demo
#include <sys/mman.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid;
// 创建可读可写的 共享匿名映射的内存区域
char* shm = (char*)mmap(0, 4096, PROT_READ | PROT_WRITE,
MAP_SHARED | MAP_ANONYMOUS, -1, 0);
if (!(pid = fork())){
sleep(1);
printf("child got a message: %s\n", shm);
sprintf(shm, "%s", "hello, father.");
exit(0);
}
sprintf(shm, "%s", "hello, my child");
sleep(2);
printf("parent got a message: %s\n", shm);
return 0;
}
2.2 调用流程剖析
在程序调用了api以后,它内部都做了哪些事儿、都是怎么做的呢,这一小节来揭开它的面纱。
2.2.1 相关的数据结构
剖析流程之前,需要先了解操作过程中用到了操作系统中的哪些结构体,下面贴出主要的几个结构体。
1. task_struct
进程控制块,也称为PCB。 详细结构见这里
struct task_struct {
struct mm_struct *mm;
struct mm_struct *active_mm;
struct fs_struct *fs;
struct files_struct *files;
...
}
2. mm_struct
是每个进程的内存描述符,是PCB的一个属性。
struct mm_struct {
struct vm_area_struct *mmap; // vma链表
struct rb_root mm_rb; // 红黑树用于查找vma
int map_count; // vma数量
...
}
3. vm_area_struct
简称vma,就是上面mm_struct中的*mmap,用于内核管理进程地址空间使用的数据结构。
它不仅可以将物理内存和虚拟内存映射,还可以将文件内容和虚拟内存进行映射(可以看到*vm_file这个属性),这样就能达到访问内存等同于访问文件的效果。
它的结构定义大致如下,详细数据结构见 这里:
struct vm_area_struct {
unsigned long vm_start; // 区间首地址
unsigned long vm_end; // 区间尾地址
pgprot_t vm_page_prot; // 访问控制权限
unsigned long vm_flags; // 标志位
struct file *vm_file; // 被映射的文件
unsigned long vm_pgoff; // 文件中的偏移量
...
}
4. file
进程打开了一个文件,PCB中就会有一个struct file与这个文件对应。
struct file {
union {
struct llist_node f_llist;
struct rcu_head f_rcuhead;
unsigned int f_iocb_flags;
};
struct path f_path;
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;
u64 f_version;
void *f_security;
struct hlist_head *f_ep;
struct address_space *f_mapping;
...
}
5. inode
这个结构与具体的磁盘文件是对应的,同一个文件只会有一个inode结构
2.2.2 api内部操作
在程序中调用mmap的api后,流程图所下所示:
- 在进程的虚拟内存地址空间中,寻找一段满足要求的空闲地址
- 若是匿名映射,通过mm_struce->get_unmapped_area进行映射一个vma结构,跳到步骤6
- 若为文件映射,则调用file->file_operations->thp_getunmmaped_area,其实内部也是步骤2,进行映射一个vma结构
- 建立vma->vm_file和struct file的映射关系,完成内存到文件的映射
- 调用file->file_operations->mmap,虚拟文件系统定位寻找文件对应的inode,建立文件和虚拟内存的映射
- 将vma挂到struct file->address_space->i_mmap这颗红黑树上
- 调用结束
上述步骤中,除了第5步都是在用户态进行的,第5步要调用系统函数mmap,会进入到内核态中。
而mmap api调用结束后仅仅是建立了映射关系,但是实际并没有分配物理内存,需要发起对这片映射空间访问时,通过引发缺页中断,实现内容的加载。
2.3 页中断
页中断产生的情况,大致有几种类型:
| 异常来源 | 中断后要做的动作 | 场景 |
|---|---|---|
| 缺页中断 | 匿名映射时,分配物理页面并设置虚拟地址和物理地址的映射;文件映射时,从磁盘中将数据按页读入内存中。 | 匿名映射&文件映射 |
| 写保护中断 | 写时复制时,则进行复制并设置标识为可写;写异常则触发SIGSEGV信号 | 私有映射 |
| 没有访问权限 | 触发SIGSEGV信号 | 非法访问 |
2.3.1 缺页中断
进程访问虚拟内存的某个地址,发现没有对应的物理页面时,就会产生缺页中断
- 如果没有给vma分配物理页,被动触发分配物理内存页面;
- 当物理页面被交换出去,需要将数据从swap文件中重新读入内存;
- 文件映射页表未加载的时候,需要将数据按页读入内存中;
第一种情况,分配一个物理页即可;第二三种情况,需要将文件内容载入到物理页中,若未分配物理页则需再分配一个。
三种情况的详细处理流程图如下
大致了解过程逻辑即可,不需要抠细节
2.3.2 写保护中断
举一个父子进程之间产生写保护中断,从而触发写时复制的例子,来清晰这个过程。
第一步,父进程调用fork以后,会执行如下操作:
- 复制PCB
- 复制页表和PCB的vma数组
- 把正常状态的数据段/堆/栈的虚拟内存页,设置为不可写
- 把已经映射的物理页面的引用计数+1
由于没有复制实际的物理页给子进程,所以父子进程是公用的之前父进程分配的物理内存页面。
第二步,当子进程/父进程要进行物理页面的修改时,由于判断物理页只读,而vma可写,所以是写保护中断情况,就调用do_wp_page来处理中断,判定流程如下:
- 判断物理地址的引用计数
- 如果大于1,说明是共享中的
- 为发生中断的进程再分配一个物理页面,内容拷贝过去
- 发生中断的虚拟地址映射到新的物理页
- 物理页引用-1
- 修改父进程PTE的权限,由只读变为读写
3. 使用场景
本小节根据2种权限和2种映射方式形成的四种组合方式,来分别介绍四种方式的应用场景是什么样的。
3.1 私有&匿名映射
3.1.1 应用场景
申请小块内存可以使用brk方式,而在堆上分配大块的可用内存则可以使用mmap的私有匿名映射方式。
3.1.2 调用流程
在文件映射区域分配一块内存,创建对应的vma结构就结束了。实际分配物理内存是当访问的时候产生缺页中断后进行分配的,流程如下:
- 当访问虚拟内存时,发现没有映射到物理内存上
- 发生缺页中断(页面未映射)
- 判断关联的文件属性是空
- 调用do_anonymous_page分配物理内存,将整个物理页初始化为0,建立映射关系
3.2 私有&文件映射
3.2.1 应用场景
- 用于加载动态库
- fork进程后的写时复制,父子进程数据变动时隔离
3.2.2 调用流程
调用mmap建立虚拟内存与文件映射后,并没有将文件的内容加载到内存当中。
所以当进行read/write调用时,会发生缺页中断(页面内容不在内存中),然后将对应页的数据载入物理内存中,并建立虚拟内存与物理内存的映射关系,具体流程如图所示:
将PCB中的struct file --> inode 建立对应关系。
inode结构中包含一个hash table 页号为key,物理内存页为value,用于保存已经加载完成的数据页
hash table 已经被优化为radix tree
只需要第一个请求加载后,后续请求直接建立虚拟地址和物理页的映射关系即可。
这样做不但提升了加载速度。而且多个程序依赖同一个动态库时,动态库也仅仅占用一份内存。当有程序修改动态库时,再触发写时复制(写保护中断),在程序的进程中copy一份动态库内容进行修改。
3.3 共享&文件映射
3.3.1 应用场景
用于多进程间的通信
加载过程和私有文件映射一样,差别就是某个进程修改内容的时候,不会触发写保护中断,多个进程依然共用相同的物理页面。
3.4 共享匿名映射
3.4.1 应用场景
父子进程进行通信。
3.4.2 内部实现
早期的linux内核不支持共享匿名映射。原因是父进程创建一个共享匿名映射后,这时未分配实际的物理内存页面,再fork出一个子进程,仅仅复制了父进程的vma结构。再后续当父进程访问了这个vma产生缺页中断,分配了实际的物理页面以后,这时对于子进程是无感知的,没有办法也把子进程中的vma映射到父进程已分配的物理页上。
后面内核使用了虚拟文件系统来解决这个问题,在虚拟文件系统中创建一个inode结构(磁盘中不存在),将这个inode和vma建立起关联。在fork出子进程后,由于父进程的vma和子进程的vma都映射到了这个虚拟的inode上,后续操作就和共享文件映射一样了。
4 存在的问题
- 虚拟内存和物理内存的映射要以页为单位,所以映射的区域大小必须是物理页的「整数倍」。
- 读写数据的范围,需要在一开始调用时mmap建立的映射范围内,文件变长增长超出该范围则操作「无法映射」。
- 不适合更新操作频繁且是随机写的情况,这样会导致大量脏页回写以及引起随机IO,导致减少两态间拷贝的优势被摊还。
5 MQ中的使用
5.1 消息写入
在RocketMQ和kafka中,都使用mmap建立虚拟内存地址和文件磁盘地址进行关联,直接以mmap+write通过偏移量形式进行写入,而不用进行read的系统调用,减少了数据在两态之间的拷贝,加快写入速度。
RocketMQ中具体方式是,每个mmap文件(MappedFile)为1G,把多个mmap文件用链表串起来做一个逻辑队列(MappedFileQueue),就实现了一个无需考虑长度的存储空间,从而用来保存全部的消息。
由于在mmap调用后仅建立了映射关系,实际加载数据还需要通过缺页中断来实现,RocketMQ为了避免切换MappedFile后产生大量缺页中断,mmap的同时进行了madvise调用,以达到预热内存的目的。
5.2 消息拉取
由于kafka和RocketMQ在设计之初背景不同,所以很多地方的实现方式也不同
- kafka目标是topic不那么多,需要高吞吐的大块日志消息文件
- RocketMQ目标是topic较多,针对业务级小数据块做低时延、高频率的传输。
所以,在消息拉取上kafka使用了sendfile阻塞式IO作为零拷贝方式,而RocketMQ选择了mmap+write非阻塞式IO(基于多路复用)作为零拷贝方式。
个人认为,RocketMQ的这种选择和它自身的存储逻辑也有关系。由于它将所有topic的消息混合写在一个文件中,所以在拉取消息的时候,首先要根据ConsumerQueue文件获取消息在commitLog中的偏移量和长度,再根据偏移量和长度再去读取对应的消息。这样在消息拉取的过程中,commitLog的偏移量会不断的跳跃变化,所以更适合mmap+write的方式。
而sendfile的方式更适合于一个文件中仅存在一个topic,且无需进行消息过滤的场景,直接根据偏移量和长度批量读取到消息以后,原封不动的将数据直接发送给consumer。
总结
mmap是通过建立虚拟内存地址到文件磁盘地址的映射,配合页中断和文件机制来实现了各种功能。
在做文件映射时,可以减少两态之间的数据拷贝和上下文切换,减少了cpu和内存资源的开销,以达到数据高效read/write目的。
在MQ的场景下,由于自身场景随机读写少,再配合预创建mmap文件和内存预热,就可以达到提升IO效率的目的了。
RocketMQ中消息写入磁盘和向消费者发送消息都使用了mmap+write方式,而kafka只在消息写入使用了mmap。