Linux 6.6内核内存管理深度解析(十):KSM 同页合并 — 内容去重,把 N 份相同内存合并成 1 份

引言:一台机器跑 10 个虚拟机,guest OS 都一样,为什么要存 10 份?

KVM 虚拟化里最常见的一幕:一台宿主机跑 10 台虚拟机,装的都是同一个 CentOS/Ubuntu 镜像。这些 guest OS 的内核代码段、共享库(libc)、只读数据,内容完全相同,却各自占一份物理页------10 台 VM 就是 10 份,内存被白白浪费 9 倍。

能不能"合并同款"?让内容相同的匿名页只存一份物理页,所有映射它的虚拟地址都指向这一份?能,这就是 KSM(Kernel Samepage Merging,内核同页合并)。

它做的事一句话:扫描标记为"可合并"的匿名页,发现内容相同的,就合并成一份物理页;合并后谁要写,就 COW 拆开(写时复制)。这篇讲的就是这条链路:标记 → 扫描 → 两棵树找"同款" → 写保护 + 替换 PTE 合并 → 写时拆开。


全景图

#mermaid-svg-PEEOtE2amht9rkdr{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-PEEOtE2amht9rkdr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-PEEOtE2amht9rkdr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-PEEOtE2amht9rkdr .error-icon{fill:#552222;}#mermaid-svg-PEEOtE2amht9rkdr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-PEEOtE2amht9rkdr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-PEEOtE2amht9rkdr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-PEEOtE2amht9rkdr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-PEEOtE2amht9rkdr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-PEEOtE2amht9rkdr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-PEEOtE2amht9rkdr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-PEEOtE2amht9rkdr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-PEEOtE2amht9rkdr .marker.cross{stroke:#333333;}#mermaid-svg-PEEOtE2amht9rkdr svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-PEEOtE2amht9rkdr p{margin:0;}#mermaid-svg-PEEOtE2amht9rkdr .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-PEEOtE2amht9rkdr .cluster-label text{fill:#333;}#mermaid-svg-PEEOtE2amht9rkdr .cluster-label span{color:#333;}#mermaid-svg-PEEOtE2amht9rkdr .cluster-label span p{background-color:transparent;}#mermaid-svg-PEEOtE2amht9rkdr .label text,#mermaid-svg-PEEOtE2amht9rkdr span{fill:#333;color:#333;}#mermaid-svg-PEEOtE2amht9rkdr .node rect,#mermaid-svg-PEEOtE2amht9rkdr .node circle,#mermaid-svg-PEEOtE2amht9rkdr .node ellipse,#mermaid-svg-PEEOtE2amht9rkdr .node polygon,#mermaid-svg-PEEOtE2amht9rkdr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-PEEOtE2amht9rkdr .rough-node .label text,#mermaid-svg-PEEOtE2amht9rkdr .node .label text,#mermaid-svg-PEEOtE2amht9rkdr .image-shape .label,#mermaid-svg-PEEOtE2amht9rkdr .icon-shape .label{text-anchor:middle;}#mermaid-svg-PEEOtE2amht9rkdr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-PEEOtE2amht9rkdr .rough-node .label,#mermaid-svg-PEEOtE2amht9rkdr .node .label,#mermaid-svg-PEEOtE2amht9rkdr .image-shape .label,#mermaid-svg-PEEOtE2amht9rkdr .icon-shape .label{text-align:center;}#mermaid-svg-PEEOtE2amht9rkdr .node.clickable{cursor:pointer;}#mermaid-svg-PEEOtE2amht9rkdr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-PEEOtE2amht9rkdr .arrowheadPath{fill:#333333;}#mermaid-svg-PEEOtE2amht9rkdr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-PEEOtE2amht9rkdr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-PEEOtE2amht9rkdr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PEEOtE2amht9rkdr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-PEEOtE2amht9rkdr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PEEOtE2amht9rkdr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-PEEOtE2amht9rkdr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-PEEOtE2amht9rkdr .cluster text{fill:#333;}#mermaid-svg-PEEOtE2amht9rkdr .cluster span{color:#333;}#mermaid-svg-PEEOtE2amht9rkdr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-PEEOtE2amht9rkdr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-PEEOtE2amht9rkdr rect.text{fill:none;stroke-width:0;}#mermaid-svg-PEEOtE2amht9rkdr .icon-shape,#mermaid-svg-PEEOtE2amht9rkdr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-PEEOtE2amht9rkdr .icon-shape p,#mermaid-svg-PEEOtE2amht9rkdr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-PEEOtE2amht9rkdr .icon-shape .label rect,#mermaid-svg-PEEOtE2amht9rkdr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-PEEOtE2amht9rkdr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-PEEOtE2amht9rkdr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-PEEOtE2amht9rkdr :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 命中
第二次出现
第一次出现
进程 madvise(MADV_MERGEABLE)

