Linux 内存管理深度剖析(四):内存回收机制与 OOM Killer

本文基于 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
// include/linux/mmzone.h
#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
// include/linux/mmzone.h
struct lruvec {
struct list_head lists[NR_LRU_LISTS];
/* per lruvec lru_lock for memcg */
spinlock_t lru_lock;
/*
* These track the cost of reclaiming one LRU - file or anon -
* over the other. As the observed cost of reclaiming one LRU
* increases, the reclaim scan balance tips toward the other.
*/
unsigned long anon_cost;
unsigned long file_cost;
/* Non-resident age, driven by LRU movement */
atomic_long_t nonresident_age;
/* Refaults at the time of last reclaim cycle */
unsigned long refaults[ANON_AND_FILE];
/* Various lruvec state flags (enum lruvec_flags) */
unsigned long flags;
#ifdef CONFIG_LRU_GEN
/* evictable pages divided into generations */
struct lru_gen_folio lrugen;
/* to concurrently iterate lru_gen_mm_list */
struct lru_gen_mm_state mm_state;
#endif
#ifdef CONFIG_MEMCG
struct pglist_data *pgdat;
#endif
};

lruvec 中的 nonresident_age 字段是工作集检测的核心计数器——每次 LRU 发生移动时递增,用于计算 refault distance(见第四节)。anon_costfile_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
// include/linux/mm_inline.h
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 节点对应的一个内核线程(kswapd0kswapd1…),它在后台默默维护各 zone 的空闲页面水位,避免内存分配路径上的延迟。它的核心逻辑在 mm/vmscan.ckswapd() 函数中:

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
// mm/vmscan.c
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);

/* Read the new order and 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 的主循环逻辑很清晰:

  1. 尝试睡眠(kswapd_try_to_sleep),直到某个 zone 的空闲页低于 WMARK_HIGH,被 wakeup_kswapd() 唤醒。
  2. 调用 balance_pgdat() 对该 NUMA 节点执行回收。
  3. 若回收到的 order 低于请求 order(高阶分配不满足),则再次尝试睡眠等待 kcompactd 完成碎片整理。

PF_MEMALLOC 标志让 kswapd 在分配内存时可绕过部分水位检查,避免回收过程本身因内存不足而卡死。

2.2 balance_pgdat:对单节点执行回收

balance_pgdat() 是 kswapd 的核心回收函数,其签名为:

1
2
// mm/vmscan.c(第 7347 行)
static int balance_pgdat(pg_data_t *pgdat, int order, int highest_zoneidx)

它的工作流程:

  1. 统计各 zone 的 watermark_boost,决定是否需要 boost 回收。
  2. 以优先级(priority)从 DEF_PRIORITY(默认 12)逐步降低,每轮调用 kswapd_shrink_node(),直到水位满足或优先级耗尽。
  3. 优先级越低,每次扫描的 LRU 页面比例越高(扫描范围 = 链表长度 >> priority),回收压力越大。
  4. 回收结束后唤醒所有因水位不足而被阻塞的用户进程。

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
// mm/vmscan.c(第 7267 行)
static bool kswapd_shrink_node(pg_data_t *pgdat,
struct scan_control *sc)
{
struct zone *zone;
int z;

/* Reclaim a number of pages proportional to the number of zones */
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);
}

/*
* Historically care was taken to put equal pressure on all zones but
* now pressure is applied based on node LRU order.
*/
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
// mm/vmscan.c(第 6998 行)
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
// mm/vmscan.c(第 6780 行)
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_pagesbalance_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
// mm/vmscan.c(第 2556 行)
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);
// 统计 PGSCAN_KSWAPD 或 PGSCAN_DIRECT 事件
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);
// 统计 PGSTEAL_KSWAPD 或 PGSTEAL_DIRECT 事件
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);
// ...
}

整体流程:

  1. 调用 isolate_lru_folios() 在持锁状态下从 LRU 链表隔离一批页面到临时链表 folio_list
  2. 释放锁后,调用 shrink_folio_list()(6.4 中已替代旧的 shrink_page_list)处理每个页面的实际回收。
  3. 重新持锁,将未能回收的页面放回 LRU(move_folios_to_lru)。
  4. 调用 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
// mm/vmscan.c(第 2293 行)
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;
// 移动到 dst 链表
}
// ...
}

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
// mm/workingset.c(第 396 行)
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; // refault 距离太远,不值得激活

