4. KSM扫描线程:ksmd内核线程的创建与生命周期、扫描循环的工作机制

KSM(Kernel Same-page Merging)的核心执行实体是一个名为 ksmd 的内核线程。该线程负责遍历所有注册的虚拟内存区域(VMA),查找并合并内容相同的物理页面。理解 ksmd 的创建、生命周期以及其核心扫描循环 scan_get_next_rmap_item 的工作机制,是掌握 KSM 工程落地的关键。

4.1 ksmd内核线程的创建与生命周期

ksmd 线程并非系统启动时默认创建,而是由用户空间通过 madvise() 系统调用或 /sys/kernel/mm/ksm 接口触发。其生命周期严格遵循“注册-激活-扫描-休眠-销毁”的流程。

生命周期关键阶段:
  1. 创建与注册: 当进程调用 madvise(addr, length, MADV_MERGEABLE) 时,内核将对应的 VMA 标记为可合并,并注册到 KSM 的全局红黑树(mm_slot 链表)中。若 ksmd 线程尚未运行,则通过 ksm_run 状态机启动。
  2. 激活与扫描: 用户写入 /sys/kernel/mm/ksm/run 为 1 时,ksmd 线程被唤醒,进入扫描循环。
  3. 休眠与等待: 扫描完一轮所有注册的 VMA 后,ksmd 根据 sleep_millisecs 参数进入可中断休眠,等待下一次唤醒。
  4. 销毁: 当 run 设置为 2 时,KSM 停止合并但保留已合并页面;当 run 设置为 0 时,ksmd 线程退出,所有合并页面被解除。
工程提示: ksmd 是一个 freezable 内核线程,支持系统休眠/唤醒。在虚拟机场景中,若宿主机进入 S3 睡眠,ksmd 会被冻结,避免在恢复时产生数据不一致。
ksmd 内核线程生命周期状态机 未创建/休眠 扫描循环 可中断休眠 销毁/退出 run=1 一轮结束 run=0 重新注册

4.2 扫描循环的核心数据结构

ksmd 的扫描循环围绕三个核心数据结构展开,它们共同维护了扫描的“游标”状态:

数据结构 类型 作用
ksm_mm_head 链表头 管理所有注册了 MADV_MERGEABLE 的进程 mm_struct 链表。
ksm_scan 全局变量 记录当前扫描位置:mm_slot(当前进程)、rmap_item(当前VMA内的节点)、address(当前虚拟地址)。
rmap_item 结构体 挂载在 anon_vma 红黑树上,代表一个待扫描的匿名页面槽位。
并发安全注意: ksm_scan 全局变量在 ksmd 线程上下文中独占访问,但 mm_slot 链表可能被其他进程(如 exec、exit)修改。KSM 通过 ksm_mmlist_lock 和 mmap_lock 实现细粒度锁保护,避免死锁。

4.3 scan_get_next_rmap_item 工作机制

scan_get_next_rmap_item 是 ksmd 扫描循环的核心函数,负责从当前扫描位置获取下一个待处理的 rmap_item。其工作流程可分解为以下步骤:

  1. 获取当前 mm_slot: 从 ksm_scan 中读取当前正在扫描的进程内存描述符 mm_slot。若为空,则从链表头开始。
  2. 锁定进程内存: 调用 mmap_read_lock 获取进程的 mmap_lock(读锁),防止扫描期间 VMA 布局发生变化。
  3. 遍历 VMA 红黑树: 从 ksm_scan.address 开始,在进程的 VMA 红黑树中查找下一个标记为 VM_MERGEABLE 的 VMA。
  4. 查找 rmap_item: 在目标 VMA 对应的 anon_vma 红黑树中,查找或创建 rmap_item。若该页面已被合并(KSM 页面),则跳过。
  5. 更新游标: 将 ksm_scan.address 前进到下一个页面边界。若当前 VMA 扫描完毕,则移动到下一个 VMA 或下一个 mm_slot。
  6. 返回结果: 返回找到的 rmap_item 指针;若所有 VMA 扫描完毕,返回 NULL 触发 ksmd 休眠。
