回到工程:Linux VM 如何改变性能诊断
前面 16 篇从地址空间到 OOM,逐层拆解了 Linux 虚拟内存子系统的结构和机制。这些知识的价值不只在于读源码——更在于它提供了一种分层诊断习惯:遇到内存相关的性能问题时,先定位层次,再找对象,再看状态转移,最后用指标验证。 核心问题可以压成一句话: Linux VM 的价值不只在源码知识,而在一种分层诊断习惯:先定位层次,再找对象,再看状态转移,最后用指标验证。 系列概念地图 整个系列覆盖的层次和对象: 1234567891011121314151617181920212223用户空间视角 内核视角───────────── ──────────malloc / mmap VMA (vm_area_struct) ↓ 虚拟地址 ↓page fault 页表 (PGD→P4D→PUD→PMD→PTE) ↓ 物理页分配 ↓RSS 增长 ...
OOM Killer:内核什么时候决定杀进程
上一篇讲了 memcg 如何把全局内存资源划分成层级预算。当预算用尽且回收无力时,最后一道防线是 OOM killer——通过终止进程来释放内存。这不是内存管理的常规路径,而是所有正常手段都失败后的兜底。 核心问题可以压成一句话: OOM killer 是多轮分配、回收、压缩、写回、swap 都无法满足请求后的兜底路径,不是内存管理的常规目标。 问题从哪里来 内存分配失败的处理有一个基本问题:内核不能简单地对调用者返回"分配失败"。很多内核代码路径不检查分配失败(GFP_KERNEL 分配假设不会失败),即使返回错误,用户空间进程通常也没有合理的 fallback 逻辑。 所以内核的策略是:在返回失败之前,尽可能通过各种手段释放内存。如果所有手段都用尽仍然无法满足分配请求,最后才走 OOM kill——选择一个进程杀掉以释放它占用的内存。 到达 OOM kill 之前的完整路径: 1234567891011121314__alloc_pages() 分配请求 → 检查 zone 水位线:有空闲页? → 成功返回 → 唤醒 kswapd 后台回收 →...
cgroup memory:内存从全局资源变成局部预算
上一篇讲了 SLUB 如何在页之上管理小对象的分配。到此为止,所有讨论都假设一个全局的内存资源池——进程共享同一组物理页,回收和 OOM 是全局决策。容器化环境打破了这个假设:一个容器不应该消耗完整机器的内存,它的 OOM 不应该波及其他容器。 核心问题可以压成一句话: memcg 把全局 VM 策略投影到层级资源边界里,使回收、统计和 OOM 都带上 cgroup 语义。 问题从哪里来 传统 Linux 内存管理是全局视角:所有进程共享物理内存,kswapd 按全局水位线回收,OOM killer 从全局选 victim。这在单租户系统上没问题,但在多租户场景(容器、虚拟化、共享主机)上不够: 第一,隔离性缺失。一个行为异常的容器可以消耗所有可用内存,触发全局 OOM,导致无关容器的进程被杀。 第二,资源可预测性缺失。一个容器无法知道自己"还能用多少内存"——这取决于其他容器当前的使用情况。 第三,统计粒度缺失。管理员无法回答"这个服务用了多少内存"——/proc/meminfo 只有全局数据。 cgroup memory cont...
SLUB:小对象为什么不直接按页分配
上一篇讲了 buddy allocator 如何按阶管理物理页的分配与释放。但 buddy 的最小粒度是一页(4KB)。内核中绝大多数对象远小于 4KB——一个 struct dentry 是 192 字节,一个 struct inode 是 600 字节左右。如果每个小对象都分配一整页,内部碎片率将高达 95% 以上。 核心问题可以压成一句话: 内核对象通常比一页小得多,SLUB 用 slab cache 把页切成同类型对象池,减少碎片和初始化成本。 问题从哪里来 内核不断创建和销毁各种数据结构:每次 open() 分配一个 struct file,每次路径查找分配一个 struct dentry,每次网络包到达分配一个 struct sk_buff。这些对象有两个共同特点: 第一,大小远小于 4KB。如果用 buddy allocator 直接分配,一个 192 字节的 dentry 占用 4096 字节物理页,利用率不到 5%。 第二,同类型对象的分配/释放极为频繁。dentry 在路径解析密集的工作负载中每秒创建/销毁数十万次。每次都调用 buddy allocat...
Buddy system:物理页如何按阶分配
上一篇讲了 huge page 如何通过增大映射粒度降低 TLB 和页表开销。但分配 2MB 连续物理内存本身就不是简单的事——物理页分配器必须能快速找到指定大小的连续空闲块。Buddy system 就是 Linux 用来解决这个问题的核心机制。 核心问题可以压成一句话: buddy allocator 用按阶(order)拆分和合并管理连续物理页,解决的是页级分配与碎片控制问题。 问题从哪里来 内核需要分配连续物理页的场景很多:DMA buffer 要求物理连续、huge page 需要 order-9(2MB)或 order-18(1GB)的连续块、设备驱动需要特定对齐的物理内存。 最简单的方案是维护一个空闲页链表。但这有两个问题:第一,找"连续 N 页"需要遍历链表检查物理地址连续性,时间复杂度不可控;第二,反复分配释放后内存碎片化,即使总空闲页足够也可能凑不出连续块。 Buddy system 用 power-of-two 分块解决这两个问题:只管理 2^n 大小的块(称为 order-n 块),分配时拆大块为小块,释放时合并相邻同阶块为更大块...
Huge Page:TLB 压力和页表开销
上一篇讲了 NUMA 如何让内存访问不再均匀。但即使在单 node 系统上,当工作集足够大时,另一类开销浮现:TLB miss 和页表自身的内存消耗。Huge page 通过增大映射粒度来同时缓解这两个问题。 核心问题可以压成一句话: huge page 用更大的映射粒度减少 TLB 和页表开销,但引入碎片、分配时机和回收复杂度作为代价。 问题从哪里来 标准页大小 4KB。一个 64GB 内存的机器有 1600 万个物理页。如果一个进程映射了 8GB 工作集,对应 200 万个 PTE。 TLB(Translation Lookaside Buffer)是 MMU 的地址翻译缓存。现代 CPU 的 L1 dTLB 通常只有 64-128 个条目,L2 sTLB 有 1024-2048 个条目。200 万个活跃页面对 2048 个 TLB 条目意味着 TLB 覆盖率不足 0.1%——绝大多数访问要走 page table walk。 page table walk 不是免费的。它需要 4-5 次内存访问(每级页表一次),即使有 page walk cache 辅助,在 TLB...
NUMA:内存为什么有远近
上一篇讲了 swap 如何为匿名页提供外部 backing store。讨论中一直隐含一个假设:物理内存是一块均匀的资源,任何 CPU 访问任何物理页的代价相同。在 NUMA 架构下这个假设不成立。 这篇要回答的核心矛盾: NUMA 让"物理内存"不再是均匀资源;分配位置、CPU 位置和迁移策略共同决定访问成本。 问题从哪里来 在 UMA(Uniform Memory Access)系统中,所有 CPU 共享一条总线访问同一组内存控制器。每个 CPU 访问任何物理地址的延迟相同。早期单路和低端双路机器大多是这种架构。 当 CPU 数量增加,单一总线成为瓶颈。NUMA(Non-Uniform Memory Access)把系统拆成多个节点(node),每个节点包含若干 CPU 核心和一组本地内存。节点内部通过本地总线通信(快),节点之间通过互联(QPI、UPI、Infinity Fabric 等)通信(慢)。 1234567891011UMA: CPU0 CPU1 CPU2 CPU3 \ | | / shared bus ...
Swap:换出去的页怎样回来
上一篇把页回收的整体机制讲清楚了:水位线决定何时回收、kswapd 与 direct reclaim 决定谁来回收。但回收路径里对匿名页有一个前提条件——必须有地方可以把数据写出去。这个"地方"就是 swap。 理解 swap 的关键判断: swap 是匿名页在内存压力下的 backing store;它不是系统失败的标志,而是把物理内存压力转成更慢的外部存储访问。 问题从哪里来 文件页天然有 backing store:磁盘上的原始文件。干净文件页可以直接丢弃,下次访问时从文件重新读入。脏文件页先写回再丢弃。无论哪种情况,数据都有归处。 匿名页没有这种归处。一块 malloc 出来的内存、一个栈帧、一段 mmap(MAP_ANONYMOUS) 区域——它们的内容只存在于物理内存中,没有对应文件。如果要回收这些页面,必须先把内容保存到某个外部存储,等将来再需要时读回来。 没有 swap 时,匿名页完全不可回收。内存压力一旦超过文件页能释放的量,系统直接到 OOM。这就是为什么纯粹"关掉 swap"在内存紧张的工作负载下会导致进程被杀—...
页回收:kswapd 和 direct reclaim
上一篇讲了内核如何用 LRU 近似和 workingset 检测来判断"谁冷谁热"。判断完之后,实际把页面释放出来的工作由回收子系统完成。回收不是一个单点事件,而是围绕水位线、后台线程和分配路径形成的一套压力响应机制。 核心问题可以压成一句话: 页回收不是内存耗尽后的单点动作,而是围绕水位线、后台线程和分配路径形成的一套压力响应机制。 问题从哪里来 内存分配在 Linux 里几乎无处不在:用户态 malloc 背后的匿名页、page cache 的文件页、内核自己的 slab 对象。每次分配都从 buddy allocator 拿物理页。物理页是有限的。 如果等到完全分配不出页面再回收,分配方会被阻塞很长时间——回收可能涉及写脏页、等待 I/O、遍历反向映射。这种"等到没有了才动"的策略延迟不可控。 Linux 的做法是提前开始。内核设定一组水位线(watermark),在不同压力级别触发不同强度的回收。大部分情况下由后台线程 kswapd 在压力升起时提前回收;只有来不及时才在分配路径上直接回收(direct reclaim)。 水...
LRU 和 workingset:内核如何近似"最近使用"
上一篇把反向映射讲清楚了:内核可以从物理页找回所有映射方。一旦找到,下一个问题是:在所有缓存着的页面中,选谁回收?这就是 LRU 和 workingset 机制要回答的事。 核心问题可以压成一句话: Linux VM 无法知道未来访问序列,只能用最近访问证据、链表分层和 refault 信息近似估计 working set。 问题从哪里来 教材把 LRU 画成一个链表:每次访问把页面移到头部,回收时从尾部摘掉。理论上,这对局部性良好的负载接近最优。 实际系统无法直接实现教材 LRU。原因有三: 第一,每次内存访问都移动链表节点,开销不可承受。用户态一个循环可能每秒触发亿次内存访问,不可能每次都走内核锁链表。 第二,精确 LRU 需要全局排序。两个进程分别访问各自的页面,真正的 LRU 需要知道谁先谁后,需要一个全局时间戳或全局链表锁,NUMA 架构下这是严重瓶颈。 第三,内核的信息来源有限。硬件只提供 PTE 的 Accessed 位:访问过就置 1,内核定期清除再观察是否重新置 1。这是一个"采样"而不是"时间戳"。 因此 Linu...
