前文回顾: 我们此前已经梳理了 OOM 的触发条件与 Memory Reserve 机制。当系统彻底无页可回收到、内存保留耗尽后,内核就进入 out_of_memory() 中的“杀人决策”环节——从数百个运行进程和内核线程中,精准选出一个作为 victim 处死。这个决策的核心,就藏在 oom_badness() 这个“死亡名单打分算法”里。本章我们就完全基于源码,逐行解析这个看似简单却充满工程智慧的打分逻辑。


一、总体流程:三道过滤 + 一个打分

oom_badness() 不是被 out_of_memory() 直接调用的,它位于 oom_evaluate_task() 这一层评估函数中,被 select_bad_process() 遍历所有任务时调用。整体流水线可以总结为:

out_of_memory()
  └─→ select_bad_process(oc)
        ├─ 全局 OOM:for_each_process(p) → oom_evaluate_task(p, oc)
        └─ 内存组 OOM:mem_cgroup_scan_tasks(oc->memcg, ...)
              └─→ oom_evaluate_task(task, arg)
                     ├─ oom_unkillable_task(task)        → 第一道过滤
                     ├─ oom_cpuset_eligible(task, oc)    → 第二道过滤
                     ├─ tsk_is_oom_victim(task)          → 第三道过滤
                     ├─ oom_task_origin(task)            → 特殊豁免
                     ├─ oom_badness(task, totalpages)    → 核心打分
                     └─ 比较 chosen_points,更新 chosen

这就是整个选择过程:遍历 → 过滤 → 打分 → 取最高分

2026/08/halo_a2rubz6.webp

二、三道前置过滤

2.1 第一道:oom_unkillable_task —— 谁绝对不能死

/* 第 164 行 */
static bool oom_unkillable_task(struct task_struct *p)
{
    if (is_global_init(p))
        return true;
    if (p->flags & PF_KTHREAD)
        return true;
    return false;
}

这个函数直白而冷酷:如果进程是 init(PID 1) 或者 内核线程(PF_KTHREAD,立即返回 true——这意味着 oom_evaluate_task 中会走到 goto next,该任务被永久排除在候选名单之外。

为什么?

  • init 是系统核心进程,若被杀系统直接崩溃,无法再执行任何清理操作。

  • 内核线程(如 kworkerkswapd)是维持内核运转的关键基础设施,杀死它们会导致系统死锁。

两道检查 is_global_init()PF_KTHREAD 确保了内核不会自杀。

2.2 第二道:oom_cpuset_eligible —— 进程是否“可触及”

static bool oom_cpuset_eligible(struct task_struct *start,
                    struct oom_control *oc)
{
    struct task_struct *tsk;
    bool ret = false;
    const nodemask_t *mask = oc->nodemask;

    if (is_memcg_oom(oc))
        return true;   /* 内存组 OOM 不限制 */

    rcu_read_lock();
    for_each_thread(start, tsk) {
        if (mask) {
            /* mempolicy 约束 OOM:检查内存策略交集 */
            ret = mempolicy_in_oom_domain(tsk, mask);
        } else {
            /* 非 mempolicy OOM:检查 cpuset 内存交集 */
            ret = cpuset_mems_allowed_intersects(current, tsk);
        }
        if (ret)
            break;
    }
    rcu_read_unlock();
    return ret;
}

关键行为:

  • 若 OOM 是由 Memory Cgroup 触发,直接返回 true(该 cgroup 内的进程都是候选,无需额外过滤)。

  • 若 OOM 是全局性内存压力,则对每个线程检查它是否可以触及当前 OOM 触发者的内存域。

    • Mempolicy 约束__GFP_THISNODE 等):通过 mempolicy_in_oom_domain() 检查策略节点重叠。

    • Cpuset 约束:通过 cpuset_mems_allowed_intersects() 检查 cpuset 的内存节点是否相交。

这道过滤的目的:防止杀死那些内存不在同一域中的进程——比如一个进程只绑定了 NUMA node 0,但当前 OOM 发生在 node 1 上的分配,杀死它并不能释放可用内存。

2.3 第三道:tsk_is_oom_victim —— 已标记死亡者不再参与竞争

if (!is_sysrq_oom(oc) && tsk_is_oom_victim(task)) {
    if (test_bit(MMF_OOM_SKIP, &task->signal->oom_mm->flags))
        goto next;
    goto abort;
}
  • tsk_is_oom_victim(task) 的实现极其简洁,仅是一个指针检查:

    static inline bool tsk_is_oom_victim(struct task_struct * tsk)
    {
        return tsk->signal->oom_mm;
    }

    task->signal->oom_mm 非空,该进程已经被选为 OOM victim 且正在被杀。

  • 两种情形:

    • 如果该进程 MMF_OOM_SKIP 标志已设置(OOM reaper 已处理过),则跳过它(goto next),防止重复选择。

    • 否则直接 goto abort,中止遍历(oom_evaluate_task 返回 1),select_bad_process 停止寻找。

