一、背景:为什么需要页面迁移?

在 NUMA 架构或内存管理需要均衡压力的场景下,页面迁移是核心手段。它能动态将内存页迁移到更合适的节点,以提升性能或回收资源。在看下面的代码前,最好了解一下strcut page结构体,更好的理解页面迁移时的一些判断。


二、整体流程概览

围绕4个核心函数展开:

  1. promote_active_list() ➔ 页面筛选
  2. isolate_lru_pages_promotion() ➔ 页面隔离
  3. promote_page_list() ➔ 迁移条件检查
  4. migrate_page_list() ➔ 真正迁移

最终实际迁移通过 migrate_pages() 实现。


三、主函数promote_active_list()

static unsigned long promote_active_list(unsigned long nr_to_scan,
	struct lruvec *lruvec, enum lru_list lru)
{
    LIST_HEAD(page_list); // 创建一个空链表 page_list,用于暂时存放被隔离出来的页面。
    pg_data_t *pgdat = lruvec_pgdat(lruvec); // 获取该lruvec所属的pgdat
    unsigned long nr_taken, nr_promoted;

    lru_add_drain(); // 确保per-CPU pagevec缓存刷回global LRU链表

    // 为什么需要这一步?
    // Linux为提高性能,引入了per-CPU pagevec缓存。避免每次页面状态变化都频繁改global LRU链表。
    // 但当我们要做扫描、迁移等操作时,必须同步这些缓存,否则会漏掉页面。

    spin_lock_irq(&lruvec->lru_lock); // 加锁保护LRU链表结构和统计信息,防止并发写。不是为读。
    nr_taken = isolate_lru_pages_promotion(nr_to_scan, lruvec, lru, &page_list, 0);
    __mod_node_page_state(pgdat, NR_ISOLATED_ANON, nr_taken); // 隔离页面的记账
    spin_unlock_irq(&lruvec->lru_lock);

    if (nr_taken == 0)
        return 0;

    nr_promoted = promote_page_list(&page_list, pgdat);

    spin_lock_irq(&lruvec->lru_lock);
    move_pages_to_lru(lruvec, &page_list);
    __mod_node_page_state(pgdat, NR_ISOLATED_ANON, -nr_taken); // 将隔离页面计数恢复,表示这些页面已经不再被隔离。
    spin_unlock_irq(&lruvec->lru_lock);

    mem_cgroup_uncharge_list(&page_list); // 对失败或不再需要的页面释放memcg资源

    // 因为迁移失败且没有被归还放入LRU中的页面(比如引用已经为0了),即将被释放这些页面 ➔ 必须解除charge避免内存组资源泄漏。

    free_unref_page_list(&page_list); // 释放引用计数为0的页面

    return nr_promoted;
}

NR_ISOLATED_ANON和NR_ISOLATED_FILE 是内核中的vmstat指标,记录当前有多少页面正在被隔离、尚未返回LRU。有的路径(如kswapd、direct reclaim)会根据这个计数器判断是否需要放慢扫描速度、或者停止隔离页面,避免OOM。

四、隔离页面isolate_lru_pages_promotion()

筛选并隔离候选页面的核心函数:

page = lru_to_page_head(src); // 获取链表头部页面
prefetchw_prev_lru_page_promotion(page, src, flags); // 提前对下一页调用write prefetch,预取到CPU缓存。
VM_WARN_ON(!PageLRU(page)); 

// 公共辅助函数
if (!__isolate_lru_page_prepare(page, 0)) {
	  list_move(&page->lru, src); // 如果检查失败 ➔ 页面重新放回 src ➔ 继续下一个。
	  continue;
}

// 确保页面引用计数有效,防止被其他线程释放。如果 page->_refcount == 0,说明可能已经被回收,不安全。!=0会对该页面的引用计数 +1
if (unlikely(!get_page_unless_zero(page))) { 
	  list_move(&page->lru, src);
	  continue;
}

// 清除PageLRU标志:标记这个页面现在不在LRU链表中了。
if (!TestClearPageLRU(page)) {
	  put_page(page); // 清除失败➔ 引用计数-1,如果引用计数泄漏 ➔ 页面永远不会被释放 ➔ 内存泄漏。
	  list_move(&page->lru, src);
	  continue;
}

__isolate_lru_page_prepare一般检查的内容包括:页面是否还在LRU链表(PageLRU);页面是否在被写回(dirty/writeback);页面是否处于被锁定等异常状态;是否适合进行隔离(如unevictable page)。

VM_WARN_ON防御性断言:确认page确实有PageLRU标志。理论上,从lru_to_page_head()拿到的页面必然是在LRU链表上的。但由于并发、race condition、或其他bug,某些极端情况下PageLRU可能被其他线程意外清掉(例如并发的回收、迁移、释放等)。

注意容易漏掉的点:为什么put_page()?(代码块里写了)

五、迁移准备promote_page_list()