ksm_madvise :2667
__ksm_enter :2707

mm 挂进 ksm_mm_head 链表

vma 设 VM_MERGEABLE
ksmd 扫描线程

ksm_scan_thread :2508
ksm_do_scan :2485

每批 100 页, 睡 20ms
scan_get_next_rmap_item :2308

游标扫下一个页
cmp_and_merge_page :2115

核心决策
① stable_tree_search :1631

先查稳定树(已合并的)
② calc_checksum :1086

校验和过滤, 变了就跳过
③ unstable_tree_search_insert :2001

查不稳定树(候选)
try_to_merge_with_ksm_page :1354
try_to_merge_two_pages :1393
插入 unstable tree

等第二次
write_protect_page :1095

PTE 改只读
replace_page :1186

PTE 改指共享 kpage
拆开

写时 COW: do_wp_page :3338

主动拆分: break_ksm :486

一句话:进程 madvise(MADV_MERGEABLE) 把 vma 标成可合并 → ksmd 后台线程扫描这些页 → cmp_and_merge_page 先查稳定树、再查不稳定树找"内容相同"的页 → 找到了就把 PTE 改指共享页(写保护 + 替换)→ 谁要写就 COW 拆开。


一、KSM 是什么:内容去重 + COW

1.1 它合并的是"内容",不是"地址"

KSM 和前面所有内存机制的本质区别:buddy/页表/缺页/大页都在解决"哪个物理页归谁 ",而 KSM 在解决"内容相同的页能不能共用"。

  • 合并条件 :两个匿名页内容逐字节相同 (memcmp_pages 比较)。
  • 合并动作 :保留一份物理页(叫 ksm page),让另一个虚拟地址的 PTE 也指向它,原来的页释放。
  • 合并后 :这份共享页对所有映射都是只读的(因为别人还指着它,谁都不能改)。

于是 10 台 VM 里内容相同的 10 个 libc 页,合并成 1 份物理页,省 9 份。

1.2 为什么必须和 COW 绑定

合并后页只读,问题来了:某个进程要写这个页怎么办?不能让它直接改共享页(会把其他 9 个进程的数据一起改了)。

答案就是 COW(写时复制) :进程写共享页时触发缺页(写只读页),内核拆开这个映射------分配一个新页、把内容拷过去、让这个进程的 PTE 改指新页、然后它在新页上写。其他进程继续共享旧页。

所以 KSM 的正确性完全建立在 #3 篇讲过的 COW 之上:合并靠"写保护 + 替换 PTE",拆分靠"写时缺页"。合并是"免费"的(内容相同的页本就不该各占一份),代价只在"有人写"时才付(拆开)。

1.3 只合并匿名页,且要显式标记

KSM 只合并匿名页 (PageAnon),不合并文件页------文件页内容由文件后备(page cache 一份即可,多个 mmap 映射同一个 file 的同一页本来就共享),无需再去重。

而且匿名页也不是默认全扫:得进程显式 madvise(MADV_MERGEABLE) 标记,vma 满足 vma_ksm_compatible(:533)才纳入------排除了 VM_SHARED/VM_MAYSHARE(共享文件映射)、VM_HUGETLB(大页)、VM_PFNMAP/VM_IO(设备映射)等。为什么默认不开?因为扫描、比较、写保护、拆分全有 CPU 和内存开销,只有"确实有大量重复内容"(如 KVM)的场景才划算。