为什么要 abort? 如果已经有进程被选为 victim 且正在释放内存(但 OOM reaper 还没来得及回收),立即中止遍历并杀死新进程会破坏当前 victim 的“优先释放内存”机制,可能导致更多浪费。


三、特殊豁免:oom_task_origin —— 必杀标记

if (oom_task_origin(task)) {
    points = LONG_MAX;
    goto select;
}

这个豁免在过滤之后、打分之前,拥有绝对优先级。

oom_task_origin() 的实现:

static inline bool oom_task_origin(const struct task_struct *p)
{
    return p->signal->oom_flag_origin;
}

它仅检查一个布尔位 oom_flag_origin,该位由 set_current_oom_origin() 设置,用来标记触发 OOM 的那个进程本身。它的意义是:当系统必须选一个进程时,优先杀死发起者自己——因为它最清楚自己占了多少内存,杀死它后释放的内存最多。

源码中如何设置这个位:out_of_memory() 入口之前、或用户态通过 /proc/<pid>/oom_score_adj 调整时,内核会设置该标志。一旦设置,该进程的 points 被直接设为 LONG_MAX,无需打分,直接选中。


四、核心打分:oom_badness() 逐行拆解

2026/08/halo_8fxeps4.webp

评分函数的核心定义(mm/oom_kill.c 第 203-241 行):

long oom_badness(struct task_struct *p, unsigned long totalpages)
{
    long points;
    long adj;

    /* 第一道内部防线 */
    if (oom_unkillable_task(p))
        return LONG_MIN;

    /* 获取拥有 mm 的真实线程 */
    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;
    }

    /* 基础得分:RSS + Swap + 页表 / PAGE_SIZE */
    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 */
    adj *= totalpages / 1000;
    points += adj;

    return points;
}

4.1 内部防线一:oom_unkillable_task

oom_evaluate_task 中的检查一致。打分函数内部再次调用,是双重保险——避免外部调用时遗漏。

4.2 查找拥有 mm 的真实线程:find_lock_task_mm

struct task_struct *find_lock_task_mm(struct task_struct *p)
{
    struct task_struct *t;

    rcu_read_lock();

    for_each_thread(p, t) {
        task_lock(t);
        if (likely(t->mm))
            goto found;
        task_unlock(t);
    }
    t = NULL;
    /* found: */
    /* RCU + task_lock 保护 t->mm 不为空 */
    ...
}

为什么需要这个函数?

一个进程的 task_struct 可能有多个线程,而 task->mm 可能被某些线程通过 kthread_use_mm() 分离出去——这些线程不再使用原进程的虚拟内存。同时,还有线程可能已经退出,->mm 已经清空。

find_lock_task_mm 的作用:

  1. 遍历进程 p 的所有线程

  2. 找到第一个拥有 t->mm != NULL 的线程

  3. task_lock() 持有该线程的自旋锁,防止线程切换 ->mm

  4. RCU 方式保护 task_struct 不被释放

如果所有线程都找不到有 mm 的,返回 NULL,该进程被标记为 LONG_MIN(不可杀)。

4.3 内部防线二:用户态调整 + vfork + 已跳过

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;
}
  • oom_score_adj == -1000:用户态(/proc/<pid>/oom_score_adj)将值设为 OOM_SCORE_ADJ_MIN (-1000),等同于 oom_score_adj=0 时的最大负偏移,相当于将该进程"免疫"——永远不会被杀死。打分函数立即返回 LONG_MIN

  • MMF_OOM_SKIP 标志:该进程的页表已在 OOM reaper 处理过程中被映射清除,不再需要作为 victim 被杀死。

  • in_vfork(p):若进程正在进行 vfork 调用,内核不应选中它,以免破坏 vfork 父子进程间共享页表的语义。

4.4 基础得分:RSS + Swap + 文件页 + 页表

points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
         mm_pgtables_bytes(p->mm) / PAGE_SIZE;

得分公式 = 三部分之和,单位都是

  • RSS(驻留集大小)get_mm_rss() 是一个内联函数:

    static inline unsigned long get_mm_rss(struct mm_struct *mm)
    {
        return get_mm_counter(mm, MM_FILEPAGES) +
               get_mm_counter(mm, MM_ANONPAGES) +
               get_mm_counter(mm, MM_SHMEMPAGES);
    }

    它将 文件页 + 匿名页 + 共享页 三部分合并,即进程的物理驻留内存总量。

  • Swap 占用get_mm_counter(p->mm, MM_SWAPENTS) 获取该进程占用的 swap 页数。swap 虽然不占物理 RAM,但它占用了总交换空间,且交换出去的内存无法立即回收,对 OOM 系统构成压力。

  • 页表大小mm_pgtables_bytes(p->mm) / PAGE_SIZE 将该进程页表占用的字节数转换为页单位,纳入得分。这是为了让内存占用大且地址空间分散的进程(如 mmap 大量内存)也能被选中——页表本身也消耗内存。

为什么用驻留集而非总虚拟内存?

