[内核内存] [arm64] 内存回收机制深度解析:从shrink_node到页面回收实战

1. 内存回收:当你的ARM64服务器开始“断舍离”

想象一下,你正在运行一个高负载的数据库服务,突然发现系统响应变慢,dmesg里开始出现“page allocation failure”的警告。这不是硬件故障,而是Linux内核在告诉你:“内存不够用了,我得赶紧清理点东西出来!” 这个过程,就是我们今天要深入探讨的内存回收

在ARM64服务器上,内存回收机制和x86架构在核心思想上是一致的,但底层页表处理和缓存一致性等方面有其独特之处。简单来说,内存回收就是内核在物理内存紧张时,扮演一个“管家”的角色,它需要决定哪些数据可以暂时请出内存(比如写到交换分区),哪些缓存可以丢弃,以便为新的内存申请腾出空间。这个过程对系统性能至关重要,回收得太激进,会导致缓存命中率暴跌,应用性能骤降;回收得太保守,又会直接触发OOM(Out Of Memory)杀手,把进程给“干掉”。

整个回收流程的“司令部”是一个名为shrink_node的函数。它就像项目经理,负责统筹一个内存节点(pg_data_t)的清理工作。它自己并不直接干活,而是协调两个核心部门:页面回收(由shrink_node_memcgs负责扫描LRU链表)和Slab缓存收缩(由shrink_slab负责)。我们的旅程,就从shrink_node这个起点开始。

2. 核心指挥官:shrink_node函数的两层循环策略

shrink_node函数是内存回收的入口和总调度中心。它的逻辑清晰,采用了一个双层循环结构,这种设计是为了在全局效率和局部公平之间取得平衡。

static bool shrink_node(pg_data_t *pgdat, struct scan_control *sc)
{
    do {
        // 外层循环:针对整个内存节点的一轮扫描
        unsigned long nr_reclaimed, nr_scanned;
        struct mem_cgroup *memcg;
        ...
        memcg = mem_cgroup_iter(root, NULL, &reclaim);
        do {
            // 内层循环:遍历该节点下的每个内存控制组(memcg)
            shrink_node_memcg(pgdat, memcg, sc, &lru_pages);
            if (memcg)
                shrink_slab(sc->gfp_mask, pgdat->node_id, memcg, ...);
        } while ((memcg = mem_cgroup_iter(root, memcg, &reclaim)));

        if (global_reclaim(sc))
            shrink_slab(sc->gfp_mask, pgdat->node_id, NULL, ...);
    } while (should_continue_reclaim(pgdat, ...));
    return reclaimable;
}

外层循环的退出条件由should_continue_reclaim函数决定。这个函数非常“务实”,它主要看两点:第一,本轮回收已经捞到了多少页面(sc->nr_reclaimed);第二,节点上还有多少“不活跃”页面可供扫描。如果回收的页面已经达到了一个基础目标(通常是2 << sc->order个页),或者不活跃页面池子已经快见底了,它就会考虑收手,把希望寄托给内存规整(Compaction)等其他机制。

内层循环则是具体任务的执行者。它遍历节点内的每一个内存控制组(Cgroup)。这里有个关键点:即使系统没有启用Cgroup(CONFIG_MEMCG未配置),内核也会视整个节点为一个“全局”的memcg进行处理。对于每个memcg,它依次执行两件大事:

  1. shrink_node_memcg:这是主力部队,负责扫描和回收LRU链表上的用户页面(匿名页和文件页)。
  2. shrink_slab:这是特种部队,负责回收各种内核对象的Slab缓存(比如目录项dentry、索引节点inode缓存)。

我曾在一次性能调优中遇到过问题:某个容器(memcg)内应用内存泄漏,导致整个节点的shrink_slab被频繁调用,但回收效果甚微,因为泄漏的内存不在Slab里。最后通过监控发现,shrink_node_memcg对该容器的扫描收效甚微,但shrink_slab却徒劳地消耗了大量CPU。这时就需要调整Cgroup的内存限制,或者找出泄漏的元凶。

3. 页面回收主力军:shrink_node_memcg的扫描与平衡

当控制权交给shrink_node_memcg,真正的页面回收战役就打响了。这个函数的目标很明确:从指定的LRU链表中找出可以回收的页面。但“找多少”和“怎么找”是个大学问。

3.1 扫描配额的智慧:get_scan_count函数