Why 内容去重而非地址去重 :buddy 按地址管理页,但"省内存"的需求是"内容重复"驱动的(10 台 VM 的 libc 物理地址各不相同,内容却一样)。KSM 用 memcmp_pages 逐字节比内容,才能抓到"地址不同、内容相同"的页。这也决定了它必须靠 COW 兜底------内容相同的页一旦有人写,就必须拆开。


二、两个红黑树:stable vs unstable

KSM 的核心数据结构是两棵按内容排序的红黑树 (每 NUMA node 一份,root_stable_tree/root_unstable_tree :224/:225):

树 存什么 节点类型 何时进/出
stable tree(稳定树) 已经合并、有共享 ksm page 的节点 struct ksm_stable_node(:157) 两页确认相同后插入;一个 stable_node 挂多个 rmap_item
unstable tree(不稳定树) 只出现过一次、还没找到"同伴"的候选页 struct ksm_rmap_item(:197) 第一次扫描插入;第二次出现就两页合并、移入 stable tree

两棵树的关键区别:stable tree 存"已经去重的页"(一个物理 ksm page 对应多个虚拟地址),unstable tree 存"等着找同款的候选"。

2.1 两个核心结构体

c 复制代码
// struct ksm_stable_node(ksm.c:157)------ 稳定树节点,代表"一个共享的 ksm page"
struct ksm_stable_node {
	union {
		struct rb_node node;	/* 稳定树的红黑树节点 */
		...
	};
	struct hlist_head hlist;    /* 挂在它上面的 rmap_item 链表(所有共享者) */
	union {
		unsigned long kpfn;     /* 这个 ksm page 的物理页帧号 */
		unsigned long chain_prune_time;
	};
	int rmap_hlist_len;         /* 有多少个 rmap_item 共享它 */
	int nid;                    /* NUMA node */
};

// struct ksm_rmap_item(ksm.c:197)------ 反向映射项,代表"某个 mm 的某个虚拟地址"
struct ksm_rmap_item {
	struct ksm_rmap_item *rmap_list;
	union {
		struct anon_vma *anon_vma;  /* 在稳定树时 */
		int nid;                    /* 在不稳定树时 */
	};
	struct mm_struct *mm;       /* 属于哪个进程 */
	unsigned long address;      /* 虚拟地址(低位存标志位) */
	unsigned int oldchecksum;   /* 上次算的校验和(不稳定树时用) */
	union {
		struct rb_node node;    /* 不稳定树的红黑树节点 */
		struct {                /* 稳定树时挂到 stable_node */
			struct ksm_stable_node *head;
			struct hlist_node hlist;
		};
	};
};

一句话理解这对关系:stable_node 管"共享的物理页",rmap_item 管"某个进程的某个虚拟地址" 。一个 stable_node(一个 ksm page)通过 hlist 挂着所有共享它的 rmap_item(所有进程的虚拟地址)。

2.2 为什么是"两棵树"而非一棵

关键设计:要等"第二次出现"才合并。

第一次扫到某内容时,它只有一个实例,没有"同伴"可合并,只能作为候选放进 unstable tree 等着。第二次扫到相同内容时(不管来自同一个进程还是另一个 VM),才说明"这内容确实重复、值得合并",于是两页合并、升级进 stable tree。

这背后的成本考量:如果只出现一次的内容也去合并,纯属浪费(没有第二份,无利可图)。两棵树把"候选"和"已确认"分开,unstable tree 就是"待验证的重复嫌疑",stable tree 才是"板上钉钉的重复"。


三、cmp_and_merge_page:核心决策

cmp_and_merge_page(ksm.c:2115)是 KSM 的大脑,每个被扫描的页都要过它一遍。它的流程分三步(代码注释写得极清楚):

