1.背景
该论文主要是解决redis在持久化(RDB,AOD)以及主从全量同步时都会调用fork来创建子进程获取内存快照,由于fork需要复制父进程页表,此时如果内存比较大,也就是我们常说的大key过多,就会导致fork这一过程太慢,从而影响redis整体的读写进程,导致读写时延大大增加。
阿里云的Async-fork正是改变将fork最耗时的复制父页表的过程从父进程转移到子进程,这样就能让我们的父进程尽快去处理新的读写请求。
2.前提知识
(前提知识仅是一提,如果有需要的同学可以去自行查阅)
2.1 页表
页表是一种特殊的数据结构,用于记录虚拟地址空间中的页面与物理内存中的页框之间的映射关系。每个进程都有自己的页表,它存储在系统空间的页表区内,并由PCB表中的指针指向。页表的主要作用是将逻辑地址转换成物理地址,实现虚拟内存的管理
2.2 内存页表
内存页表通常指的是存放在主存中的页表。当CPU需要将一个虚拟地址转换为物理地址时,会先通过页表查找对应的物理页框号,然后结合页内偏移量计算出最终的物理地址
2.3 多级分页
多级分页是为了解决单级页表在面对大容量内存时所占用的内存过大的问题。它将页表分为多个层次,每一级页表只包含下一级页表的索引,而不是直接包含所有页面的映射。这样可以减少单个页表的大小,提高内存的使用效率。
具体多级分页的方式64位和32位有所不同,我们仅以64位的操作系统为例
对于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原理相信最近面试的小伙伴也经常碰到,主要原理就是copy on write(写时复制)简单的说就是一开始父进程先复制页表给子进程,这样子进程就和父进程共享一块虚拟地址,但此时不分配物理地址。
在原生fork下,在父进程调用fork()创建子进程的过程中,虽然使用了写时复制页表的方式进行优化,但由于要复制父进程的页表,还是会造成父进程出现短时间阻塞,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长。
4. Async-fork
Async-fork的核心思想其实就是将复制父进程页表的过程转移到子进程中,但不是完全转移到子进程中,这样做就能极大减少父进程在复制页表内容时花的时间,让父进程尽快去处理请求,极大减少了尾部延迟
然而,Async-fork的实现过程中,实际工作并非描述的这么简单。页表的异步复制操作可能导致快照不一致。以下图为例,Redis在T0时刻保存内存快照,而某个用户请求在T2时刻向Redis插入了新的键值对(k2, v2),这将导致父进程修改它的页表项(PTE2)。假如T2时刻这个被修改的页表项(PTE2)还没有被子进程复制完成, 这个修改后的内存页表项及对应内存页后续将被复制到子进程,这个新插入的键值对将被子进程最终写入硬盘,破坏了快照一致性。(快照文件应该记录的是保存拍摄内存快照那一刻的内存数据)
图片来源于:
zhuanlan.zhihu.com/p/645845794
所以 Async-fork并非是将复制页表的任务全部交给父进程,我们在上述内容中提到,内存块分为VMA,VMA下多级分页PGD,PUD,PME,PTE,fork的思路是遍历VMA,然后在依次递归复制PGD,PUD,PME,PTE,而 Async-fork的思路是首先遍历VMA,再依次复制PGD,PUD,然后剩下的PME和PTE,交给子进程复制,这样还是有我们上述的问题,如果此时父进程修改某一页内存,破坏快照一致性应该怎么去处理。
图源来自《Async-fork: Mitigating Query Latency Spikes Incurred by the Fork-based Snapshot Mechanism from the OS Level》
解决方法是主动同步机制,具体来讲就是,父进程返回用户态收到写请求后可能会导致PTE改变,此时父进程会加入到复制过程中,也就是和子进程一起复制,来主动修改页表复制,保证子进程的快照一致性。值得注意的是当一个PTE被修改,父进程会复制整个PMD的512个PTE。
父进程检测到PTE被修改后,会检测子进程是否复制,如果已经复制,则避免重复复制,没有复制则进行复制。
Async-fork使用PMD项上的RW位来标记是否被复制。具体而言,当父进程第一次返回用户态时,它所有PMD项被设置为写保护(RW=0),代表这个PMD项以及它指向的512个PTE还没有被复制到子进程。当子进程复制一个PMD项时,通过检查这个PMD是否为写保护,即可判断该PMD是否已经被复制到子进程。如果还没有被复制,子进程将复制这个PMD,以及它指向的512个PTE。
在完成PMD及其指向的512个PTE复制后,子进程将父进程中的该PMD设置为可写(RW=1),代表这个PMD项以及它指向的512个PTE已经被复制到子进程。当父进程触发主动同步时,也通过检查PMD项是否为写保护判断是否被复制,并在完成复制后将PMD项设置为可写。同时,在复制PMD项和PTE时,父进程和子进程都锁定PTE表,因此它们不会出现同时复制同一PMD项指向的PTE。
这里就会出现一个问题Async-fork会可能发生错误,比如说内存不足,此时显然应该回滚到Async-fork之前的状态,但RW应该怎么处理?答案是应该将RW直接置为可写状态
5.总结
这篇论文本身还是非常有含金量的,建议大家去读一下,无论是开发还是内核都有很大的帮助