OOM 发生在物理内存耗尽,所以必须看 物理页占用,而不是虚拟地址空间大小。一个进程可能映射了 1TB 虚拟内存,但若没有真正分配页,它实际占用的物理内存极少,不应被优先杀死。

4.5 用户态调整:oom_score_adj 缩放调整

adj *= totalpages / 1000;
points += adj;

公式adjusted_points = raw_points + adj * totalpages / 1000

  • totalpagesoc->totalpages,表示整个系统中可用于回收的总页面数(RAM 页面 + Swap 页面)。

  • adjp->signal->oom_score_adj,范围是 -1000 ~ +999OOM_SCORE_ADJ_MIN = -1000)。

  • 缩放系数:totalpages / 1000,将范围 [1000, 999] 映射到 [raw_points - totalpages * 1000/1000, raw_points + totalpages * 999/1000],即 raw_points - totalpagesraw_points + totalpages * 0.999

实际效果:

  • adj = 0,无调整。

  • adj = 1000,得分增加 totalpages,该进程几乎必然成为 victim(除非有更大的)。

  • adj = -1000,得分减少 totalpages,可能跌入负值 → 被 oom_evaluate_taskpoints < oc->chosen_points 过滤掉。

设计意图: 允许管理员将关键进程的 oom_score_adj 降低(如守护进程设为 -1000),或将高消耗进程(如浏览器、Java)设为正值,人为影响 OOM 决策。


五、oom_evaluate_task 的遍历与选择

回到 oom_evaluate_task 的完整逻辑:

static int oom_evaluate_task(struct task_struct *task, void *arg)
{
    struct oom_control *oc = arg;
    long points;

    /* 过滤 1:不可杀任务 */
    if (oom_unkillable_task(task))
        goto next;

    /* 过滤 2:cpuset/mempolicy 过滤 */
    if (!is_memcg_oom(oc) && !oom_cpuset_eligible(task, oc))
        goto next;

    /* 第三道:正在被杀的任务 */
    if (!is_sysrq_oom(oc) && tsk_is_oom_victim(task)) {
        if (test_bit(MMF_OOM_SKIP, &task->signal->oom_mm->flags))
            goto next;
        goto abort;
    }

    /* 特殊豁免:必杀 */
    if (oom_task_origin(task)) {
        points = LONG_MAX;
        goto select;
    }

    /* 核心打分 */
    points = oom_badness(task, oc->totalpages);
    if (points == LONG_MIN || points < oc->chosen_points)
        goto next;

    /* 更新 chosen */
select:
    if (oc->chosen)
        put_task_struct(oc->chosen);
    get_task_struct(task);
    oc->chosen = task;
    oc->chosen_points = points;
next:
    return 0;
abort:
    if (oc->chosen)
        put_task_struct(oc->chosen);
    oc->chosen = (void *)-1UL;
    return 1;
}

关键细节:

  • oc->chosen_points 初始化为 LONG_MIN,第一个合法候选的 points 就会成为新的 chosen

  • get_task_struct / put_task_struct 是引用计数保护,避免在遍历过程中选中的进程被释放。

  • 返回 0 表示继续遍历(找更大的),返回 1 表示停止遍历(abort,已有 victim 正在被杀)。

select_bad_process() 的调用方式也很关键:

if (is_memcg_oom(oc))
    mem_cgroup_scan_tasks(oc->memcg, oom_evaluate_task, oc);
else {
    struct task_struct *p;
    rcu_read_lock();
    for_each_process(p)
        if (oom_evaluate_task(p, oc))
            break;
    rcu_read_unlock();
}
  • 全局 OOMfor_each_process 遍历所有 task,找到后 break

  • 内存组 OOMmem_cgroup_scan_tasks 只扫描 cgroup 内的进程。


六、总结:一个简单的打分,背后的设计哲学

整个 oom_badness 体系虽然只有几十行代码,但蕴含了丰富的设计哲学:

  1. 简单可靠:评分公式只用了驻留内存 + swap + 页表,避免了复杂的权重与优先级。内核开发者认为**"内存消耗最大的进程应该被杀"** 是最可预测的准则,不需要引入负载、优先级、用户身份等复杂因素。

  2. 多层防御oom_unkillable_taskoom_cpuset_eligibletsk_is_oom_victimMMF_OOM_SKIPoom_score_adj == -1000in_vfork —— 六道过滤防线,确保不会被错误选中。

  3. 可干预性oom_score_adj 为用户态保留了精细调控的入口,支持 -1000(免疫)到 +999(优先被杀)全范围调节。

  4. 安全优先find_lock_task_mm 用 RC + task_lock 双重保护,确保不会访问到已经被分离、释放的 mm,避免 use-after-free 漏洞。

  5. 快速决策oom_task_origin 直接给发起者赋 LONG_MAX,避免不必要的遍历,让"谁搞出来的问题谁背锅"的原则落地。

下一章我们将继续深入 oom_reaper —— 那个在进程被杀后异步解除地址空间映射的幕后英雄,以及 OOM Kill + panic_on_oom 的两种紧急处理机制。