c 复制代码
static void cmp_and_merge_page(struct page *page, struct ksm_rmap_item *rmap_item)
{
	...
	/* ① 先查稳定树:这页的内容,是不是已经有一个合并过的 ksm page? */
	kpage = stable_tree_search(page);              // :2147
	if (kpage == page && rmap_item->head == stable_node) {
		put_page(kpage);
		return;                                  // 已经是 ksm page 自己,跳过
	}

	remove_rmap_item_from_tree(rmap_item);          // :2153

	if (kpage) {                                    // 命中了稳定树
		if (PTR_ERR(kpage) == -EBUSY)
			return;
		err = try_to_merge_with_ksm_page(rmap_item, page, kpage);  // :2159
		if (!err) {
			lock_page(kpage);
			stable_tree_append(rmap_item, page_stable_node(kpage), ...);  // :2166
			unlock_page(kpage);
		}
		put_page(kpage);
		return;
	}

	/* ② 稳定树没命中:算校验和,页变频繁就放弃 */
	checksum = calc_checksum(page);                 // :2180
	if (rmap_item->oldchecksum != checksum) {
		rmap_item->oldchecksum = checksum;         // 页在变,别追了
		return;
	}

	/* ③ 校验和稳定:查不稳定树,看有没有"同伴" */
	tree_rmap_item =
		unstable_tree_search_insert(rmap_item, page, &tree_page);  // :2217
	if (tree_rmap_item) {                           // 找到同伴!两页合并
		kpage = try_to_merge_two_pages(rmap_item, page,
						tree_rmap_item, tree_page);      // :2221
		...
		if (kpage) {
			stable_node = stable_tree_insert(kpage);   // 升级进稳定树
			...
		}
	}
	// 没找到同伴:unstable_tree_search_insert 内部已把 rmap_item 插进 unstable tree
}

拆开三步:

  1. 先查稳定树(stable_tree_search :1631) :稳定树按内容排序(memcmp_pages 比较),做一次红黑树查找,看这页的内容是否已经有 合并过的 ksm page。命中 → try_to_merge_with_ksm_page 直接让这页也共享那个 ksm page。

  2. 校验和过滤(calc_checksum :1086,在 cmp_and_merge_page :2180 处调用) :稳定树没命中,先算这页内容的校验和,和上一次扫描 记下的 oldchecksum 比。不一样 → 说明这页内容在频繁变化(每次扫描都不同),追它没有意义,直接放弃。一样 → 说明内容稳定,值得继续找同伴。

  3. 查/插不稳定树(unstable_tree_search_insert :2001) :在不稳定树里找内容相同的候选页。找到 → try_to_merge_two_pages 把两页合并成一个新 ksm page,插进稳定树;没找到 → 把这页的 rmap_item 插进不稳定树,当候选,等下次出现。

Why 三步的顺序 :稳定树优先,因为"合并到已有的 ksm page"最便宜(不用分配新页、不用升级页类型)。校验和过滤在查不稳定树之前,是为了用一个 O(1) 的整数比较,挡住"内容变来变去"的页------否则频繁变化的页会被反复插进/移出不稳定树,纯浪费。只有"内容稳定且重复"的页才走到最后一步合并,这正呼应了"第二次出现才合并"的设计。


四、合并底层:写保护 + 替换 PTE

"合并"两个函数(try_to_merge_with_ksm_page :1354 / try_to_merge_two_pages :1393)最终都落到 try_to_merge_one_page(:1289),它干两件事:

c 复制代码
static int try_to_merge_one_page(struct vm_area_struct *vma,
				 struct page *page, struct page *kpage)
{
	...
	if (page == kpage)			/* ksm page forked */
		return 0;

	if (!PageAnon(page))
		goto out;

	if (!trylock_page(page))		/* 拿不到锁就跳过,不等待 */
		goto out;

	if (PageTransCompound(page)) {		/* 大页先拆 */
		if (split_huge_page(page))
			goto out_unlock;
	}

	if (write_protect_page(vma, page, &orig_pte) == 0) {   // ① 写保护
		if (!kpage) {
			/* 第一次:把 page 升级成 ksm page(mapping 标上 PAGE_MAPPING_KSM) */
			set_page_stable_node(page, NULL);     // page->mapping = stable_node | PAGE_MAPPING_KSM
			mark_page_accessed(page);
			if (!PageDirty(page))
				SetPageDirty(page);
			err = 0;
		} else if (pages_identical(page, kpage))          // ② 内容确认真相同
			err = replace_page(vma, page, kpage, orig_pte);  // ③ 替换 PTE
	}
	...
}

