4. THP内核实现原理:khugepaged内核线程、扫描与合并策略、大页分配与回退机制

透明大页(Transparent Hugepages, THP)的内核实现是一个精巧的协同系统,核心由三个关键组件构成:khugepaged内核线程负责后台扫描与合并,扫描与合并策略决定何时以及如何将普通页提升为大页,大页分配与回退机制则确保在内存压力下系统的稳定性。本章深入剖析这三部分的实现原理与代码路径。

4.1 khugepaged内核线程:守护进程的启动与生命周期

khugepaged是一个独立的内核线程,在系统初始化时由THP模块创建。它不隶属于任何用户进程,拥有独立的mm_struct和地址空间,专门负责扫描进程的虚拟内存区域(VMA),寻找可以合并为透明大页的连续小页集合。

关键数据结构: khugepaged使用一个全局链表 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线程的主循环执行以下步骤:

  1. 休眠等待: 线程在 khugepaged_wait 等待队列上休眠,默认每 10秒 唤醒一次(由 /sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs 控制)。
  2. 获取扫描目标: 从 khugepaged_scan.mm_head 链表中取出下一个待扫描的mm_struct。
  3. 遍历VMA: 在目标进程的VMA红黑树中,从上次扫描位置开始,逐个检查VMA是否满足合并条件(可写、可共享、非特殊映射等)。
  4. 页表扫描: 对符合条件的VMA,逐页检查其页表项(PTE),统计连续且相同的物理页数量。
  5. 执行合并: 如果连续页数量达到HPAGE_PMD_NR(通常为512),则调用 collapse_huge_page() 执行实际的合并操作。
  6. 更新进度: 记录当前扫描位置,释放mm引用,继续下一个mm或进入休眠。
性能提示: 可以通过调整 scan_sleep_millisecs 和 alloc_sleep_millisecs 参数控制khugepaged的扫描频率。在数据库或延迟敏感型负载中,适当增大休眠时间(如50-100ms)可以减少CPU开销,但会降低大页合并的及时性。
khugepaged内核线程生命周期与扫描流程 khugepaged 内核线程生命周期与扫描流程 khugepaged 线程创建 休眠等待 (默认10s) 获取下一个 mm_struct 遍历VMA红黑树 扫描PTE统计连续页 collapse_huge_page() 循环扫描下一个mm 说明:khugepaged 线程在完成一轮扫描后,若没有更多 mm 可扫描,则重新进入休眠等待状态。 扫描进度通过 khugepaged_scan 结构体持久化,支持跨唤醒周期断点续扫。

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;
}
避坑指南: 扫描过程中,如果发现某个PTE映射的物理页被多个进程共享(page_mapcount > 1),khugepaged会立即放弃该区域。这是因为合并共享页会导致写时复制(COW)开销激增,得不偿失。在容器或虚拟机场景中,共享内存页非常普遍,这解释了为何THP在这些环境中合并效率较低。

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个小页放回伙伴系统,更新内存统计。
khugepaged 扫描与合并策略决策树 khugepaged 扫描与合并策略决策树 获取下一个VMA VMA过滤:VM_WRITE & VM_HUGEPAGE VMA大小 ≥ 2MB ? 扫描512个PTE 条件:none≤阈值 && writable && mapcount==1 collapse_huge_page() 跳过,继续下一个VMA 说明:决策树中每个节点代表一个检查点,只有全部通过才会执行合并。任何一步失败都会导致跳过当前VMA。 khugepaged_max_ptes_none 默认值为511,即允许最多1个空PTE,保证合并区域几乎完全填充。

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;
}
透明大页分配与回退机制流程图 透明大页分配与回退机制流程图 alloc_hugepage_vma() 尝试从HugeTLB池分配 成功 失败 返回大页 伙伴系统分配 内存压缩 (compact) 直接回收 (try_to_free) 返回NULL (回退小页) 重试分配 说明:分配路径从HugeTLB池开始,逐级回退到伙伴系统、内存压缩、直接回收,最终返回NULL。 在 defrag=defer 模式下,压缩和回收操作由后台 kcompactd 异步执行,避免同步阻塞。 分裂操作 (split_huge_page) 是大页回退的逆向过程,用于内存回收或COW场景。

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