scan_get_next_rmap_item 扫描循环流程图 1. 获取当前 mm_slot 2. 锁定 mmap_lock (读锁) 3. 遍历 VMA 红黑树 找到 VMA? 否 下一个 mm_slot 是 4. 查找/创建 rmap_item 5. 更新游标地址 6. 返回 rmap_item 继续下一轮

4.4 扫描循环的完整代码逻辑

以下为 ksmd 主循环的简化伪代码,展示了 scan_get_next_rmap_item 的调用上下文:

/* ksmd 内核线程主函数 */
static int ksm_scan_thread(void *nothing)
{
    while (!kthread_should_stop()) {
        /* 检查是否被要求休眠 */
        if (ksm_run & KSM_RUN_STOP)
            wait_event_freezable(ksm_thread_wait,
                     (ksm_run & KSM_RUN_MERGE) || kthread_should_stop());

        /* 核心扫描循环 */
        while (ksm_run & KSM_RUN_MERGE) {
            struct rmap_item *rmap_item;

            /* 获取下一个待扫描的 rmap_item */
            rmap_item = scan_get_next_rmap_item(&ksm_scan);
            if (!rmap_item) {
                /* 所有 VMA 扫描完毕,休眠 */
                schedule_timeout_interruptible(
                    msecs_to_jiffies(sleep_millisecs));
                break;
            }

            /* 尝试合并该页面 */
            cmp_and_merge_page(rmap_item);
        }
    }
    return 0;
}
关键设计决策:
  • 分批处理: 每次调用 scan_get_next_rmap_item 只获取一个页面,避免长时间持有 mmap_lock,降低对业务进程的延迟影响。
  • 可中断休眠: 使用 schedule_timeout_interruptible 而非 uninterruptible,允许系统在休眠期间响应信号(如 run=0 的销毁请求)。
  • Freezable 支持: wait_event_freezable 确保系统休眠时 ksmd 被冻结,避免在恢复时处理不一致的内存状态。

4.5 性能参数与调优对照

ksmd 的行为受多个 /sys/kernel/mm/ksm/ 下的参数控制,直接影响扫描循环的执行效率:

参数名 默认值 对扫描循环的影响
sleep_millisecs 20 一轮扫描结束后的休眠时间。值越小,CPU 占用越高,去重速度越快。
pages_to_scan 100 每次唤醒后扫描的页面数。增大该值可提高吞吐量,但会增加单次唤醒的 CPU 开销。
max_page_sharing 256 单个 KSM 页面最多可被共享的进程数。超过后该页面停止接收新的合并请求。
merge_across_nodes 1 是否允许跨 NUMA 节点合并。关闭后可避免跨节点访问延迟,但会降低去重率。
调优建议: 在虚拟机密度较高的场景(如 QEMU/KVM 宿主机),建议将 sleep_millisecs 调整为 10~50ms,pages_to_scan 调整为 200~500,以平衡 CPU 开销与内存节省。对于延迟敏感型业务,应关闭 KSM 或设置 run=2 仅保留已合并页面。

4.6 避坑指南:扫描循环的常见问题

  • 锁竞争导致业务抖动: ksmd 在扫描时持有 mmap_lock 读锁,若业务进程频繁进行 mmap/munmap 操作,可能因写锁等待导致延迟增加。解决方案:降低 pages_to_scan 或使用 madvise(MADV_UNMERGEABLE) 排除关键路径。
  • 扫描死锁: ksmd 在获取 mmap_lock 时使用 mutex_lock 而非 down_read_trylock,若业务进程持有 mmap_lock 写锁并等待 ksmd 持有的其他锁,可能形成死锁。KSM 通过 ksm_exit 回调在进程退出时清理,避免长期阻塞。
  • 大页面透明化冲突: 若 THP(透明大页)与 KSM 同时启用,ksmd 可能扫描到 2MB 大页。KSM 只能合并 4KB 基页,大页会被 split_huge_page 拆分,导致额外的内存碎片。建议在 KSM 场景下禁用 THP。
内核版本差异: 在 Linux 5.0+ 中,KSM 引入了 ksm_use_zero_pages 优化,将全零页面直接映射到全局零页,减少扫描开销。但该优化在 ARM64 平台上可能因缓存行冲突导致性能回退,需根据硬件特性选择性开启。