在 Linux 中,KSM(Kernel SamePage Merging),翻译为 内核同页合并,是一个用于优化内存使用的机制。KSM 允许 Linux 内核自动检测并合并相同内容的内存页,以减少虚拟机(VM) 或进程之间的重复内存占用. 对于匿名内存,KSM 机制同样可以将两个相同内容的匿名页合并成一个匿名页,然后将匿名页标记为写保护,那么接下来可以对 KSM 匿名页进行读操作. 一旦某个进程开始对 KSM 匿名页进行写操作,MMU 检查到权限异常并触发缺页异常。在缺页异常处理函数里,存在以下两种缺页场景:
- KSM 匿名页有多个 VMA 映射缺页场景: 当 CPU 访问匿名内存时,MMU 检查到访问权限异常并触发缺页异常,在缺页异常处理函数里,可以识别写保护的匿名页被多个 VMA(或多个进程) 映射,那么将 KSM 匿名页内容拷贝到一个新的物理页上,并将进程对应的页表映射到新的物理页上,且标记页表为可读可写,那么进程后续访问匿名内存不会缺页.
- KSM 匿名页只有一个 VMA 映射缺页场景: 当 CPU 访问匿名内存时,MMU 检查到访问权限异常并触发缺页异常,在缺页异常处理函数里,可以识别写保护的匿名页只有一个 VMA(或者一个进程) 映射,当缺页异常处理函数不会关注这个条件,而是关注其属于 KSM 页,那么将 KSM 页内容拷贝到新的物理页上,并将进程对应的页表映射到新的物理页上,且标记页表为可读可写,那么进程后续访问匿名内存不会缺页.
与 COW 匿名页场景不同,虽然都是写保护匿名页,KSM 都是 COPY 到新页,而 COW 则是 COPY 和 REUSE 共同使用。KSM 最初设计用于虚拟化环境,尤其是在使用基于 KVM(Kernel-based Virtual Machine)的虚拟机管理器时非常有用。虚拟机通常会运行相似的操作系统和应用程序,因此它们可能会有大量相同的内存页,例如共享库、内核代码等。KSM 允许内核检测这些相同的内存页,并将它们合并为一个共享页,从而减少内存消耗. 不同的内存类型对 KSM 引起的缺页存在差异,那么接下来对不同的内存类型的 KSM 缺页场景进行研究:
匿名内存的 KSM 缺页场景
与 COW 匿名页场景不同,虽然都是写保护匿名页,KSM 都是 COPY 到新页,而 COW 则是 COPY 和 REUSE 共同使用。KSM 最初设计用于虚拟化环境,尤其是在使用基于 KVM(Kernel-based Virtual Machine)的虚拟机管理器时非常有用。虚拟机通常会运行相似的操作系统和应用程序,因此它们可能会有大量相同的内存页,例如共享库、内核代码等。KSM 允许内核检测这些相同的内存页,并将它们合并为一个共享页,从而减少内存消耗。接下来通过一个实践案例了解 KSM 匿名页缺页过程,实践案例在 BiscuitOS 上的部署逻辑如下(实践之前需要打开内核 CONFIG_KSM 宏):
cd BiscuitOS
make menuconfig
[*] Package --->
[*] Paging Mechanism --->
[*] Page Fault with Anonymous on KSM --->
# 部署实践案例
make
# 源码目录
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-KSM-default/
# 部署源码
make download
# 在 BiscuitOS 中实践
make build
实践案例由一个应用程序构成,进程在 30 行调用 alloc_anonymous_memory() 函数分配一块可读可写的匿名内存,同理在 35 行调用 alloc_anonymous_memory() 函数分配一块可读可写的匿名内存,并在 42-45 行构造两块内存相同的匿名内存,接着在 48-49 行调用 madvise 函数发起 MADV_MERGEABLE 请求,该操作会触发底层 KSM 合并相似页,待 KSM 页合并完成之后,程序在 55 行第一次访问 KSM 匿名页,此时会触发缺页,程序继续在 57 行第二次访问 KSM 匿名页,此时再次触发缺页. 以上便是一个最基础的实践案例,可以知道 55 行读操作和 57 行写操作都会触发缺页,为了可以看到内存在缺页异常里的流动,本次在 55 行前后加上 BS_DEBUG 开关:
接着在匿名内存缺页流程必经之路上任意位置加上 BS_DEBUG 函数,以此观察内存在某个函数里的流动,例如上图在 wp_page_copy 函数的 3102 行加上 bs_debug 打印,以此确认内存是向哪个函数流动,接下来执行如下命令进行实践:
# 编译应用程序
cd BiscuitOS/output/linux-6.0-x86_64/package/BiscuitOS-PAGING-PF-ANON-KSM-default/
# 编译内核
make kernel
# 编程程序
make build
当 BiscuitOS 启动之后,直接运行 RunBiscuitOS.sh 脚本,脚本里包括实践所需的命令,可以看到进程执行之后对虚拟内存的访问引起了缺页异常,并且该案例的缺页异常处理流程打印了字符串 “KSM Anonymous Memory on wp_page_copy 0x6000000000”, 说明缺页异常处理函数执行过 KSM 写保护缺页. 通过实践可以看到实践案例按着之前分析的代码路径流动。最后开发者可以在该路径上的任何地方使用 bs_debug 查看匿名内存在缺页异常处理流程里的流动.
对于 KSM 匿名内存,其缺页流程如上图,当进程对 KSM 匿名内存写操作时,缺页异常处理函数会进入 “COPY-PAGE” 分支进行处理.
对于 “COPY-PAGE” 分支核心处理函数是 do_copy_page 函数,由于该函数长度太长,这里只做部分解析. 函数首先判断到写保护的匿名内存不是 ZERO Page,于是进入 3113 行分支,此时调用 alloc_page_vma() 函数分配一个新的物理页,然后调用 __wp_page_copy_user 函数将 KSM 匿名页内容拷贝到新的物理页上.
函数接着在 3148 行调用 pte_offset_map_lock 函数获得对应的 PTE Entry,3160 行开始基于 VMA 的 vm_page_prot 构造新的 PTE Entry 内容,由于原先进程的 VMA 就已经包含了 PROT_READ 和 PROT_WRITE,那么新页表的 _PAGE_RW 标志位一定置位。函数接着在 3179 行调用 page_add_new_anon_rmap 函数更新 VMA 的逆向映射,以及调用 lru_cache_add_inactive_or_unevictable 函数将匿名页添加到 LRU 链表上. 最后函数在 3187 行调用 set_pte_at_notify 函数更新页表内容,至此待缺页异常处理函数返回之后,进程可以对 KSM 匿名内存进行读写操作而不会引起缺页异常.
madvise 函数处理 MADV_MERGEABLE 请求的逻辑如上图,其可以强制将某块内存加入到 KSM 内核线程扫描的范围,最后唤醒了 ksm_scan_thread 内核线程,该内核线程属于异步运行,其核心逻辑是在找到两个可以合并的匿名页之后,将其中一个页作为 KSM 匿名页,并将匿名页标记为普通的写保护匿名页.