深入理解 mmap:提升 MQ 性能的关键技术

1,009 阅读14分钟

前言

在阅读kafka和RocketMQ相关的文章时,提到了通过使用mmap的方式提升写入速度。

那么什么是mmap,它又是怎么帮助MQ程序提升写入速度的呢?

抱着这个疑问,进行了资料的查阅整理,总结出了这篇文章。如果你也有这个疑问,希望能够对你有一些帮助。

1. IO方式对比

提升速度是一个相对的概念,指新的方式相对于原有方式的速度提升,所以我们先来对比一下两种方式的执行流程差异。

1.1 常规IO方式

在当前场景下,原有方式就是常规的write/read操作文件的方式,先来看下它的整体流程。

read+write文件 (1).png

  1. 进行发起读文件请求
  2. 进入内核态,查找进程文件符表,定位文件的inode
  3. 通过inode结构中的radix tree确定请求的文件页是否在page cache中
  4. 存在则直接返回,不存在则通过缺页中断,定位磁盘位置,将磁盘内容加载到page cache中在进行返回
  5. 将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使用物理内存做媒介,建立了进程虚拟内存与文件的映射关系,如下图所示:

mmap映射关系.png

也就是说,建立了映射以后,在程序中操作内存就几乎等同于在操作文件。 这样的方式,避免了两态之间「数据的拷贝」和「上下文切换」的资源消耗,从而达到高效率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如下:

  1. PROT_EXEC 可执行
  2. PROT_READ 可读
  3. PROT_WRITE 可写
  4. PROT_NONE 不可访问

常用的flags如下:

  1. MAP_SHARED:共享映射 进程间交互数据
  2. MAP_PRIVATE:私有映射
  3. MAP_ANONYMOUS:匿名映射 fd必须为空
  4. 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后,流程图所下所示:

mmap调用流程.jpg

  1. 在进程的虚拟内存地址空间中,寻找一段满足要求的空闲地址
  2. 若是匿名映射,通过mm_struce->get_unmapped_area进行映射一个vma结构,跳到步骤6
  3. 若为文件映射,则调用file->file_operations->thp_getunmmaped_area,其实内部也是步骤2,进行映射一个vma结构
  4. 建立vma->vm_file和struct file的映射关系,完成内存到文件的映射
  5. 调用file->file_operations->mmap,虚拟文件系统定位寻找文件对应的inode,建立文件和虚拟内存的映射
  6. 将vma挂到struct file->address_space->i_mmap这颗红黑树上
  7. 调用结束

上述步骤中,除了第5步都是在用户态进行的,第5步要调用系统函数mmap,会进入到内核态中。

而mmap api调用结束后仅仅是建立了映射关系,但是实际并没有分配物理内存,需要发起对这片映射空间访问时,通过引发缺页中断,实现内容的加载。

2.3 页中断

页中断产生的情况,大致有几种类型:

异常来源中断后要做的动作场景
缺页中断匿名映射时,分配物理页面并设置虚拟地址和物理地址的映射;文件映射时,从磁盘中将数据按页读入内存中。匿名映射&文件映射
写保护中断写时复制时,则进行复制并设置标识为可写;写异常则触发SIGSEGV信号私有映射
没有访问权限触发SIGSEGV信号非法访问

2.3.1 缺页中断

进程访问虚拟内存的某个地址,发现没有对应的物理页面时,就会产生缺页中断

  1. 如果没有给vma分配物理页,被动触发分配物理内存页面;
  2. 当物理页面被交换出去,需要将数据从swap文件中重新读入内存;
  3. 文件映射页表未加载的时候,需要将数据按页读入内存中;

第一种情况,分配一个物理页即可;第二三种情况,需要将文件内容载入到物理页中,若未分配物理页则需再分配一个。

三种情况的详细处理流程图如下

大致了解过程逻辑即可,不需要抠细节

缺页中断V2.jpg

2.3.2 写保护中断

举一个父子进程之间产生写保护中断,从而触发写时复制的例子,来清晰这个过程。

第一步,父进程调用fork以后,会执行如下操作:

  1. 复制PCB
  2. 复制页表和PCB的vma数组
  3. 把正常状态的数据段/堆/栈的虚拟内存页,设置为不可写
  4. 把已经映射的物理页面的引用计数+1

由于没有复制实际的物理页给子进程,所以父子进程是公用的之前父进程分配的物理内存页面。

第二步,当子进程/父进程要进行物理页面的修改时,由于判断物理页只读,而vma可写,所以是写保护中断情况,就调用do_wp_page来处理中断,判定流程如下:

  1. 判断物理地址的引用计数
  2. 如果大于1,说明是共享中的
  3. 为发生中断的进程再分配一个物理页面,内容拷贝过去
  4. 发生中断的虚拟地址映射到新的物理页
  5. 物理页引用-1
  6. 修改父进程PTE的权限,由只读变为读写

3. 使用场景

本小节根据2种权限和2种映射方式形成的四种组合方式,来分别介绍四种方式的应用场景是什么样的。

3.1 私有&匿名映射

3.1.1 应用场景

申请小块内存可以使用brk方式,而在堆上分配大块的可用内存则可以使用mmap的私有匿名映射方式。

3.1.2 调用流程

在文件映射区域分配一块内存,创建对应的vma结构就结束了。实际分配物理内存是当访问的时候产生缺页中断后进行分配的,流程如下:

  1. 当访问虚拟内存时,发现没有映射到物理内存上
  2. 发生缺页中断(页面未映射)
  3. 判断关联的文件属性是空
  4. 调用do_anonymous_page分配物理内存,将整个物理页初始化为0,建立映射关系

3.2 私有&文件映射

3.2.1 应用场景

  1. 用于加载动态库
  2. fork进程后的写时复制,父子进程数据变动时隔离

3.2.2 调用流程

调用mmap建立虚拟内存与文件映射后,并没有将文件的内容加载到内存当中。

所以当进行read/write调用时,会发生缺页中断(页面内容不在内存中),然后将对应页的数据载入物理内存中,并建立虚拟内存与物理内存的映射关系,具体流程如图所示:

mmap写中断.png

将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 存在的问题

  1. 虚拟内存和物理内存的映射要以页为单位,所以映射的区域大小必须是物理页的「整数倍」。
  2. 读写数据的范围,需要在一开始调用时mmap建立的映射范围内,文件变长增长超出该范围则操作「无法映射」。
  3. 不适合更新操作频繁且是随机写的情况,这样会导致大量脏页回写以及引起随机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。