4.1 write_protect_page:把页改成只读(:1095)

合并前的关键动作是把这页写保护 ------把 PTE 的写权限去掉(pte_wrprotect),这样以后谁写它都会触发 COW 缺页(为拆分铺路)。但它不能只改 PTE,还要做一次安全校验:

c 复制代码
// write_protect_page(ksm.c:1095)------ 关键片段
entry = ptep_get(pvmw.pte);
if (pte_write(entry) || pte_dirty(entry) ||
    anon_exclusive || mm_tlb_flush_pending(mm)) {
	swapped = PageSwapCache(page);
	flush_cache_page(vma, pvmw.address, page_to_pfn(page));
	entry = ptep_clear_flush(vma, pvmw.address, pvmw.pte);
	/*
	 * 检查没有 O_DIRECT 或类似 I/O 正在这个页上跑:
	 * page_mapcount + 1 + swapped 必须等于 page_count,
	 * 否则说明有额外的引用(如 GUP pin)在飞,不能合并。
	 */
	if (page_mapcount(page) + 1 + swapped != page_count(page)) {   // :1146
		set_pte_at(mm, pvmw.address, pvmw.pte, entry);  // 有 I/O,恢复原样
		goto out_unlock;
	}
	...
	if (pte_dirty(entry))
		set_page_dirty(page);
	entry = pte_mkclean(entry);       // 清 dirty
	if (pte_write(entry))
		entry = pte_wrprotect(entry); // 去写权限
	set_pte_at_notify(mm, pvmw.address, pvmw.pte, entry);
}