get_scan_count函数是这场战役的“军师”,它决定了扫描火力(扫描页数)在匿名页和文件页之间的分配比例。它的决策逻辑非常精细,绝不是简单的一刀切:

  1. 基础检查:如果系统根本没有交换空间(!sc->may_swap),那匿名页无法被换出,只能全力扫描文件页。
  2. 内存压力极大:当回收优先级sc->priority为0(最高优先级)且swappiness不为0时,说明系统濒临OOM,此时匿名页和文件页“平等对待”,按相同比例扫描。
  3. 缓存陷阱防御:这是为了防止系统陷入“文件缓存膨胀-挤占匿名内存-触发交换-性能抖动”的恶性循环。内核会计算节点上的空闲页+文件页总数,如果这个值低于所有内存区域(zone)的高水位线之和,它会判断文件缓存已经过多,优先扫描匿名页以缓解交换压力。
  4. 常态下的比例计算:在一般情况下,扫描比例由著名的swappiness参数和页面的“热度”共同决定。内核会维护两个关键统计量:recent_scanned(最近扫描过的页数)和recent_rotated(最近被重新激活的页数)。一个页面的rotated/scanned比值越高,说明它被缓存的价值越大,越不应该被回收。

计算最终扫描页数的公式可以简化为: 扫描页数 = (LRU链表总页数 >> sc->priority) * 权重系数 其中,权重系数对于匿名页是 (swappiness * (recent_scanned[0]+1)) / (recent_rotated[0]+1),对于文件页是 ((200-swappiness) * (recent_scanned[1]+1)) / (recent_rotated[1]+1)sc->priority值越小,表示回收压力越大,>> sc->priority就会右移得越少,最终计算出的扫描页数就越多,回收力度也就越强。

swappiness(默认60)是用户态最重要的调节旋钮。把它调到接近100,内核会更积极地换出匿名页;调到接近0,则会尽量保护匿名页,倾向于丢弃文件缓存。在数据库服务器上,因为数据文件本身在磁盘上,我们通常会把swappiness调低(比如10),以避免重要的进程内存被不必要的换出。

3.2 活跃列表的降温:shrink_active_list函数

LRU链表分为活跃(Active)和非活跃(Inactive)两类。shrink_active_list的工作,就是把活跃链表尾部那些“不够活跃”的页面,降级到非活跃链表,给它们一个“死缓观察期”。

它的执行流程像一个精细的筛选流水线:

  1. 批量隔离:从活跃链表尾部批量抓取nr_to_scan个页面,放到一个临时链表里。批量操作是为了减少对全局LRU锁的持有时间。
  2. 逐页审判:对临时链表里的每个页面进行检查。核心检查是page_referenced(page, ...),它会通过反向映射找到所有映射了这个页面的PTE(页表项),并检查它们的“访问位”(AF)。同时,也会检查页面的PG_referenced软访问标志。
  3. 分流处理
    • 特赦:如果页面是可执行文件的缓存页(vm_flags & VM_EXEC)并且最近被访问过,它会被重新放回活跃链表头部,获得更长的存活时间。这是为了提升交互体验,让常用程序的启动更快。
    • 降级:其他所有页面,无论是否被访问过,都会被清除PG_active标志,加入到非活跃链表的头部

这里有一个关键细节:page_referenced()函数在检查PTE的访问位后,会立即将其清零。这样,当这个页面后来被移到非活跃链表后,如果再次被访问,访问位会再次被置位,这将成为它在后续shrink_inactive_list中被“拯救”回活跃链表的依据。

3.3 非活跃列表的终极审判:shrink_inactive_list与shrink_page_list

非活跃链表是页面的“死缓区”。shrink_inactive_list负责从这里取出页面,并交给终极法官——shrink_page_list进行审判,决定是释放、激活还是留观。

shrink_inactive_list首先进行数量检查(too_many_isolated),如果发现已经有太多页面被隔离出来正在回收,当前的回收线程会主动睡眠一会儿,防止过多回收者同时工作导致系统卡顿。然后,它同样采用批量隔离的方式,从非活跃链表取出一批页面。

真正的核心在shrink_page_list。这个函数很长,但逻辑严密,它像一道复杂的过滤网,对每一个页面进行多达十几项的检查。我们可以将其决策流程提炼为以下几个关键检查点:

检查点匿名页的关键动作文件页的关键动作共同动作
是否可回收? (page_evictable)不可回收则放回Unevictable链表同左检查页面是否被mlock或标记为不可回收
是否被映射? (page_mapped)若不允许解除映射(!may_unmap),则保留同左映射关系是回收的前提,需解除
是否正在回写? (PageWriteback)等待或跳过(视情况)同左避免回收正在写入磁盘的数据
最近被访问过吗? (page_check_references)若被访问,则激活回活跃链表若被访问,根据次数和类型决定激活或保留这是“第二次机会”算法的体现
有交换空间吗? (PageSwapCache)若无,则调用add_to_swap分配swap槽位不适用匿名页换出的前提
能解除所有映射吗? (try_to_unmap)更新PTE指向swap槽直接解除PTE映射回收前必须切断所有用户访问路径
是脏页吗? (PageDirty)必须调用pageout写回swap通常不回写,除非kswapd且脏页过多干净页才能被释放
有私有缓冲区? (page_has_private)较少见尝试释放buffer_head释放文件系统的额外元数据
能脱离缓存吗? (__remove_mapping)从swap cache中删除从page cache中删除最终从缓存系统中移除