对上一轮确认的页面做迁移前最后一次检查

  • 必须 trylock_page() ➔ 保证页面迁移过程独占。
  • 添加业务的实际需求比如是否支持大页面迁移、SetPageActive等 ➔ 确保迁移合理性。
  • 部分isolate检查在__isolate_lru_page_prepare函数也做过,是否重复检查要考虑的点是:isolate状态下可能有其他线程修改了页面状态?可能被修改的状态再检查一遍是必须的。
// 关键步骤:
    while (!list_empty(page_list)) {
		struct page *page;

		page = lru_to_page(page_list);
		list_del(&page->lru);
	
		if (!trylock_page(page)){ // 加锁,SetPageActive()、迁移等操作必须持有 page lock
			// __isolate_lru_page_prepare 并没有 lock,只是快速检查,因为不迁移
			goto __keep;
		}
		if (unlikely(!page_evictable(page))){ 
			goto __keep_locked;
		}
		if (PageWriteback(page)){
			goto __keep_locked;
		}
		if (PageTransHuge(page) && !thp_migration_supported()){
			goto __keep_locked;
		}

		SetPageActive(page);
		list_add(&page->lru, &promote_pages);
		unlock_page(page);
		continue;
__keep_locked:
		unlock_page(page);
__keep:
		list_add(&page->lru, &ret_pages);
    }
条件 isolate (__isolate_lru_page_prepare) promote (promote_page_list)
是否在LRU链表 不检查
是否Unevictable
是否Writeback
是否已锁定 不检查 必须 trylock_page()
是否THP 不关心 迁移操作支持

六、执行迁移migrate_page_list()

static unsigned long migrate_page_list(struct list_head *migrate_list,
	pg_data_t *pgdat, bool promotion)
{
    // ……

    if (list_empty(migrate_list) || target_nid == NUMA_NO_NODE)
        return 0;

    migrate_pages(migrate_list, alloc_migrate_page, NULL,
        target_nid, MIGRATE_ASYNC, MR_NUMA_MISPLACED, &nr_succeeded);

    // 有需要的话,可以监控迁移成功次数。需要额外代码完善/proc/vmstat接口。
    count_vm_events(NR_PROMOTED, nr_succeeded);

    return nr_succeeded;
}
参数 含义
migrate_list 待迁移的页面链表
alloc_migrate_page 回调函数,负责在迁移时分配目标Node的新页面
NULL 回调上下文参数(未使用)
target_nid 目标Node ID
MIGRATE_ASYNC 异步迁移,尽量不阻塞
MR_NUMA_MISPLACED migrate reason,表示是NUMA失配迁移
&nr_succeeded 输出成功迁移的页面数

七、复杂的migrate_pages()

  • 分配目标Node新页面。调用 alloc_pages_node() 等接口,在目标Node上申请一个相同order、相同GFP的页面。支持Hugepage、Anonpage、Filepage等多种情况。

    当目标Node内存不足 ➔ 分配失败 ➔ 页面迁移失败 ➔ fallback.

  • 拷贝旧页面数据到新页面。通常调用 migrate_page_copy()。

    对于Anon Page ➔ 拷贝Page内容(匿名内存);
    对于File Page ➔ 特别注意PageCache、Dirty bit等状态同步;
    Compound page的拷贝又不一样。

  • 更新映射关系(page table remap)。page table remap后,旧页才能真正释放。①修改VMA的页表项(PTE)。危险性极高 必须持有pte lock. ②让虚拟地址开始指向新分配的页面并且MMU刷新TLB. 此操作依赖页表正确, 因为刷新TLB只是“同步通知CPU”(使用flush_tlb_page()),并不会主动修复问题。

  • 释放旧页面、LRU链表更新。

    flowchart TD
      A[释放旧页面、LRU链表更新] --> B{虚拟地址只有一个}
      B -->|VMA/PTE只能指向一个物理页| C[物理页可能临时有两个]
      C --> D[旧页 + 新页]
      D --> E[页表切换后旧页面被put_page]
      E --> F{引用计数为0?}
      F -->|是| G[释放旧页]
      F -->|否 refcount > 0| H[swap/writeback/page cache等暂时需要]
      H --> I[VMA映射已指向新页]
      I --> J{其他引用释放?}
      J -->|是| G

并发的page fault风险:

flowchart TD
    subgraph 迁移过程
        A[迁移线程] -->|页表更新| B[新页]
    end
    
    subgraph 并发访问
        C[另一个CPU线程] -->|访问同一虚拟地址| D[触发缺页异常]
        D --> E[触发页表更新]
    end
    
    E -.->|无同步| F[看到旧页]
    B -.->|迁移线程已换新页| F
    F --> G[UAF或数据丢失]
    
    E -.->|竞争条件| H[两边互相覆盖页表]
    H --> I[系统崩溃]
    
    style G fill:#ff6b6b
    style I fill:#ff6b6b

解决方案:

flowchart LR
    A[PageLock加锁] -->|读写序列化| B[解决并发访问]
    C[pte lock保护] -->|页表更新原子性| D[SMP-safe]
    
    style B fill:#51cf66
    style D fill:#51cf66

文章作者: 易百分
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 易百分 !
  目录