那句 page_mapcount(page) + 1 + swapped != page_count(page) 是精髓:如果页的引用计数(page_count)大于"映射数 + 自身 + swap",说明有别的持有者 ------最典型的就是 GUP pin 住页给设备 DMA(#6 篇) ,或 O_DIRECT 正在读写。这种页不能合并(否则设备的 DMA 和 CPU 看到的会不一致),所以恢复 PTE、放弃。

4.2 replace_page:把 PTE 改指共享页(:1186)

写保护成功后,replace_page 把当前进程的 PTE 从"指向旧页"改成"指向 ksm page":

c 复制代码
// replace_page(ksm.c:1186)------ 关键片段
if (!is_zero_pfn(page_to_pfn(kpage))) {
	get_page(kpage);                              // ksm page 引用 +1
	page_add_anon_rmap(kpage, vma, addr, RMAP_NONE);  // 共享页加一个 rmap
	newpte = mk_pte(kpage, vma->vm_page_prot);    // 新 PTE 指向 ksm page
}
...
ptep_clear_flush(vma, addr, ptep);                // 清旧 PTE + flush TLB
set_pte_at_notify(mm, addr, ptep, newpte);        // 写入新 PTE

folio = page_folio(page);
page_remove_rmap(page, vma, false);               // 旧页减一个映射
if (!folio_mapped(folio))
	folio_free_swap(folio);
folio_put(folio);                                 // 旧页引用 -1(没人映射就释放)

三步:给共享页加引用 → 改 PTE → 释放旧页。合并完成后,这个进程和别的进程的 PTE 都指向同一个 ksm page,旧页无人引用后被 buddy 回收。

Why 合并 = 写保护 + 替换 PTE 两步走 :写保护是"上保险"------保证合并后谁写都会 COW,不会静默改到共享页;替换 PTE 是"换指针"------把虚拟地址从旧页重定向到共享页。两步缺一不可:只改 PTE 不写保护,写者会直接改共享页;只写保护不改 PTE,等于没合并。而写保护里那句 page_mapcount + 1 != page_count 的检查,正是把 KSM 和 #6 GUP 串起来的地方------pin 住的页不能合并。


五、拆开:写时 COW(被动)+ break_ksm 主动拆分

合并后页只读共享,但"共享"是临时的------只要有人要写,就得拆。拆有两条独立路径:

5.1 写时 COW:do_wp_page 通用路径(对 KSM 页强制 copy)

进程写共享 ksm page,触发 #3 篇的写时复制缺页 do_wp_page(memory.c:3338)。do_wp_page 对 KSM 页有专门判断------folio_test_ksm 为真时强制走 copy,而不是像普通独占匿名页那样走 reuse:

c 复制代码
// do_wp_page(memory.c:3386-3452)------ 关键片段
if (folio && folio_test_anon(folio)) {
	if (PageAnonExclusive(vmf->page))
		goto reuse;                     // 普通独占页:直接复用
	...
	if (folio_test_ksm(folio) || folio_ref_count(folio) > 3)
		goto copy;                      // KSM 页:强制 copy(:3401)
	...
	if (folio_test_ksm(folio) || folio_ref_count(folio) != 1) {
		folio_unlock(folio);
		goto copy;                      // 再次确认 KSM 页不能 reuse(:3415)
	}
	page_move_anon_rmap(vmf->page, vma); // 普通页才能走到这里复用
reuse:
	...
	wp_page_reuse(vmf);
	return 0;
}
copy:
	...
	if (folio && folio_test_ksm(folio))
		count_vm_event(COW_KSM);         // 统计 KSM COW 次数(:3449)
	return wp_page_copy(vmf);            // :3053 分配新页 + 拷贝 + 改 PTE

为什么 KSM 页不能 reuse?因为 ksm page 的 mapping 指向 stable_node(不是 anon_vma),page_move_anon_rmap(把页转成独占匿名页)对它无效,所以只能走 wp_page_copy:分配一个新匿名页、把内容拷过去、把本进程 PTE 改指新页;旧 ksm page 引用减一,其他进程继续共享。

关键点:这一步走通用 COW 框架,不调用 KSM 的 break_ksm 。do_wp_page 只是"认出这是 KSM 页、强制 copy"(靠 PAGE_MAPPING_KSM = PAGE_MAPPING_ANON | PAGE_MAPPING_MOVABLE page-flags.h:631 让 folio_test_ksm :681 判定),真正干活的是通用 wp_page_copy。

5.2 主动拆分:break_ksm / break_cow(KSM 自己干)

break_ksm(ksm.c:486)是 KSM 主动 把某个地址的 ksm page 拆开:用 walk_page_range_vma 找到 ksm page,然后 handle_mm_fault(..., FAULT_FLAG_UNSHARE | FAULT_FLAG_REMOTE, ...) 触发一次"取消共享"的缺页,分配新页、拷贝内容。break_cow(:567)是更高层封装,从 rmap_item 出发找 vma 再调 break_ksm。

它有两个用途:

  • 合并失败回滚 :try_to_merge_two_pages(:1409)或 cmp_and_merge_page(:2258-2259)合并到一半失败,break_cow 撤销刚才的合并。
  • 显式 unmerge :madvise(MADV_UNMERGEABLE)(ksm_madvise :2694)、vma 删除(__ksm_del_vma :2556)、sysfs run=2(KSM_RUN_UNMERGE)→ unmerge_ksm_pages(:887)逐页调 break_ksm。

注意 break_ksm 里那个 do { ... } while (...) 循环(:492-504):因为 handle_mm_fault 可能因并发(比如 pte accessed 位被同时更新)而退避,所以要循环直到页表里不再有 KSM page,保证拆干净。

Why 两条拆路径各管各的 :写时 COW 是"用户写共享页"触发的被动 拆分,走 #3 的 do_wp_page 通用框架(只是认出 KSM 页后强制 wp_page_copy,不调用 KSM 的拆分函数);break_ksm 是 KSM 主动 拆分(回滚 / unmerge),借 FAULT_FLAG_UNSHARE 这个现成缺页机制来"取消共享"。两者最终都落到"分配新页 + 拷贝 + 改 PTE",但触发者不同------一个是被写的进程自己,一个是 KSM 撤销/退出时的主动清理。


六、扫描线程与触发:ksmd + madvise + sysfs

6.1 ksmd:后台扫描线程

c 复制代码
// ksm_scan_thread(ksm.c:2508)------ ksmd 主循环
set_user_nice(current, 5);   // 低优先级,不抢占正常任务

while (!kthread_should_stop()) {
	mutex_lock(&ksm_thread_mutex);
	wait_while_offlining();
	if (ksmd_should_run())                    // :2503
		ksm_do_scan(ksm_thread_pages_to_scan); // 每批扫 100 页
	mutex_unlock(&ksm_thread_mutex);

	try_to_freeze();
	if (ksmd_should_run()) {
		sleep_ms = READ_ONCE(ksm_thread_sleep_millisecs);
		wait_event_interruptible_timeout(ksm_iter_wait, ...,
			msecs_to_jiffies(sleep_ms));       // 睡 20ms 再扫下一批
	} else {
		wait_event_freezable(ksm_thread_wait, ...);  // 没活干,睡到被唤醒
	}
}
  • 每批 100 页、睡 20ms :ksm_thread_pages_to_scan = 100(:273)、ksm_thread_sleep_millisecs = 20(:276),两个可调参数。节奏慢、优先级低(nice=5),避免扫描开销影响正常负载。
  • ksm_do_scan(:2485) :批内循环 scan_get_next_rmap_item 拿下一个 rmap_item → cmp_and_merge_page 处理 → put_page。游标 struct ksm_scan(:138)记录扫到哪个 mm 的哪个地址,下次从这里继续。
  • ksmd_should_run(:2503) :(ksm_run & KSM_RUN_MERGE) && 链表非空------只有 KSM 开启、且有 mm 注册进来才跑。

6.2 入口:madvise 显式标记

c 复制代码
// ksm_madvise(ksm.c:2667)
switch (advice) {
case MADV_MERGEABLE:               // 进程说"这段内存可以合并"
	...
	if (!test_bit(MMF_VM_MERGEABLE, &mm->flags))
		err = __ksm_enter(mm);       // 把 mm 挂进 ksm 扫描链表
	*vm_flags |= VM_MERGEABLE;       // vma 打上可合并标志
	break;
case MADV_UNMERGEABLE:             // 进程说"别合并了"
	...
	err = unmerge_ksm_pages(vma, start, end, true);  // 拆掉已合并的
	*vm_flags &= ~VM_MERGEABLE;
	break;
}

__ksm_enter(:2707)把 mm 挂进全局链表 ksm_mm_head(扫描游标从此遍历所有注册的 mm),并 wake_up_interruptible(&ksm_thread_wait) 唤醒 ksmd。

6.3 sysfs 开关

/sys/kernel/mm/ksm/ 下几个文件控制 KSM:

  • run (对应 ksm_run :300):0 停止(KSM_RUN_STOP)、1 合并(KSM_RUN_MERGE)、2 反合并(KSM_RUN_UNMERGE)。
  • pages_to_scan / sleep_millisecs:调扫描节奏(对应 :273/:276)。
  • max_page_sharing (ksm_max_page_sharing = 256 :270):一个 stable_node 最多共享多少个 rmap_item,防单一 ksm page 被过度共享。

Why 默认不全局开 :KSM 的收益(省内存)只在"存在大量内容重复"时成立,代价(扫描 CPU、写保护、拆分开销)却是全局的。所以它设计成显式 opt-in ------进程 madvise(MADV_MERGEABLE) 才纳入。这正是 KVM 的场景:QEMU 给 guest 内存打 MADV_MERGEABLE,让多 VM 的重复页合并。对普通进程,内容大多不重复,强行开只会白烧 CPU。


七、与虚拟化 / AI Infra 的关系 + 记住三点

7.1 KSM 是 KVM 内存超售的地基

KVM 宿主机内存超售(overcommit)的两大支柱,KSM 占其一:

  • KSM 去重:多 VM 的相同 guest OS 页合并成一份。
  • swap / 气球(balloon):内存真不够时,换出或用 virtio-balloon 让 guest 主动还内存(接 #7 篇 swap)。

KSM 对 AI Infra 的关联是间接但真实 的:GPU 虚拟化(如 vGPU、MIG 切分)和云上训练平台,宿主机常跑多租户,KSM 省下的内存能多塞几个实例。但要注意------GPU 直接 pin 的用户页(#6 GUP)不能被 KSM 合并 (write_protect_page 的 page_count 检查会拒绝),所以"大页 DMA 缓冲"这类页天然不参与去重。

7.2 记住三点

  1. KSM 是"内容去重",靠 COW 保正确 :合并的是"内容相同的匿名页",不是"地址相邻";合并后页只读(write_protect_page :1095 去写权限),谁写就 COW 拆开(do_wp_page memory.c:3338 通用路径,KSM 主动拆才用 break_ksm :486)。所以它必须显式 madvise(MADV_MERGEABLE) 标记(ksm_madvise :2667),默认不开。

  2. 两棵红黑树 = "候选" vs "已确认" :stable tree(root_stable_tree :224)存已合并的 ksm page(ksm_stable_node :157,一个 node 挂多个 rmap_item);unstable tree(:225)存只出现过一次的候选(ksm_rmap_item :197)。第二次出现才合并 ------cmp_and_merge_page(:2115)先查 stable(stable_tree_search :1631)、再校验和过滤(calc_checksum :1086)、最后查/插 unstable(unstable_tree_search_insert :2001)。

  3. 合并 = 写保护 + 替换 PTE,迁移是重操作要过滤 :try_to_merge_one_page(:1289)里 write_protect_page 写保护(并检查 page_mapcount + 1 != page_count 拒绝 GUP pin/O_DIRECT 页)→ pages_identical 确认 → replace_page(:1186)改 PTE 释放旧页。扫描由 ksmd 后台线程(ksm_scan_thread :2508)每批 100 页、睡 20ms 低优先级跑,是 KVM 内存超售 + GPU 虚拟化的隐形地基。


附:本篇关键源码索引

符号 位置
struct ksm_stable_node / struct ksm_rmap_item / struct ksm_scan mm/ksm.c:157 / :197 / :138
root_stable_tree / root_unstable_tree mm/ksm.c:224 / :225
ksm_thread_pages_to_scan(=100)/ ksm_thread_sleep_millisecs(=20)/ ksm_max_page_sharing(=256) mm/ksm.c:273 / :276 / :270
vma_ksm_compatible / find_mergeable_vma mm/ksm.c:533 / :555
write_protect_page / replace_page mm/ksm.c:1095 / :1186
calc_checksum(校验和过滤) mm/ksm.c:1086
try_to_merge_one_page / try_to_merge_with_ksm_page / try_to_merge_two_pages mm/ksm.c:1289 / :1354 / :1393
stable_tree_search / stable_tree_insert / unstable_tree_search_insert mm/ksm.c:1631 / :1881 / :2001
stable_tree_append / cmp_and_merge_page(核心决策) mm/ksm.c:2072 / :2115
break_ksm / break_cow / unmerge_ksm_pages mm/ksm.c:486 / :567 / :887
ksm_do_scan / ksmd_should_run / ksm_scan_thread mm/ksm.c:2485 / :2503 / :2508
ksm_madvise / __ksm_enter mm/ksm.c:2667 / :2707
do_wp_page(写时 COW 拆分) mm/memory.c:3338
PAGE_MAPPING_KSM / folio_test_ksm include/linux/page-flags.h:631 / :681
相关推荐
Huangjin007_2 小时前
【Linux 系统篇(三十三)】通信(三):System V 共享内存详解
linux·运维·服务器
叼烟扛炮2 小时前
Linux 学习笔记(三十八):从实模式到分页——处理器内存管理的发展史
linux·运维·服务器
2401_868534782 小时前
论智慧医院网络建设
服务器·网络
润滑卫士2 小时前
油液水分在线传感器技术对比:电容法、电阻法与微波法的测量原理与适用性
运维
Decay__2 小时前
附-01 · MySQL 压测与容量规划 —— sysbench 基准测试、容量评估与参数基线
运维·数据库·sql·mysql·架构
mennekes3 小时前
暗装插座箱 VS 明装插座箱,工地和车间怎么取舍
运维·网络·科技·安全·制造
叼烟扛炮3 小时前
Linux 学习笔记(三十六):并发进阶——读写锁 vs 自旋锁,到底怎么选?
linux·运维·服务器·笔记·学习
qq_364682153 小时前
录音APP怎么切换语言?多语言版本如何使用
java·服务器·前端
正经教主3 小时前
【FDE系列】阶段2:Day 43(UP详细版):Docker Compose — 多容器一键编排
运维·docker·容器