通过不同数据量下对比测试,我们可以看到,Async-fork 相比原生 fork,阻塞时间大大减少,性能提升非常明显。而且阻塞时间非常稳定,不会因为数据量的增长出现倍数级增长。
1、背景
在 Redis 中,在 AOF 文件重写、生成 RDB 备份文件以及主从全量同步过程中,都需要使用系统调用 fork 创建一个子进程来获取内存数据快照,在 fork() 函数创建子进程的时候,内核会把父进程的「页表」复制一份给子进程,如果页表很大,复制页表的过程耗时会非常长,那么在此期间,业务访问 Redis 读写延迟会大幅增加。
最近,阿里云联合上海交大,在数据库顶级会议 VLDB 上发表了一篇文章《Async-fork: Mitigating Query Latency Spikes Incurred by the Fork-based Snapshot Mechanism from the OS Level》,文章介绍到,他们设计了一个新的 fork(称为 Async-fork),将 fork 调用过程中最耗时的页表拷贝部分从父进程移动到子进程,父进程因而可以快速返回用户态处理用户查询,子进程则在此期间完成页表拷贝,从而减少 fork 期间到达请求的尾延迟。所以该特性在类似 Redis 类型的内存数据库上均能取得不错的效果。
2、基本概念
2.1 物理内存地址
也即实际的物理内存地址空间。
2.2 虚拟地址空间
虚拟地址空间(Virtual Address Space)是每一个程序被加载运行起来后,操作系统为进程分配的虚拟内存,它为每个进程提供了一个假象,即每个进程都在独占地使用主存。
每个进程所能访问的最大的虚拟地址空间由计算机的硬件平台决定,具体地说是由 CPU 的位数决定的。比如 32 位的 CPU 就是我们常说的 4GB 虚拟内存空间。
程序访问内存地址使用虚拟地址空间,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。这样,只要操作系统处理好虚拟地址到物理内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠,就可以达到内存地址空间隔离的效果。
当进程创建时,每个进程都会有一个自己的 4GB 虚拟地址空间。要注意的是这个 4GB 的地址空间是“虚拟”的,并不是真实存在的,而且每个进程只能访问自己虚拟地址空间中的数据,无法访问别的进程中的数据,通过这种方法实现了进程间的地址隔离。
对于 Linux,4GB 的虚拟地址空间包含用户态虚拟内存空间和内核态虚拟内存空间两部分,默认分配状态如下:
2.3 内存页表
「页表」保存的是虚拟内存地址与物理内存地址的映射关系。
CPU 访问数据的时候,CPU 发出的地址是虚拟地址,CPU 中内存管理单元(MMU)通过查询页表,把虚拟地址转换为物理地址,再去访问物理内存条。
2.3.1 内存分页
分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小,这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB。
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万(2^20)个页,每个「页表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。
这 4MB 大小的页表,看起来也不是很大。但是每个进程都是有自己的虚拟地址空间,也就说都有自己的页表。每个机器上同时运行多个进程,页表将占用大量内存。
2.3.2 多级页表
要解决上面提到的存储进程页表项占用大量内存空间的问题,就需要采用一种叫作多级页表(Multi-Level Page Table)的解决方案。
我们把这个 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个二级页表中包含 1024 个「页表项」,形成二级分页。这样,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。也就是,内存中只需要保存一级页表以及使用到的二级页表,大量的未被使用的二级页表则不需要分配内存并加载在内存中,因此,达到节省页表占用内存空间的目的。
对于 64 位的系统,使用四级分页目录,分别是:
- 页全局目录项 PGD(Page Global Directory);
- 页上级目录项 PUD(Page Upper Directory);
- 页中间目录项 PMD(Page Middle Directory);
- 页表项 PTE(Page Table Entry);
2.4 虚拟内存区域(VMA)
进程的虚拟内存空间包含一段一段的虚拟内存区域(Virtual memory area, 简称 VMA),每个 VMA 描述虚拟内存空间中一段连续的区域,每个 VMA 由许多虚拟页组成,即每个 VMA 包含许多页表项 PTE。
3、Fork 原理
在默认 fork 的调用过程中,父进程需要将许多进程元数据(例如文件描述符、信号量、页表等)复制到子进程,而页表的复制是其中最耗时的部分(占据 fork 调用耗时的 97% 以上)。
Linux 的 fork() 使用写时拷贝 (copy-on-write) 页的方式实现。写时拷贝是一种可以推迟甚至避免拷贝数据的技术。在创建子进程的过程中,操作系统会把父进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,此时,操作系统并不复制整个进程的物理内存,而是让父子进程共享同一个物理内存。同时,操作系统内核会把共享的所有的内存页的权限都设为 read-only。
那什么时候会发生物理内存的复制呢?
当父进程或者子进程在向共享内存发起写操作时,内存管理单元 MMU 检测到内存页是 read-only 的,于是触发缺页中断异常(page-fault),处理器会从中断描述符表(IDT)中获取到对应的处理程序。在中断程序中,内核就会把触发异常的物理内存页复制一份,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,于是父子进程各自持有独立的一份,之后进程才会对内存进行写操作,这个过程也被称为写时复制(Copy On Write)。