5.2 Async-fork详解
前面提到,每个进程都有自己的虚拟内存空间,Linux使用一组虚拟内存区域VMA来描述进程的虚拟内存空间,每个VMA包含许多页表项。
在默认fork中,父进程遍历每个VMA,将每个VMA复制到子进程,并自上而下地复制该VMA对应的页表项到子进程,对于64位的系统,使用四级分页目录,每个VMA包括PGD、PUD、PMD、PTE,都将由父进程逐级复制完成。在Async-fork中,父进程同样遍历每个VMA,但只负责将PGD、PUD这两级页表项复制到子进程。
随后,父进程将子进程放置到某个CPU上使子进程开始运行,父进程返回到用户态,继续响应用户请求。由子进程负责每个VMA剩下的PMD和PTE两级页表的复制工作。
如果在父进程返回用户态后,子进程复制内存页表期间,父进程需要修改还未完成复制的页表项,怎样避免上述提到的破坏快照一致性问题呢?
5.2.1 主动同步机制
父进程返回用户态后,父进程的PTE可能被修改。如果在子进程复制内存页表期间,父进程检测到了PTE修改,则会触发主动同步机制,也就是父进程也加入页表复制工作,来主动完成被修改的相关页表复制,该机制用来确保PTE在修改前被复制到子进程。
当一个PTE将被修改时,父进程不仅复制这一个PTE,还同时将位于同一个页表上的所有PTE(一共512个PTE),连同它的父级PMD项复制到子进程。
父进程中的PTE发生修改时,如果子进程已经复制过了这个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。
在操作系统中,PTE的修改分为两类:
● VMA级的修改。例如,创建、合并、删除VMA等操作作用于特定VMA上,VMA级的修改通常会导致大量的PTE修改,因此涉及大量的PMD。
● PMD级的修改。PMD级的修改仅涉及一个PMD。
5.2.2 错误处理
Async-fork在复制页表时涉及到内存分配,难免会发生错误。例如,由于内存不足,进程可能无法申请到新的PTE表。当错误发生时,应该将父进程恢复到它调用Async-fork之前的状态。
在Async-fork中,父进程PMD项目的RW位可能会被修改。因此,当发生错误时,需要将PMD项全部回滚为可写。
6. Redis优化实践
6.1 Async-fork 阻塞现象
在支持Async-fork的操作系统(即Tair专属操作系统镜像)机器上测试,理论上来说,按照文章的预期,用户不需要作任何修改(Async-fork使用了原生fork相同的接口,没有另外新增接口),就可以享受Async-fork优化带来的优势,但是,使用Redis实际测试过程中,结果不符合预期,在Redis压测过程中手动执行bgsave命令触发fork操作,还是观察到了TP100抖动现象。
● 测试环境
Redis版本:优化前Redis-Server
机器操作系统:Tair专属操作系统镜像
测试数据量:54.38G
127.0.0.1:6679> info memory # Memory used_memory:58385641120 used_memory_human:54.38G
● 问题现象
现象:fork耗时正常,但是压测过程中执行bgsave,TP100不正常
在压测过程中执行bgsave,使用 info stats 返回上次fork耗时:latest_fork_usec:426
TP100结果如下:
# 压测过程中执行 bgsave [root@xxx ~]# /usr/bin/Redis-benchmark -d 256 -t set -n 1000000 -a xxxxxx -p 6679 ====== SET ====== 1000000 requests completed in 7.88 seconds 50 parallel clients 256 bytes payload keep alive: 1 100.00% <= 411 milliseconds 100.00% <= 412 milliseconds 100.00% <= 412 milliseconds 126871.35 requests per second
也就是说,观察到的fork耗时正常,但是压测过程中Redis依然出现了尾延迟,这显然不符合预期。
● 追踪过程
使用 strace 命令进行分析,结果如下:
$ strace -p 32088 -T -tt -o strace00.out 14:18:12.933441 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, child_tidptr=0x7f461c0daa50) = 13772 <0.000380> 14:18:12.933884 open("/data1/6679/6679.log", O_WRONLY|O_CREAT|O_APPEND, 0666) = 60 <0.000019> 14:18:12.933948 lseek(60, 0, SEEK_END) = 11484 <0.000013> 14:18:12.933983 stat("/etc/localtime", {st_mode=S_IFREG|0644, st_size=556, ...}) = 0 <0.000016> 14:18:12.934032 fstat(60, {st_mode=S_IFREG|0644, st_size=11484, ...}) = 0 <0.000014> 14:18:12.934062 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f461c0e4000 <0.358768> 14:18:13.292883 write(60, "32088:M 21 Mar 14:18:12.933 * Ba"..., 69) = 69 <0.000032> 14:18:13.292951 close(60) = 0 <0.000014> 14:18:13.292980 munmap(0x7f461c0e4000, 4096) = 0 <0.000019>
$ strace -p 11559 -T -tt -e trace=memory -o trace00.out 14:18:12.934062 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f461c0e4000 <0.358768> 14:18:13.292980 munmap(0x7f461c0e4000, 4096) = 0 <0.000019>
可以观察到,clone耗时380微秒,已经大幅降低,也就fork快速返回了用户态响应用户请求。然而,注意到,紧接着出现了一个mmap耗时358毫秒,与TP100数据接近。
由于mmap系统调用会在当前进程的虚拟地址空间中,寻找一段满足大小要求的虚拟地址,并且为此虚拟地址分配一个虚拟内存区域( vm_area_struct 结构),也就是会触发VMA级虚拟页表变化,也就触发父进程主动同步机制,父进程主动帮助完成相应页表复制动作。VMA级虚拟页表变化,需要将对应的三级和四级所有页目录都复制到子进程,因此,耗时比较高。
那么,这个mmap调用又是哪里来的呢?
● 定位问题
perf是Linux下的一款性能分析工具,能够进行函数级与指令级的热点查找。
通过perf trace可以看到响应调用堆栈及耗时,分析结果如下:
$ perf trace -p 11559 -o trace01.out --max-stack 15 -T 616821913.647 (358.740 ms): Redis-server_4/32088 mmap(len: 4096, prot: READ|WRITE, flags: PRIVATE|ANONYMOUS ) = 0x7f461c0e4000 __mmap64 (/usr/lib64/libc-2.17.so) __GI__IO_file_doallocate (inlined) __GI__IO_doallocbuf (inlined) __GI__IO_file_overflow (inlined) _IO_new_file_xsputn (inlined) _IO_vfprintf_internal (inlined) __GI_fprintf (inlined) serverLogRaw (/usr/local/Redis/Redis-server) serverLog (/usr/local/Redis/Redis-server) rdbSaveBackground (/usr/local/Redis/Redis-server) bgsaveCommand (/usr/local/Redis/Redis-server) call (/usr/local/Redis/Redis-server) processCommand (/usr/local/Redis/Redis-server) processInputBuffer (/usr/local/Redis/Redis-server) aeProcessEvents (/usr/local/Redis/Redis-server) 616822272.562 ( 0.010 ms): Redis-server_4/32088 munmap(addr: 0x7f461c0e4000, len: 4096 ) = 0 __munmap (inlined) __GI__IO_setb (inlined) _IO_new_file_close_it (inlined) _IO_new_fclose (inlined) serverLogRaw (/usr/local/Redis/Redis-server) serverLog (/usr/local/Redis/Redis-server) rdbSaveBackground (/usr/local/Redis/Redis-server) bgsaveCommand (/usr/local/Redis/Redis-server) call (/usr/local/Redis/Redis-server) processCommand (/usr/local/Redis/Redis-server) processInputBuffer (/usr/local/Redis/Redis-server) aeProcessEvents (/usr/local/Redis/Redis-server) aeMain (/usr/local/Redis/Redis-server) main (/usr/local/Redis/Redis-server)
也就可以看到,在bgsave执行逻辑中,有一处打印日志中的fprintf调用了mmap,很显然这应该是fork返回父进程后,父进程中某处调用。