folio_set_active(folio); // 直接提升为 active,跳过 inactive 熬炼
// ...
}

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
// include/linux/swapops.h
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
// mm/swap_state.c(第 88 行)
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
// include/linux/rmap.h(第 31 行)
struct anon_vma {
struct anon_vma *root; /* Root of this anon_vma tree */
struct rw_semaphore rwsem; /* W: modification, R: walking the list */
atomic_t refcount;

unsigned long num_children;
unsigned long num_active_vmas;

struct anon_vma *parent; /* Parent of this anon_vma */

/* Interval tree of private "related" vmas */
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
// mm/rmap.c(第 1214 行)
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
// mm/rmap.c(第 1451 行)
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)) {
/* Unexpected PMD-mapped THP? */
VM_BUG_ON_FOLIO(!pvmw.pte, folio);
// ...
// 清除 PTE,写入 swap entry 或置零
// 刷新 TLB
// 调用 page_remove_rmap() 减少 _mapcount
}
// ...
}

对于匿名页,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
// mm/oom_kill.c(第 1106 行)
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))
/* Got some memory back in the last second. */
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
// mm/oom_kill.c(第 201 行)
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;
}

/*
* The baseline for the badness score is the proportion of RAM that each
* task's rss, pagetable and swap space use.
*/
points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
mm_pgtables_bytes(p->mm) / PAGE_SIZE;
task_unlock(p);

/* Normalize to oom_score_adj units */
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
// mm/oom_kill.c(第 1013 行)
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() 会:

  1. 调用 count_vm_event(OOM_KILL) 计数。
  2. 先向受害者发送 SIGKILL
  3. 如果该进程的 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
// mm/compaction.c(第 2317 行)
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;

// 1. 隔离可迁移页面(isolate_migratepages_block)
// 2. 隔离空闲页面(isolate_freepages)
// 3. 执行页面迁移(migrate_pages)
// 4. 释放迁移后的空闲页面回 buddy allocator
}
// ...
}

迁移完成后,低地址区域腾出了连续的空闲块,高阶分配得以满足。

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 # 完全空闲(buddy 中的页)
MemAvailable: 4096000 kB # 实际可用(含可回收 cache),比 MemFree 更准确
Buffers: 128000 kB # 块设备的 buffer cache
Cached: 6144000 kB # 文件 page cache(不含 Buffers 和 SwapCached)
SwapCached: 64000 kB # 已换出但仍在 swap cache 中的页
Active: 5120000 kB # active LRU 总量(anon + file)
Inactive: 3072000 kB # inactive LRU 总量(anon + file)
Active(anon): 2048000 kB # active 匿名页
Inactive(anon): 1024000 kB # inactive 匿名页(换出候选)
Active(file): 3072000 kB # active 文件页
Inactive(file): 2048000 kB # inactive 文件页(回收候选)
Unevictable: 32000 kB # mlock 等不可回收页
Mlocked: 32000 kB # mlock 锁定的页
Dirty: 16000 kB # 脏页(待写回)
Writeback: 2000 kB # 正在写回的页
AnonPages: 3072000 kB # 匿名页总量
Mapped: 1024000 kB # 已映射到用户空间的文件页
Shmem: 512000 kB # 共享内存(tmpfs)
  • MemAvailable 远比 MemFree 更能反映系统真实可用内存,它综合考虑了 page cache 和 slab 的可回收部分。
  • Dirty 通常意味着 I/O 子系统跟不上写入速度,可能导致回收时的写回等待。
  • Writeback 长时间非零 表明正在发生大量回写,内存压力较高。

9.2 vmstat 实时监控

1
2
3
4
5
6
# -w 宽格式,1 秒刷新一次
$ 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 # kswapd 扫描的页数
pgscan_direct 123456 # 直接回收扫描的页数
pgsteal_kswapd 1000000 # kswapd 实际回收的页数
pgsteal_direct 90000 # 直接回收实际回收的页数
pgmajfault 5678 # major page fault 次数(需要磁盘 I/O 的缺页)

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
# 追踪 OOM kill 事件,打印被杀进程名和 PID
$ bpftrace -e 'kprobe:oom_kill_process { printf("OOM kill: comm=%s pid=%d\n", comm, pid); }'

# 追踪 oom_badness 的评分过程
$ 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 内联函数