对于匿名页,最关键的步骤是换出(Swap Out)。如果匿名页还没有分配swap槽(!PageSwapCache),add_to_swap会为其分配一个。随后try_to_unmap会找到所有映射它的PTE,将PTE的内容从物理页帧号改为swap槽号。脏页必须通过pageout写回到交换分区,变成干净页后,才能通过__remove_mapping从swap cache中移除,最终释放回伙伴系统。

对于文件页,决策更侧重于缓存价值page_check_references的返回值决定了它的命运:

  • PAGEREF_ACTIVATE:最近被访问过,激活回活跃链表。
  • PAGEREF_KEEP:保持现状,留在非活跃链表。
  • PAGEREF_RECLAIM/PAGEREF_RECLAIM_CLEAN:可以尝试回收。

文件页的回收通常更“温和”,除非是kswapd线程且系统脏页积累太多,否则内核会尽量避免回写脏文件页,而是直接丢弃干净的缓存页,因为数据可以从磁盘重新读取。

4. 内核缓存的清理:shrink_slab机制

除了用户页面,内核自己使用的缓存(Slab)也是回收的重要目标。这部分由shrink_slab函数处理。它与页面回收是并行的。

内核中很多子系统(如文件系统、网络、VFS)都会注册自己的shrinker回调函数,这些函数被组织在一个全局链表shrinker_list中。shrink_slab遍历这个链表,询问每个shrinker:“你负责的缓存现在有多大压力?能释放多少对象?”

每个shrinker需要实现两个方法:

  • count_objects:返回当前可回收对象的数量。
  • scan_objects:尝试扫描并释放指定数量的对象。

常见的shrinker包括:

  • shrink_dcache_memory:回收目录项(dentry)缓存。
  • shrink_icache_memory:回收索引节点(inode)缓存。
  • mb_cache_shrink_fn:回收文件系统元数据缓存。

shrink_slab的调用频率和力度,与页面回收的扫描比例(nr_scanned / nr_eligible)成正比。这意味着当页面回收压力大时,内核也会更积极地向Slab缓存“开刀”。有时你会看到系统空闲内存很少,但Slab占用很大,手动触发echo 2 > /proc/sys/vm/drop_caches就是一种强制调用shrink_slab的方式。

5. ARM64架构下的特殊考量与调优实战

虽然内存回收的核心逻辑是通用的,但在ARM64平台上,我们需要注意一些架构相关的细节。

首先,是缓存一致性(Cache Coherency)。ARM64大量使用系统级缓存(System Cache)。当页面被回收、其物理帧被重新分配用作它用时,必须确保旧数据不会从缓存中被错误地访问。这涉及到cache invalidation(缓存无效化)操作。在try_to_unmap和页面释放路径中,ARM64代码需要正确使用__flush_dcache_area之类的指令来维护缓存一致性,这对性能有细微影响。

其次,是页表遍历page_referencedtry_to_unmap都需要遍历映射页面的所有PTE。在ARM64的CONFIG_ARM64_PTDUMP_CORE配置下,我们可以更直观地观察页表映射关系。在多核系统中,反向映射的遍历可能带来锁竞争,影响回收效率。

基于以上理解的调优建议:

  1. 监控先行:不要盲目调参。使用sar -Bvmstat/proc/vmstat中的pgscanpgstealpgfree等指标,以及/proc/meminfo中的Active(file/inactive)Inactive(file/anonymous),了解回收的频率和效果。
  2. 调整swappiness:这是最直接的参数。对于重内存应用(如Redis、Memcached),建议调低(10-30),保护工作集。对于文件服务器,可以调高(60-80)。
  3. 优化vfs_cache_pressure:这个参数控制内核回收dentry和inode缓存的倾向性。默认值100。如果系统打开文件数很多,且内存紧张,可以适当增大(如150)来更积极地回收VFS缓存。
  4. 警惕zone_reclaim_mode:在NUMA系统中(ARM64服务器常见),这个参数控制当一个内存节点(Node)本地内存不足时,是优先回收本地节点内存(模式1),还是允许从其他节点分配。不当的设置可能导致频繁的本地回收,反而降低性能。通常建议设置为0。
  5. 合理配置交换空间:在ARM64服务器上,即使物理内存很大,也建议配置适量的交换空间(如8GB)。这不仅是最后的救命稻草,更重要的是,它为匿名页提供了“退路”,让shrink_page_list的换出流程能正常工作,避免某些情况下直接触发OOM。可以使用SSD作为交换设备以提升速度。

内存回收是Linux内核一个复杂而精妙的子系统。理解从shrink_node到页面回收的完整链条,能帮助我们在面对性能问题时,不再盲目猜测,而是能够有的放矢地观察、分析和调整。在ARM64的生态中,随着更多样化的计算负载出现,深入理解这些底层机制的价值会愈发凸显。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值