本文基于 Linux 6.4-rc1(commit ac9a78681b92)源码,所有代码片段均来自真实内核文件。
内存是操作系统最核心的资源之一。当物理内存不足时,内核必须决定哪些页面可以释放、哪些必须保留——这个过程叫做内存回收(Memory Reclaim) 。本文深入剖析 Linux 内存回收子系统的完整链路:从 LRU 链表组织、kswapd 后台线程、直接回收路径,到 Swap 换出、RMAP 反向映射、OOM Killer,以及内存碎片整理(Compaction)。
一、LRU 页面链表 1.1 五种 LRU 链表 Linux 内核将所有可回收的用户态页面按照访问活跃度 和类型 组织成五条 LRU(Least Recently Used)链表,定义于 include/linux/mmzone.h:
1 2 3 4 5 6 7 8 9 10 11 12 13 #define LRU_BASE 0 #define LRU_ACTIVE 1 #define LRU_FILE 2 enum lru_list { LRU_INACTIVE_ANON = LRU_BASE, LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE, LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE, LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE, LRU_UNEVICTABLE, NR_LRU_LISTS };
五条链表的语义如下:
链表
含义
LRU_INACTIVE_ANON
不活跃的匿名页(栈、堆等),回收时需要换出到 swap
LRU_ACTIVE_ANON
活跃的匿名页,近期被访问过
LRU_INACTIVE_FILE
不活跃的文件页(page cache),若干净可直接释放
LRU_ACTIVE_FILE
活跃的文件页,近期被访问过
LRU_UNEVICTABLE
不可回收页,如 mlock() 锁定的页
Active/Inactive 双链表设计的关键价值 :若所有页面都在同一条链表上,扫描时很容易将那些历史上访问频繁但近期”恰好”没有被访问的页面错误淘汰,产生 thrashing。双链表通过”降级”而非直接淘汰来保护热页面:页面必须先从 active 链表移到 inactive 链表,在 inactive 链表上”熬过”若干次扫描后才会被回收候选。
1.2 per-node LRU 向量:struct lruvec 每个 NUMA 节点(pg_data_t)都维护一个 struct lruvec,其中包含上述五条链表:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 struct lruvec { struct list_head lists [NR_LRU_LISTS ]; spinlock_t lru_lock; unsigned long anon_cost; unsigned long file_cost; atomic_long_t nonresident_age; unsigned long refaults[ANON_AND_FILE]; unsigned long flags; #ifdef CONFIG_LRU_GEN struct lru_gen_folio lrugen ; struct lru_gen_mm_state mm_state ; #endif #ifdef CONFIG_MEMCG struct pglist_data *pgdat ; #endif };
lruvec 中的 nonresident_age 字段是工作集检测 的核心计数器——每次 LRU 发生移动时递增,用于计算 refault distance(见第四节)。anon_cost 和 file_cost 动态记录回收匿名页和文件页各自的代价,平衡扫描压力。
1.3 folio_lru_list:判断 folio 应当位于哪条链表 include/linux/mm_inline.h 中的 folio_lru_list() 封装了 LRU 链表判断逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 static __always_inline enum lru_list folio_lru_list (struct folio *folio) { enum lru_list lru ; VM_BUG_ON_FOLIO(folio_test_active(folio) && folio_test_unevictable(folio), folio); if (folio_test_unevictable(folio)) return LRU_UNEVICTABLE; lru = folio_is_file_lru(folio) ? LRU_INACTIVE_FILE : LRU_INACTIVE_ANON; if (folio_test_active(folio)) lru += LRU_ACTIVE; return lru; }
判断依据是 folio->flags 中的两个核心标志位:
**PG_active**:置位时表示该页面在 active 链表上,是近期访问过的热页面。
**PG_referenced**:访问引用标志,用于实现二次机会(second-chance)算法。当 inactive 链表上的页面被扫描时,若 PG_referenced 已置位,则将其提升回 active 链表并清除标志;若未置位,才将其作为回收候选。
二、kswapd 后台回收 2.1 kswapd 的职责 kswapd 是内核中每个 NUMA 节点对应的一个内核线程(kswapd0、kswapd1…),它在后台默默维护各 zone 的空闲页面水位,避免内存分配路径上的延迟。它的核心逻辑在 mm/vmscan.c 的 kswapd() 函数中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 static int kswapd (void *p) { unsigned int alloc_order, reclaim_order; unsigned int highest_zoneidx = MAX_NR_ZONES - 1 ; pg_data_t *pgdat = (pg_data_t *)p; struct task_struct *tsk = current; const struct cpumask *cpumask = cpumask_of_node(pgdat->node_id); if (!cpumask_empty(cpumask)) set_cpus_allowed_ptr(tsk, cpumask); tsk->flags |= PF_MEMALLOC | PF_KSWAPD; set_freezable(); WRITE_ONCE(pgdat->kswapd_order, 0 ); WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES); atomic_set (&pgdat->nr_writeback_throttled, 0 ); for ( ; ; ) { bool ret; alloc_order = reclaim_order = READ_ONCE(pgdat->kswapd_order); highest_zoneidx = kswapd_highest_zoneidx(pgdat, highest_zoneidx); kswapd_try_sleep: kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order, highest_zoneidx); alloc_order = READ_ONCE(pgdat->kswapd_order); highest_zoneidx = kswapd_highest_zoneidx(pgdat, highest_zoneidx); WRITE_ONCE(pgdat->kswapd_order, 0 ); WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES); ret = try_to_freeze(); if (kthread_should_stop()) break ; if (ret) continue ; trace_mm_vmscan_kswapd_wake(pgdat->node_id, highest_zoneidx, alloc_order); reclaim_order = balance_pgdat(pgdat, alloc_order, highest_zoneidx); if (reclaim_order < alloc_order) goto kswapd_try_sleep; } tsk->flags &= ~(PF_MEMALLOC | PF_KSWAPD); return 0 ; }
kswapd 的主循环逻辑很清晰:
尝试睡眠(kswapd_try_to_sleep),直到某个 zone 的空闲页低于 WMARK_HIGH,被 wakeup_kswapd() 唤醒。
调用 balance_pgdat() 对该 NUMA 节点执行回收。
若回收到的 order 低于请求 order(高阶分配不满足),则再次尝试睡眠等待 kcompactd 完成碎片整理。
PF_MEMALLOC 标志让 kswapd 在分配内存时可绕过部分水位检查,避免回收过程本身因内存不足而卡死。
2.2 balance_pgdat:对单节点执行回收 balance_pgdat() 是 kswapd 的核心回收函数,其签名为:
1 2 static int balance_pgdat (pg_data_t *pgdat, int order, int highest_zoneidx)
它的工作流程:
统计各 zone 的 watermark_boost,决定是否需要 boost 回收。
以优先级(priority)从 DEF_PRIORITY(默认 12)逐步降低,每轮调用 kswapd_shrink_node(),直到水位满足或优先级耗尽。
优先级越低,每次扫描的 LRU 页面比例越高(扫描范围 = 链表长度 >> priority),回收压力越大。
回收结束后唤醒所有因水位不足而被阻塞的用户进程。
2.3 kswapd_shrink_node 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 static bool kswapd_shrink_node (pg_data_t *pgdat, struct scan_control *sc) { struct zone *zone ; int z; sc->nr_to_reclaim = 0 ; for (z = 0 ; z <= sc->reclaim_idx; z++) { zone = pgdat->node_zones + z; if (!managed_zone(zone)) continue ; sc->nr_to_reclaim += max(high_wmark_pages(zone), SWAP_CLUSTER_MAX); } shrink_node(pgdat, sc); if (sc->order && sc->nr_reclaimed >= compact_gap(sc->order)) sc->order = 0 ; return sc->nr_scanned >= sc->nr_to_reclaim; }
kswapd_shrink_node 计算出本轮需要回收的页面数(与各 zone 的高水位之和成正比),然后调用 shrink_node() 实际执行回收,最后返回是否已扫描了足够多的页面。
三、直接回收(Direct Reclaim) 3.1 调用链路 当内存分配器(__alloc_pages())在 slow path 里仍无法满足分配请求时,分配上下文会直接参与内存回收,这就是直接回收 。其调用路径为:
1 2 3 4 5 6 7 8 __alloc_pages_slowpath() └─> __alloc_pages_direct_reclaim() └─> try_to_free_pages() └─> do_try_to_free_pages() └─> shrink_zones() └─> shrink_node() └─> shrink_node_memcgs() └─> shrink_lruvec()
3.2 try_to_free_pages 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 unsigned long try_to_free_pages (struct zonelist *zonelist, int order, gfp_t gfp_mask, nodemask_t *nodemask) { unsigned long nr_reclaimed; struct scan_control sc = { .nr_to_reclaim = SWAP_CLUSTER_MAX, .gfp_mask = current_gfp_context(gfp_mask), .reclaim_idx = gfp_zone(gfp_mask), .order = order, .nodemask = nodemask, .priority = DEF_PRIORITY, .may_writepage = !laptop_mode, .may_unmap = 1 , .may_swap = 1 , }; if (throttle_direct_reclaim(sc.gfp_mask, zonelist, nodemask)) return 1 ; set_task_reclaim_state(current, &sc.reclaim_state); trace_mm_vmscan_direct_reclaim_begin(order, sc.gfp_mask); nr_reclaimed = do_try_to_free_pages(zonelist, &sc); trace_mm_vmscan_direct_reclaim_end(nr_reclaimed); set_task_reclaim_state(current, NULL ); return nr_reclaimed; }
struct scan_control 是贯穿整个回收子系统的控制结构,记录回收目标数量、GFP 标志、是否允许 writepage、是否允许 unmap、是否允许 swap 等所有策略参数。
3.3 do_try_to_free_pages 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 static unsigned long do_try_to_free_pages (struct zonelist *zonelist, struct scan_control *sc) { int initial_priority = sc->priority; retry: do { if (!sc->proactive) vmpressure_prio(sc->gfp_mask, sc->target_mem_cgroup, sc->priority); sc->nr_scanned = 0 ; shrink_zones(zonelist, sc); if (sc->nr_reclaimed >= sc->nr_to_reclaim) break ; if (sc->compaction_ready) break ; if (sc->priority < DEF_PRIORITY - 2 ) sc->may_writepage = 1 ; } while (--sc->priority >= 0 ); }
do_try_to_free_pages 与 balance_pgdat 的策略相同:优先级从 12 逐级降低,每轮调用 shrink_zones() 遍历 zonelist 中的每个 NUMA 节点,调用 shrink_node()。值得注意的是,优先级低于 DEF_PRIORITY - 2 时,即使处于 laptop_mode 也会强制开启 writepage。
四、LRU 页面扫描与回收 4.1 shrink_inactive_list:扫描 inactive 链表 shrink_inactive_list() 是内存回收的核心逻辑,它从 inactive LRU 链表中隔离页面,尝试回收:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 static unsigned long shrink_inactive_list (unsigned long nr_to_scan, struct lruvec *lruvec, struct scan_control *sc, enum lru_list lru) { LIST_HEAD(folio_list); unsigned long nr_scanned; unsigned int nr_reclaimed = 0 ; unsigned long nr_taken; struct reclaim_stat stat ; bool file = is_file_lru(lru); lru_add_drain(); spin_lock_irq(&lruvec->lru_lock); nr_taken = isolate_lru_folios(nr_to_scan, lruvec, &folio_list, &nr_scanned, sc, lru); __mod_node_page_state(pgdat, NR_ISOLATED_ANON + file, nr_taken); spin_unlock_irq(&lruvec->lru_lock); if (nr_taken == 0 ) return 0 ; nr_reclaimed = shrink_folio_list(&folio_list, pgdat, sc, &stat, false ); spin_lock_irq(&lruvec->lru_lock); move_folios_to_lru(lruvec, &folio_list); __mod_node_page_state(pgdat, NR_ISOLATED_ANON + file, -nr_taken); spin_unlock_irq(&lruvec->lru_lock); lru_note_cost(lruvec, file, stat.nr_pageout, nr_scanned - nr_reclaimed); mem_cgroup_uncharge_list(&folio_list); free_unref_page_list(&folio_list); }
整体流程:
调用 isolate_lru_folios() 在持锁状态下从 LRU 链表隔离一批页面到临时链表 folio_list。
释放锁后,调用 shrink_folio_list()(6.4 中已替代旧的 shrink_page_list)处理每个页面的实际回收。
重新持锁,将未能回收的页面放回 LRU(move_folios_to_lru)。
调用 lru_note_cost() 更新 anon/file 回收代价,动态平衡后续扫描比例。
4.2 shrink_folio_list:逐页面决策 shrink_folio_list() 对每个隔离出的页面做细粒度决策:
匿名页(Anon) :先调用 add_to_swap() 分配 swap slot 并加入 swap cache,再通过 try_to_unmap() 解除所有 PTE 映射,最后调用 swap_writepage() 将页面数据写入 swap 分区。
文件页(File) :若页面是脏的(folio_test_dirty),则调用 pageout() → writeback;若页面已经干净,则直接从 page cache 中移除并释放物理页帧。
4.3 isolate_lru_folios:从 LRU 隔离页面 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 static unsigned long isolate_lru_folios (unsigned long nr_to_scan, struct lruvec *lruvec, struct list_head *dst, unsigned long *nr_scanned, struct scan_control *sc, enum lru_list lru) { struct list_head *src = &lruvec->lists[lru]; unsigned long nr_taken = 0 ; while (scan < nr_to_scan && !list_empty(src)) { struct folio *folio ; folio = lru_to_folio(src); if (!folio_test_lru(folio)) goto move; if (!sc->may_unmap && folio_mapped(folio)) goto move; if (unlikely(!folio_try_get(folio))) goto move; if (!folio_test_clear_lru(folio)) { folio_put(folio); goto move; } nr_taken += nr_pages; } }
isolate_lru_folios 从链表尾部(最老的页面)开始扫描,对每个候选 folio 做一系列检查:是否仍在 LRU 上、是否允许 unmap、是否能成功获取引用、是否能清除 LRU 标志(确保独占处理权)。通过检查后才从链表摘下,加入隔离列表 dst。
4.4 refault distance:工作集检测 内核通过 shadow entry(影子条目) 机制来检测被错误淘汰的页面(refault)。当一个页面从 page cache 被逐出时,内核不会立刻删除其在 xarray 中的槽位,而是在原位置存入一个 shadow entry ,包含当前 nonresident_age 的快照(即”驱逐时间戳”)。
当该文件区域被再次访问(page fault)时,workingset_refault() 被调用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void workingset_refault (struct folio *folio, void *shadow) { unpack_shadow(shadow, &memcgid, &pgdat, &eviction, &workingset); eviction <<= bucket_order; refault = atomic_long_read(&eviction_lruvec->nonresident_age); refault_distance = (refault - eviction) & EVICTION_MASK; if (refault_distance > workingset_size) goto out; folio_set_active(folio); }
refault distance = 从页面被逐出到被重新访问,LRU 总共发生了多少次移动(nonresident_age 增量)。若这个距离小于当前工作集大小(active + inactive 链表长度),说明该页面本应留在内存中,内核将直接把它提升为 active,防止反复 thrashing。
五、Swap 机制 5.1 swap_entry_t:swap 槽位引用 swap_entry_t 是一个 32/64 位的不透明整数值,编码了 swap 类型(设备索引)和偏移量(槽位号):
1 2 3 4 typedef struct { unsigned long val; } swp_entry_t ;
其二进制布局:高位为 swap 类型 ID(SWP_TYPE_SHIFT 位),低位为 offset(页面在 swap 分区中的页号)。
5.2 add_to_swap_cache 当匿名页即将被换出时,需要先通过 add_to_swap() 分配一个 swap slot(swp_entry_t),再调用 add_to_swap_cache() 将页面插入 swap cache(swapper_space 的 xarray):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 int add_to_swap_cache (struct folio *folio, swp_entry_t entry, gfp_t gfp, void **shadowp) { struct address_space *address_space = swap_address_space(entry); pgoff_t idx = swp_offset(entry); XA_STATE_ORDER(xas, &address_space->i_pages, idx, folio_order(folio)); unsigned long i, nr = folio_nr_pages(folio); void *old; xas_set_update(&xas, workingset_update_node); VM_BUG_ON_FOLIO(!folio_test_locked(folio), folio); VM_BUG_ON_FOLIO(folio_test_swapcache(folio), folio); VM_BUG_ON_FOLIO(!folio_test_swapbacked(folio), folio); folio_ref_add(folio, nr); folio_set_swapcache(folio); do { xas_lock_irq(&xas); xas_create_range(&xas); if (xas_error(&xas)) goto unlock; for (i = 0 ; i < nr; i++) { old = xas_load(&xas); if (xa_is_value(old)) { if (shadowp) *shadowp = old; } set_page_private(folio_page(folio, i), entry.val + i); xas_store(&xas, folio); xas_next(&xas); } address_space->nrpages += nr; __node_stat_mod_folio(folio, NR_FILE_PAGES, nr); __lruvec_stat_mod_folio(folio, NR_SWAPCACHE, nr); unlock: xas_unlock_irq(&xas); } while (xas_nomem(&xas, gfp)); }
注意此函数会将 page->private 设为 swp_entry_t 的值,这样当页面从 swap 读回时(lookup_swap_cache),内核可以通过 entry 在 swap cache 中查找已有的内存拷贝,避免重复 I/O。
5.3 匿名页换出全流程 1 2 3 4 shrink_folio_list() ├─> add_to_swap() // 分配 swap slot,加入 swap cache ├─> try_to_unmap() // 解除所有进程的 PTE 映射(RMAP) └─> swap_writepage() // 异步将页面内容写入 swap 分区
页面换入(swap-in)时,缺页中断触发 do_swap_page(),先查 swap cache(lookup_swap_cache),命中则直接重建 PTE;未命中则从 swap 分区读回,经历一次 major fault。
六、RMAP(反向映射) 6.1 为什么需要 RMAP 页面回收的一个关键步骤是:找到所有映射了该页面的 PTE ,将它们全部改为 swap entry 或置为无效。这就需要从物理页面反向找到所有的虚拟地址映射——这就是 RMAP(Reverse Mapping) 的作用。
6.2 struct anon_vma:匿名页反向映射 匿名页的 RMAP 通过 struct anon_vma 实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 struct anon_vma { struct anon_vma *root ; struct rw_semaphore rwsem ; atomic_t refcount; unsigned long num_children; unsigned long num_active_vmas; struct anon_vma *parent ; struct rb_root_cached rb_root ; };
每个匿名 VMA 关联一个 anon_vma,后者通过区间树(interval tree)组织所有可能映射了该匿名页的 VMA 集合。当进程 fork 时,子进程的 VMA 会链接到父进程的 anon_vma,形成树状结构,确保 COW 后的页面也能被正确追踪。
文件页的反向映射则依赖 struct address_space(即 inode->i_mapping)中的 i_mmap 区间树,记录所有映射了该文件页的 VMA。
6.3 page_add_anon_rmap 当新的匿名页面通过 PTE 映射到某个 VMA 时,调用 page_add_anon_rmap() 注册 RMAP:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void page_add_anon_rmap (struct page *page, struct vm_area_struct *vma, unsigned long address, rmap_t flags) { struct folio *folio = page_folio(page); atomic_t *mapped = &folio->_nr_pages_mapped; int nr = 0 , nr_pmdmapped = 0 ; bool compound = flags & RMAP_COMPOUND; bool first = true ; if (likely(!compound)) { first = atomic_inc_and_test(&page->_mapcount); nr = first; } if (likely(!folio_test_ksm(folio))) { if (first) __page_set_anon_rmap(folio, page, vma, address, !!(flags & RMAP_EXCLUSIVE)); } }
_mapcount 记录有多少个 PTE 映射了该页面。__page_set_anon_rmap() 将 page->mapping 指向对应的 anon_vma,这是反向查找的入口。
6.4 try_to_unmap_one:解除 PTE 映射 try_to_unmap() 通过 rmap_walk 遍历所有映射了目标 folio 的 VMA,对每个 VMA 调用 try_to_unmap_one():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 static bool try_to_unmap_one (struct folio *folio, struct vm_area_struct *vma, unsigned long address, void *arg) { struct mm_struct *mm = vma->vm_mm; DEFINE_FOLIO_VMA_WALK(pvmw, folio, vma, address, 0 ); pte_t pteval; bool anon_exclusive, ret = true ; struct mmu_notifier_range range ; enum ttu_flags flags = (enum ttu_flags)(long )arg; mmu_notifier_invalidate_range_start(&range); while (page_vma_mapped_walk(&pvmw)) { VM_BUG_ON_FOLIO(!pvmw.pte, folio); } }
对于匿名页,PTE 被替换成 swap entry(编码了 swp_entry_t),后续访问触发缺页中断时内核可识别 swap entry 并发起换入。对于文件页,PTE 直接被清零(置为 not-present),访问时触发 minor fault 从 page cache 重建。
七、OOM Killer 7.1 触发条件 当所有回收尝试(kswapd + 直接回收)都告失败,__alloc_pages_slowpath() 最终调用 out_of_memory(),即 OOM Killer 的入口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 bool out_of_memory (struct oom_control *oc) { unsigned long freed = 0 ; if (oom_killer_disabled) return false ; if (!is_memcg_oom(oc)) { blocking_notifier_call_chain(&oom_notify_list, 0 , &freed); if (freed > 0 && !is_sysrq_oom(oc)) return true ; } if (task_will_free_mem(current)) { mark_oom_victim(current); queue_oom_reaper(current); return true ; } if (oc->gfp_mask && !(oc->gfp_mask & __GFP_FS) && !is_memcg_oom(oc)) return true ; oc->constraint = constrained_alloc(oc); if (oc->constraint != CONSTRAINT_MEMORY_POLICY) oc->nodemask = NULL ; check_panic_on_oom(oc); if (!is_memcg_oom(oc) && sysctl_oom_kill_allocating_task && current->mm && !oom_unkillable_task(current) && oom_cpuset_eligible(current, oc) && current->signal->oom_score_adj != OOM_SCORE_ADJ_MIN) { get_task_struct(current); oc->chosen = current; oom_kill_process(oc, "Out of memory (oom_kill_allocating_task)" ); return true ; } select_bad_process(oc); if (!oc->chosen) { dump_header(oc, NULL ); pr_warn("Out of memory and no killable processes...\n" ); if (!is_sysrq_oom(oc) && !is_memcg_oom(oc)) panic("System is deadlocked on memory\n" ); } if (oc->chosen && oc->chosen != (void *)-1UL ) oom_kill_process(oc, !is_memcg_oom(oc) ? "Out of memory" : "Memory cgroup out of memory" ); return !!oc->chosen; }
out_of_memory 会先检查各类快速出口(OOM killer 被禁用、通知链释放了内存、当前进程即将退出、GFP 标志不允许 FS 操作等),都无法处理时才真正选择受害者进程。
7.2 oom_badness:评分公式 select_bad_process() 遍历所有进程,对每个进程调用 oom_evaluate_task(),后者使用 oom_badness() 计算分数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 long oom_badness (struct task_struct *p, unsigned long totalpages) { long points; long adj; if (oom_unkillable_task(p)) return LONG_MIN; p = find_lock_task_mm(p); if (!p) return LONG_MIN; adj = (long )p->signal->oom_score_adj; if (adj == OOM_SCORE_ADJ_MIN || test_bit(MMF_OOM_SKIP, &p->mm->flags) || in_vfork(p)) { task_unlock(p); return LONG_MIN; } points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) + mm_pgtables_bytes(p->mm) / PAGE_SIZE; task_unlock(p); adj *= totalpages / 1000 ; points += adj; return points; }
评分公式的核心:
1 2 score = RSS + Swap使用量 + 页表占用页数 score += oom_score_adj × (total_pages / 1000)
RSS(Resident Set Size) :进程常驻内存大小,RSS 越大,杀掉后释放的内存越多,得分越高。
swap 使用量 :进程已换出到 swap 的页面数也计入。
页表 :页表本身占用的物理内存也计入。
**oom_score_adj**:范围 [-1000, 1000],通过 /proc/PID/oom_score_adj 调整。设为 -1000 相当于豁免(返回 LONG_MIN),设为 1000 则大幅增加被杀概率。
得分最高 (内存占用最大、oom_score_adj 最高)的进程会被选中为受害者。
7.3 oom_kill_process:发送 SIGKILL 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 static void oom_kill_process (struct oom_control *oc, const char *message) { struct task_struct *victim = oc->chosen; struct mem_cgroup *oom_group ; task_lock(victim); if (task_will_free_mem(victim)) { mark_oom_victim(victim); queue_oom_reaper(victim); task_unlock(victim); put_task_struct(victim); return ; } task_unlock(victim); if (__ratelimit(&oom_rs)) dump_header(oc, victim); oom_group = mem_cgroup_get_oom_group(victim, oc->memcg); __oom_kill_process(victim, message); if (oom_group) { mem_cgroup_scan_tasks(oom_group, oom_kill_memcg_member, (void *)message); mem_cgroup_put(oom_group); } }
__oom_kill_process() 会:
调用 count_vm_event(OOM_KILL) 计数。
先向受害者发送 SIGKILL。
如果该进程的 mm 没有被其他线程共享,将其加入 OOM reaper 队列。OOM reaper 是专门的内核线程(oom_reaper),它在受害者进程的 mm 上调用 exit_mmap() 强制释放内存,而不等待进程自然退出,确保内存被快速回收。
当设置了 memory.oom.group = 1 的 cgroup 中有进程触发 OOM 时,oom_kill_process 会杀死整个 cgroup 中的所有进程。
八、内存压缩(Compaction) 8.1 碎片化问题 Linux buddy allocator 使用的伙伴系统天然会产生外部碎片 :即使总空闲页数充足,也可能无法满足高阶(order > 0)的连续内存分配请求,因为可用页面被分散在不连续的物理地址上。内存压缩(Compaction)通过迁移可移动页面 来合并空闲块,解决这一问题。
8.2 compact_zone compact_zone() 是内存压缩的核心函数,它使用两个扫描指针:
migrate scanner :从低地址向高地址扫描,寻找可迁移的页面(MIGRATE_MOVABLE 类型)。
free scanner :从高地址向低地址扫描,寻找空闲页面块作为迁移目标。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 compact_zone(struct compact_control *cc, struct capture_control *capc) { enum compact_result ret ; unsigned long start_pfn = cc->zone->zone_start_pfn; unsigned long end_pfn = zone_end_pfn(cc->zone); cc->total_migrate_scanned = 0 ; cc->total_free_scanned = 0 ; cc->migrate_pfn = cc->zone->compact_cached_migrate_pfn[sync]; cc->free_pfn = cc->zone->compact_cached_free_pfn; while ((ret = compact_finished(cc)) == COMPACT_CONTINUE) { int err; unsigned long start_pfn = cc->migrate_pfn; } }
迁移完成后,低地址区域腾出了连续的空闲块,高阶分配得以满足。
8.3 kcompactd:后台压缩线程 与 kswapd 类似,每个 NUMA 节点有一个 kcompactd 后台线程。当 kswapd 完成回收但高阶分配仍然失败时,kswapd 会调用 wakeup_kcompactd() 唤醒 kcompactd,后者调用 compact_zone() 执行后台压缩,提高高阶分配成功率而不影响前台服务的延迟。
九、诊断方法 9.1 /proc/meminfo 关键字段解读 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $ cat /proc/meminfo MemTotal: 16384000 kB MemFree: 512000 kB MemAvailable: 4096000 kB Buffers: 128000 kB Cached: 6144000 kB SwapCached: 64000 kB Active: 5120000 kB Inactive: 3072000 kB Active(anon): 2048000 kB Inactive(anon): 1024000 kB Active(file): 3072000 kB Inactive(file): 2048000 kB Unevictable: 32000 kB Mlocked: 32000 kB Dirty: 16000 kB Writeback: 2000 kB AnonPages: 3072000 kB Mapped: 1024000 kB Shmem: 512000 kB
MemAvailable 远比 MemFree 更能反映系统真实可用内存,它综合考虑了 page cache 和 slab 的可回收部分。
Dirty 大 通常意味着 I/O 子系统跟不上写入速度,可能导致回收时的写回等待。
Writeback 长时间非零 表明正在发生大量回写,内存压力较高。
9.2 vmstat 实时监控 1 2 3 4 5 6 $ vmstat -w 1 procs ---memory--- ---swap-- ---io--- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 2 1 102400 512000 64000 3072000 128 256 1024 2048 3000 5000 30 10 55 5 0
关键字段:
si(swap-in) :每秒从 swap 换入的页数。非零表示系统正在积极换入。
so(swap-out) :每秒换出到 swap 的页数。长期非零说明内存严重不足。
**bi/bo**:块设备读写(pages/s),结合 si/so 可判断是 swap I/O 还是文件 I/O。
9.3 /proc/vmstat 细粒度统计 1 2 3 4 5 6 $ cat /proc/vmstat | grep -E 'pgsteal|pgscan|pgmajfault' pgscan_kswapd 1234567 pgscan_direct 123456 pgsteal_kswapd 1000000 pgsteal_direct 90000 pgmajfault 5678
pgscan / pgsteal 的比值反映回收效率:比值越接近 1,说明扫描的页面几乎都能成功回收;比值远大于 1 说明大量扫描都是无效的(页面被重新激活或跳过)。
9.4 sar -B:内存回收统计 1 2 3 4 5 $ sar -B 1 5 Linux 6.4.0 ... 14:30:01 pgpgin/s pgpgout/s fault/s majflt/s pgfree/s pgscank/s pgscand/s pgsteal/s %vmeff 14:30:02 256.0 512.0 1024.0 2.0 2048.0 4096.0 128.0 3800.0 90.33
%vmeff = pgsteal / (pgscank + pgscand) × 100,即回收效率百分比。低于 50% 时说明系统内存压力异常高,大量页面反复被扫描却无法回收。
9.5 BPFtrace 追踪 OOM 1 2 3 4 5 6 7 8 $ bpftrace -e 'kprobe:oom_kill_process { printf("OOM kill: comm=%s pid=%d\n", comm, pid); }' $ bpftrace -e ' kretprobe:oom_badness { printf("oom_badness: pid=%d retval=%ld\n", pid, retval); }'
9.6 dmesg 查看 OOM 日志 1 2 3 4 5 6 7 $ dmesg | grep -i oom [123456.789] oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null), cpuset=/,mems_allowed=0,global_oom,task_memcg=/docker/abc123, task=mysqld,pid=12345,uid=999 [123456.790] Out of memory: Killed process 12345 (mysqld) total-vm:4194304kB, anon-rss:3145728kB, file-rss:131072kB, shmem-rss:0kB, UID:999 pgtables:8192kB oom_score_adj:0
OOM 日志包含:触发约束类型、目标 memcg 路径、被杀进程名/PID、内存分布(total-vm/anon-rss/file-rss)和 oom_score_adj 值,是排查内存泄漏和容量规划的重要依据。
十、总结:内存回收全景 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 内存分配请求(alloc_pages) │ ▼ 快速路径失败 __alloc_pages_slowpath │ ├─── 唤醒 kswapd ──────────────────────────────┐ │ │ ├─── 直接回收 (try_to_free_pages) │ kswapd 后台回收 │ │ │ balance_pgdat │ ▼ │ kswapd_shrink_node │ shrink_node │ shrink_node │ │ └─────────────────────── │ shrink_lruvec │ │ │ ┌────┴────────────┐ │ │ │ │ shrink_active_list shrink_inactive_list │ (降级到 inactive) │ │ ├── isolate_lru_folios │ ├── shrink_folio_list │ │ ├── 匿名页 → add_to_swap → try_to_unmap → swap_writepage │ │ └── 文件页 → 脏则 writeback,干净则直接释放 │ └── 未回收页放回 LRU │ ├─── 内存压缩 (kcompactd / direct compact) │ └─── OOM Killer (out_of_memory) │ select_bad_process (oom_badness 评分) │ oom_kill_process → SIGKILL → OOM reaper
Linux 内存回收机制是一个精巧的多层次系统:LRU 链表提供了基于访问热度的页面分层;kswapd 在水位告急前就开始预防性回收;直接回收作为最后的”同步”手段兜底;refault distance 防止误杀热页面;RMAP 确保能在常数时间内找到并解除所有 PTE 映射;OOM Killer 在万不得已时牺牲”最不重要”的进程换取系统继续运行。理解这些机制的协同工作方式,是编写内存高效程序和调优 Linux 系统不可或缺的基础。
参考源文件(Linux 6.4-rc1):
mm/vmscan.c — 回收主逻辑(6.4-rc1 约 8000 行)
mm/oom_kill.c — OOM Killer
mm/rmap.c — 反向映射
mm/compaction.c — 内存碎片整理
mm/swap_state.c — swap cache 管理
mm/workingset.c — 工作集检测
include/linux/mmzone.h — zone/lruvec 数据结构
include/linux/rmap.h — RMAP 数据结构
include/linux/mm_inline.h — LRU 内联函数