4. THP内核实现原理:khugepaged内核线程、扫描与合并策略、大页分配与回退机制
透明大页(Transparent Hugepages, THP)的内核实现是一个精巧的协同系统,核心由三个关键组件构成:khugepaged内核线程负责后台扫描与合并,扫描与合并策略决定何时以及如何将普通页提升为大页,大页分配与回退机制则确保在内存压力下系统的稳定性。本章深入剖析这三部分的实现原理与代码路径。
4.1 khugepaged内核线程:守护进程的启动与生命周期
khugepaged是一个独立的内核线程,在系统初始化时由THP模块创建。它不隶属于任何用户进程,拥有独立的mm_struct和地址空间,专门负责扫描进程的虚拟内存区域(VMA),寻找可以合并为透明大页的连续小页集合。
khugepaged_scan.mm_head 来管理所有注册了THP的进程内存描述符(mm_struct)。每个mm_struct通过 khugepaged_scan 结构体记录扫描进度,包括当前扫描的VMA、地址偏移以及扫描次数。
/* mm/khugepaged.c */
static struct khugepaged_scan {
struct list_head mm_head; /* 所有待扫描的mm链表 */
spinlock_t mm_lock;
unsigned long mm_slot_cache; /* 缓存当前扫描的mm槽位 */
/* ... 其他统计与状态字段 */
} khugepaged_scan;
khugepaged线程的主循环执行以下步骤:
- 休眠等待: 线程在
khugepaged_wait等待队列上休眠,默认每 10秒 唤醒一次(由/sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs控制)。 - 获取扫描目标: 从
khugepaged_scan.mm_head链表中取出下一个待扫描的mm_struct。 - 遍历VMA: 在目标进程的VMA红黑树中,从上次扫描位置开始,逐个检查VMA是否满足合并条件(可写、可共享、非特殊映射等)。
- 页表扫描: 对符合条件的VMA,逐页检查其页表项(PTE),统计连续且相同的物理页数量。
- 执行合并: 如果连续页数量达到HPAGE_PMD_NR(通常为512),则调用
collapse_huge_page()执行实际的合并操作。 - 更新进度: 记录当前扫描位置,释放mm引用,继续下一个mm或进入休眠。
scan_sleep_millisecs 和 alloc_sleep_millisecs 参数控制khugepaged的扫描频率。在数据库或延迟敏感型负载中,适当增大休眠时间(如50-100ms)可以减少CPU开销,但会降低大页合并的及时性。
4.2 扫描与合并策略:何时合并、如何合并
khugepaged的扫描与合并策略并非简单的“见缝插针”,而是基于一系列启发式规则和阈值判断,以平衡内存利用率与CPU开销。
4.2.1 扫描条件与VMA过滤
并非所有VMA都值得扫描。内核通过以下条件快速过滤:
- VMA标志检查: 必须包含
VM_WRITE和VM_SHARED(或VM_MAYSHARE),排除只读、私有匿名映射以及特殊映射(如VM_IO、VM_PFNMAP)。 - VMA大小阈值: VMA长度必须至少为
HPAGE_PMD_NR * PAGE_SIZE(2MB),否则不可能容纳一个透明大页。 - 透明大页标志: VMA的
vm_flags中必须设置VM_HUGEPAGE(由madvise模式或系统默认策略决定)。 - 内存压力规避: 如果系统空闲内存低于
khugepaged_max_ptes_none阈值(默认511),则跳过该VMA,避免因合并导致内存不足。
4.2.2 页表扫描与连续性检测
当VMA通过过滤后,khugepaged开始逐页扫描其页表。扫描的核心逻辑在 khugepaged_scan_pmd() 函数中:
/* mm/khugepaged.c - 简化逻辑 */
static int khugepaged_scan_pmd(struct mm_struct *mm, struct vm_area_struct *vma,
unsigned long address, struct page **hpage)
{
pmd_t *pmd;
pte_t *pte, ptent;
struct page *page;
unsigned long _address;
int referenced = 0, none = 0;
bool writable = false;
pmd = mm_find_pmd(mm, address);
if (!pmd || pmd_trans_huge(*pmd) || pmd_devmap(*pmd))
return SCAN_PMD_NULL; /* 已是大页或无效 */
/* 遍历512个PTE */
for (_address = address; _address < address + HPAGE_PMD_SIZE;
_address += PAGE_SIZE) {
pte = pte_offset_map(pmd, _address);
ptent = ptep_get(pte);
if (pte_none(ptent)) {
none++;
continue;
}
if (!pte_present(ptent)) {
pte_unmap(pte);
return SCAN_PTE_NON_PRESENT;
}
page = vm_normal_page(vma, _address, ptent);
if (!page || !PageLRU(page) || page_mapcount(page) != 1) {
pte_unmap(pte);
return SCAN_PAGE_LRU; /* 页不在LRU或映射计数异常 */
}
/* 检查写权限 */
if (pte_write(ptent))
writable = true;
/* 检查引用位 */
if (pte_young(ptent) || PageReferenced(page))
referenced++;
pte_unmap(pte);
}
/* 判断是否满足合并条件 */
if (none > khugepaged_max_ptes_none)
return SCAN_EXCEED_NONE_PTE;
if (!writable)
return SCAN_PAGE_NOT_WRITABLE;
/* ... 其他检查 ... */
return SCAN_SUCCEED;
}
4.2.3 合并执行:collapse_huge_page()
当扫描确认满足条件后,collapse_huge_page() 执行最终的合并操作。该函数的核心步骤包括:
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 分配大页 | 调用 alloc_hugepage_vma() 从HugeTLB或伙伴系统分配一个2MB的连续物理页。 |
| 2 | 冻结页表 | 通过 pmdp_invalidate() 将原PMD项标记为无效,阻止其他线程并发访问。 |
| 3 | 复制内容 | 将512个小页的内容逐页复制到大页中,并释放原小页的引用。 |
| 4 | 建立映射 | 使用 set_pmd_at() 将PMD项指向新分配的大页,并设置 _PAGE_PSE 标志。 |
| 5 | TLB刷新 | 调用 flush_tlb_mm_range() 使所有CPU核上的TLB缓存失效。 |
| 6 | 释放小页 | 将原512个小页放回伙伴系统,更新内存统计。 |
4.3 大页分配与回退机制:从伙伴系统到紧急回退
透明大页的分配并非总是成功。当系统内存碎片化严重或内存不足时,内核必须优雅地回退到普通小页分配,避免进程阻塞或OOM。这一机制由 alloc_hugepage_vma() 和 __alloc_pages_nodemask() 协同完成。
4.3.1 大页分配路径
分配透明大页时,内核首选从 预分配的大页池(HugeTLB)中获取,如果失败则回退到 伙伴系统 的连续页分配:
/* mm/huge_memory.c - 简化分配路径 */
struct page *alloc_hugepage_vma(struct vm_area_struct *vma, unsigned long addr,
int order, gfp_t gfp)
{
struct page *page;
/* 1. 尝试从HugeTLB池分配 */
page = alloc_hugepage(gfp | __GFP_COMP | __GFP_THISNODE,
order, vma, addr, node);
if (page)
return page;
/* 2. 回退到伙伴系统,尝试分配连续2MB */
gfp &= ~__GFP_MOVABLE; /* 大页需要不可移动 */
page = __alloc_pages_nodemask(gfp | __GFP_COMP | __GFP_NORETRY,
order, node, NULL);
if (page)
return page;
/* 3. 最终回退:允许直接回收和压缩 */
page = __alloc_pages_nodemask(gfp | __GFP_COMP | __GFP_RECLAIMABLE,
order, node, NULL);
return page; /* 可能返回NULL */
}
4.3.2 回退机制与碎片处理
当分配失败时,内核不会立即放弃,而是触发一系列回退策略:
| 回退层级 | 触发条件 | 行为 | 影响 |
|---|---|---|---|
| L1: 轻量回退 | 伙伴系统无连续2MB块 | 调用 compact_zone() 进行内存压缩,尝试移动页以形成连续区域。 |
中等延迟,可能阻塞当前进程几毫秒。 |
| L2: 直接回收 | 压缩后仍失败 | 调用 try_to_free_pages() 回收可回收页(如文件缓存),然后重试分配。 |
高延迟,可能触发磁盘I/O。 |
| L3: OOM回退 | 回收后仍失败 | 放弃大页分配,返回NULL;khugepaged记录失败并跳过该区域。 | 无直接延迟,但大页合并失败,系统继续使用小页。 |
| L4: 紧急回退 | 内存严重不足 | 触发OOM killer选择进程杀死,释放内存后重试。 | 极端情况,可能导致进程被终止。 |
transparent_hugepage/defrag = defer 模式。该模式下,khugepaged不会在分配失败时立即触发同步压缩,而是将压缩任务交给后台的 kcompactd 线程异步处理,显著降低分配延迟。
4.3.3 大页分裂(Split)与回退的逆向操作
当系统需要回收内存或大页内部出现写时复制(COW)时,内核需要将透明大页分裂回普通小页。分裂操作由 split_huge_page() 函数执行:
/* mm/huge_memory.c - 分裂大页 */
int split_huge_page(struct page *page)
{
struct page *head = compound_head(page);
struct anon_vma *anon_vma;
int i, ret = 0;
/* 1. 锁定大页,防止并发操作 */
if (!trylock_page(head))
return -EBUSY;
/* 2. 获取匿名映射信息 */
anon_vma = page_lock_anon_vma_read(head, NULL);
if (!anon_vma) {
unlock_page(head);
return -EAGAIN;
}
/* 3. 遍历所有映射,更新页表 */
for (i = 0; i < HPAGE_PMD_NR; i++) {
struct page *tail = head + i;
/* 为每个小页建立独立的PTE映射 */
/* ... 更新反向映射 ... */
}
/* 4. 释放大页元数据,将大页拆分为512个小页 */
__split_huge_page(page, anon_vma, true);
/* 5. 更新内存统计 */
mod_node_page_state(page_pgdat(head), NR_ANON_THPS, -HPAGE_PMD_NR);
unlock_page(head);
return ret;
}
4.4 关键参数与性能影响总结
理解THP内核实现后,可以通过以下参数精确控制其行为:
| 参数路径 | 默认值 | 作用 | 调优建议 |
|---|---|---|---|
/sys/kernel/mm/transparent_hugepage/enabled |
always | 全局启用/禁用THP | 数据库建议设为 madvise |
/sys/kernel/mm/transparent_hugepage/defrag |
always | 大页分配失败时的碎片处理策略 | 延迟敏感负载建议 defer |
/sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs |
10000 | khugepaged两次扫描之间的休眠时间 | 增大可减少CPU开销 |
/sys/kernel/mm/transparent_hugepage/khugepaged/alloc_sleep_millisecs |
60000 | 分配失败后重试前的休眠时间 | 内存紧张时适当减小 |
/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none |