Linux 是如何使用我的内存的?

标题:How is Linux using my RAM?

日期:2025/06/27

作者:Vlastimil Babka

链接:https://www.youtube.com/watch?v=KBz01dCehY4

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。


这是我去年为 Kernel Recipes 会议(一个面向更多新手的会议)最初创建的演讲。所以我猜我解释的一些事情你们可能已经熟悉了,但我希望其中会有很多关于一些我之前从未检查过的晦涩事物的内容,了解它们的历史,然后变得沮丧为什么它是这样的以及为什么它不能再被改变了。我们今天也会看到一些这样的例子。而且这个主题也契合我们早上讨论的可观测性主题,我们还会看到一些例子,说明为什么可观测性会很好,但(实现更好的可观测性)在性能上会变得不可接受(prohibitive)。

所以,我们将讨论在 Linux 中,内存是在内核层面和进程层面是如何被使用的。我们将从每个人都知道的非常基础的概览命令开始,那就是 free。如今,它会显示这些类型的值。我认为它们之前被扩展过,有些列以前是不存在的。嗯,我们可能会尝试思考这些值背后是什么,即使我们没有检查源代码,只是尝试做一些简单的数学运算,看看是否都合理。然后我们突然发现,如果我们尝试将 used 加上 free,我们得到的值大约是 1900,但总内存几乎是 32 GB。所以,即使是这么简单的比较看起来也不合理。是的,所以 usedfree 不等于 total。那么这是怎么回事呢?

于是我们直接遇到了 Linux 内存管理的主要原则:未使用的内存就是浪费的内存。这意味着,一些内存会被我们的进程分配并使用,这是既成事实,它被使用了。然而,对于任何空闲的内存,内核都会尝试在其中放入数据,以防这些数据稍后可以被访问,这样就能节省 I/O 开销。这方面的主要消费者是所谓的页缓存(page cache)。任何被某个进程触及的文件都会将数据带入内存,或者文件被写入时,数据也会尝试留在页缓存中,以使后续访问更快。这意味着,真正空闲的内存(really free memory)只会在系统启动后短暂存在。系统运行时间越长,页缓存中的内存就越多。突然间,free 就没有多大意义了,因为它总是接近于零。因此,我们必须有一些更好的指标来思考,如果用户空间想要请求更多内存,有多少是可用的。

所以,我们可以看到有一列叫做 buff/cache(缓冲区/缓存),这似乎可能就是我刚才解释的页缓存的东西。也许 used 加上 free 再加上 cache 会等于 total。嗯,这样看起来好一点了,但它比 total 还大。所以也许部分缓存实际上被认为是已使用的(used),以使其真正相等?或者我们可以尝试看看 freecache 是否与 available(可用)列相同。它又很接近,但仍然不完美。然后我们终于可以意识到,usedavailable 几乎等于 total,差一些舍入误差。这看起来很有希望,实际上这就是 free 命令的工作原理。但我们还不知道 available 具体是什么,我也还没解释 shared(共享)列是什么。

那么下一步是,因为 free 只是一个用户空间的命令,它必须从某个地方获取数据,而这个“某个地方”就是内核,内核内部保存了所有的统计信息。对于 free 输出这个最不全面的视图,我们可以转向最全面的视图,那就是名为 /proc/meminfo 的伪文件。它包含了描述 Linux 中内存使用情况的所有计数器。但实际上,计数器太多了,以至于字母都变得很小了。我可以去掉标题,但仍然太多了。所以让我们快速去掉一些不太相关的计数器。

我们有这些计数器,它们实际上并不描述占用的内存,而是描述用于访问物理内存的一些内部数据结构或页表(page tables)。所以这些不是内存使用计数器。我们就忽略它们吧。还有一些与 huge TLBs(大页表)相关的计数器,它们甚至不够全面,因为它们只描述了一种可能的大页(huge pages)大小,但实际上可能有多种大小,并且这些细节在 /proc/meminfo 之外。所以我们也忽略它们。然后我们有报告具有 ECC 错误的内存的计数器,内核不再使用它了,如果它不是零可能很有趣,否则就不太有趣。Cma(连续内存分配器)是主要在 Android 手机硬件上使用的东西。Unaccepted 是一个新的计数器,在机密计算(confidential computing)客户机启动后不久可能非零,之后内存最终会被接受(accepted),它会变为零。随着这些不太有趣的计数器被移除,我们现在有空间把 free 的输出也放进去看看。确实,free 命令中的一些值与 /proc/meminfo 中的某些计数器完全对应(或者你得相信我,因为这里四舍五入方式不同),它们完全对应。used 是根据 total 减去 available 计算出来的,正如我所说,在 /proc/meminfo 中没有直接的等价项。

接下来,我们可以对这些剩余的计数器采取的下一个视图是尝试将它们划分为测量用户空间直接使用的内存(绿色部分)和内核本身使用的内存(尽管也可能是代表用户空间使用的),而灰色部分又是那些不描述已用内存的计数器,斜体部分则是冗余的(redundant)。它们描述的是相同的东西,但方向不同(different kind of direction)。所以你不能只是把所有东西加起来就期望等于 total,因为其中一些是冗余的。

如果我们浏览灰色的字段:我们有 MemTotal(总内存),听起来很明显它是什么,但它也有一些细节。它与机器的实际硬件或 RAM 量不匹配,因为一些内存在启动早期就被分配了,而 MemTotal 只描述由内核的主页分配器(main page allocator)使用的内存。因此它总是小于物理 RAM 的量,并且它也可以随着时间变化,比如内存热插拔(hot plug/remove),甚至在客户机(guest)的膨胀(ballooning)驱动程序中——当主机(host)虚拟机管理程序(hypervisor)希望客户机通过膨胀释放一些内存时,这实际上会反映在这个计数器中,但仅适用于内核本身的膨胀驱动程序(我认为是 KVM),而在某些 Azure 或 VMware 上,我认为它们不会改变这些值。所以如果你看到 MemTotal 意外下降,可能是由于那个原因。

然后,例如,如果启用了 Zswap,我们有它占用的内存。Zswap 描述了压缩数据占用的物理 RAM 量。而它的互补计数器 Zswapped 并不描述消耗了多少内存(所以是灰色的),它表示原始页面在被压缩前的大小。这可以用来查看压缩效率如何,例如。

然后,转到其余的内核计数器:我们有一个 Slab 计数器,用于计算 slab 分配器(slab allocator)占用的所有内存。如果你想查看哪种对象有多少以及条目(entries)等细节,还有另一个 /proc/slabinfo 文件包含所有细节。但是,使用 kmalloc 接口进行的大型分配(large allocations)也会计入 Slab 计数器,但在 /proc/slabinfo 中不可见。所以如果你看到不一致,可能是这些大的 kmalloc 分配。我们还将 slab 缓存区分为可以在需要时回收其对象的类型,称为 SReclaimable(可回收)。这意味着当需要时,这些内存也可以以某种方式变得可用,但这并不容易。因为如果你从 slab 页面释放一些对象,并不总是意味着你能释放整个页面,因为一些对象可能在可回收缓存中,但在某个特定时刻却无法回收。所以它不像回收页缓存那么容易。

嗯,我注意到有一个 KReclaimable 计数器,它的值与 SReclaimable 相同。我说这是怎么回事,为什么它在这里?我查看了历史(git blame)看是谁添加了这个无用的计数器,当然是我自己添加的(你可以查),但那是很多年前的事了,我已经忘记了。原因是当时有一个用于 GPU 驱动的 ION 分配器,它会消耗可观的内存量,并且在 /proc/meminfo 中不可见。所以当时看起来编辑添加这个计数器是个好主意。但自那以后,他们用基于共享内存(shared memory)的东西替换了这个分配器,所以它不再有用了。我的意思是,如果我们找到一些合适的消费者,我们可以编辑它,但现在它只是和 Slab 相同的东西。我们不敢真的移除任何已添加的计数器,因为有些程序可能会解析这个文件并期望它们在那里。如果你突然移除它,你就破坏了用户空间(breaking user space),这总是一个坏主意。所以我们只能接受它(stuck with that)。这是一个教训,嗯,我认为在向内核 API 或 ABI 添加东西时,必须始终小心,因为那样你就得永远和它们绑在一起了。

内核内存的另一个消费者是进程的栈(stacks),当进程进入内核时使用。每个用户空间进程也会有一个内核栈(kernel stack),以防它想做系统调用(syscall)或被中断(interrupt)打断。另外,所有用户空间进程都需要页表(page tables)才能运行。我们知道这些占用了多少内存。如果我们有一些辅助页表用于像 KVM 客户机或 IOMMU 这样的东西,它们也有自己的计数器。

KReclaimable 类似,也有一些历史计数器无法移除,现在为零。比如 NFS_UnstableBounce 缓冲区(buffers)是仍然没有被移除的东西,但我认为一旦它们被移除,就会有另一个计数器总是零。不过我认为现代电力系统不需要它们,所以大多数情况下已经是零了。融合电力系统(Fused power systems)也需要一些临时缓冲区,特别是当它们需要写回数据时,因为它们必须将它们提交给用户空间进程,这比从内核内部做要更棘手。VmallocUsedVmallocChunk 曾经是,但 VmallocChunk 现在为零了,因为 vmalloc 的实现后来改变了(changed me to what mean meanwhile)不再使用那些块(chunks)了。Percpu 是用于每个 CPU(per cpu)数据的计数器,这是内核中使用的东西。嗯,如果它高得可疑,可能意味着这类分配正在泄漏(leaking)。

好了,这就是内核计数器和非计数器。现在让我们最后看看用户空间内存(绿色部分)。为此,我们还必须认识到有两种用户空间内存(我之前已经提到了一点):页缓存(page cache)是由文件填充的。匿名页(anonymous pages)是由进程使用 mmapMAP_PRIVATEMAP_ANONYMOUS 标志创建然后写入一些数据到其中填充的。这些数据不与任何后备存储(backing storage)关联。所以如果它需要被回收(reclaim),只能被换出(swapped out)到交换分区(swap partitions)或文件。这使得它与页缓存非常不同。这就是为什么我们几乎在所有地方都区分它们。

页缓存是用户空间的数据,由文件支持(backed by files),除非它在用户空间被覆盖,否则当需要时可以简单地丢弃(discard),并在需要时从文件中读回来。如果它被写入过,就需要写回(write back)到文件,然后才能被丢弃。嗯,我说有两种,但总会有差一错误(off by one error)。所以还有第三种,表现得有点像两者。它被认为是页缓存的一部分,因为它可以比创建它的进程存活得更久,例如 tmpfs 中的文件。但它没有持久存储(no persistent storage),所以它只能像匿名页一样被换出(swapped out)。所以,有很多很多计数器(绿色部分)谈论我刚才描述的这三种内存。为了说明它们,我创建了这些维恩图(Venn diagrams)。

首先是匿名计数器:我们有 AnonPages 计数器,这是那些由带 MAP_ANONYMOUSPRIVATEmmap 创建的页。其中一些可能是透明大页(transparent huge pages),所以有另一个计数器 AnonHugePages 用于它们。这有一个清晰的子集关系(subset relation)。

对于页缓存,情况要复杂得多。我们在 /proc/meminfo 中有一个 Cached 计数器,那是整个页缓存,但 Buffers 是单独列出的(尽管 free 命令我认为是把它们加在一起的)。共享内存(Shmem)是 Cached 计数器的一部分。然后,这些页缓存页中的一些现在也可以是透明大页(huge),所以有另一个计数器 ShmemHugePages 用于它们。但这个计数器可以同时包括共享内存(shmem)和真实文件(real files)。还有一个特殊的计数器 ShmemPmdMapped 专门用于那些是共享内存的透明大页。所以如果你需要,你可以得到每种可能组合的数量。

我们还有计数器(出于某种原因)用于统计有多少页缓存被任何进程映射(mapped)了。我们没有针对匿名内存的特殊计数器,因为根据设计它们都是被映射的,否则它们就不会存在。但对于文件,我们可以跟踪现在有多少被某些进程映射了,有多少只是呆在那里。这实际上并不意味着它没有被使用,因为当有读写操作访问页缓存时,即使它没有被映射,保留那些不在映射中的页在缓存中以避免访问存储也是有意义的。

所以,再次地,为了这个图,我把 CachedBuffers 放在一起,否则就太多了。其中一部分又是 Shmem。然后 Shmem 的一部分可以被进程映射(ShmemMapped),这是 Shmem 的子集,但没有专门用于“被映射但未共享”的计数器(Shmem 总是共享的)。其中一些可能是 PMD 映射(PMD mapped)的,意味着它被映射为透明大页。ShmemPmdMapped 就是这个,但同样没有专门用于非 Shmem 文件的计数器。不过我们有 FileHugePages 计数器用于文件映射的大页,和 ShmemHugePages 用于共享内存。所以一切都很一致,如果你出于某种原因需要,它可以给你一个清晰的画面。

关于用户空间内存的另一个视图可以通过计算其中有多少是活跃(active)和非活跃(inactive)的计数器来提供。这有点是内存回收(memory reclaim)工作方式的实现细节。它试图将更热(hot)或最近访问过的页面与较少最近访问的页面区分开来,并以不同的速率处理它们以尽可能高效地工作。所以我们可以观察到现在有多少在这些活跃和非活跃列表中。这是分别针对匿名内存和文件内存给出的,因为它们在回收行为上非常不同。

这里有个小转折:共享内存(Shmem)是页缓存的一部分,所以它是 Cached 计数器的一部分。但因为它的行为像匿名内存并且必须被换出,它是被计入匿名活跃和非活跃计数器(Active(anon)Inactive(anon))的。可能还有一些不可避免或不可回收(unreclaimable)的内存没有被认为是活跃或非活跃的,但通常没有那么多。

所以我们可以做这样的交叉检查:尝试将 AnonPagesShmem 相加,看看结果是否与活跃和非活跃匿名 LRU 计数器(Active(anon) + Inactive(anon))相加的结果相同。我们可以看到它几乎匹配。总会有一些页面在中间状态(intermediate)的每 CPU 缓存(per cpu caches)中,这意味着它不会完全相同,但几乎是相同的。对于文件方面,我们可以做同样的事情,但我们必须减去 Shmem,因为它不在文件 LRU 上,而是在匿名 LRU 上。

实际上,我见过这些值不匹配的情况,其中 LRU 值大得多。这通常意味着存在某种内存泄漏(memory leak)。一些内存应该被释放,但有人增加了这些页面的引用计数(ref counter),所以它们一直留在 LRU 列表中。这可以用来交叉检查是否发生了这种泄漏。

好了,关于 /proc/meminfo 就这些了。嗯,作为最后的改动,我也把那些你可以加起来期望值接近 MemTotal 的计数器用粗体标出了。在这个列表示例中,是的,当我将那些粗体的计数器相加时,我几乎得到了那 32 GB,差异只有 572 MB,这在任何计数器中都没有体现。这实际上是个好结果,因为内核中有许多分配只是抓取了一些页面,在 /proc/meminfo 中没有对应的计数器。正如你所看到的,把所有东西都加进去会让它更长,并带来更多无法移除的历史包袱的风险。

在各种 bug 示例中,我见过例如 XFS 分配做了大量这类分配却没有被统计的情况。或者我认为网络栈(networking stack)也为缓冲区(buffers)做了一些分配,但它实际上有 TCP 或 UDP 的计数器,只是它在别的地方。这可以用作基本检查,看是否有东西在泄漏。但没有一个确切的值说明它是好是坏。如果这个差值随着系统运行时间增长,那可能意味着有东西在泄漏内存。嗯,我稍后会再讲一点可以对此做些什么。

最后,我还应该解释一下 MemAvailable 是什么,它现在是 free 命令中非常重要的计数器。是的,我们看到它非常接近 freecache 的总和。实际上确实如此,因为它计算文件 LRU 列表上的页面数量,以及可回收的 slab(SReclaimable)。它假设并非所有这些都能被实际回收,所以总是会抽象地减去一些保留部分(reserved part)。如果我们不减去它们,我们会得到这个值(指 free + cache)。但 MemAvailable 实际上更低一些。所以它实际上是估算在用户空间进程需要时,可以回收多少内存或者已经是空闲的。

好了,那是内核范围的视图。但我们可能也想知道我们单个进程使用了多少内存。对于这个,像 pstophtop 这样的工具总是有两个计数器:一个叫做 VSZVSize(虚拟大小),它表示进程的虚拟区域(virtual area)有多大,这不是立即占用的物理内存。另一个是 RSSRes(常驻集大小),它计算有多少页面实际上通过映射匿名内存或文件内存被填充了(populated)。正如我们将看到的,它也不完美,可能会相当误导人。

考虑这个程序(是的,当我把幻灯片放上网时,会链接到它)。它做的是:创建一个内存区域,然后 fork(创建)10 个子进程。这意味着现在有 10 个子进程拥有相同的 1GB 内存区域。然后每个子进程写入这个相同的内存区域。我们可以观察它对 ps 输出或 free 输出的影响。

在我们创建了一个拥有这个 1GB 区域的进程后,我们有一个作为基线(baseline)的已用内存量。虚拟大小(VSZ)大约是 1GB,但实际占用的(occupied)大小(RSS)几乎为零,因为这个区域被创建了但还没有被填充(populated)。一旦进程写入这个 1GB 区域,它就填充了它,所以 RSS 现在也是 1GB,总的已用内存增加了 1GB(同样的输出再次显示)。

现在如果我们 fork 出 10 个子进程,我们看到现在有 10 个进程,它们的 RSS 都是 1GB。但已用内存量实际上并没有增加 1GB 或 10GB。只有当我们让每个子进程写入这个区域时,最终已用内存量才增加了这 10GB,但 RSS 保持不变。所以它实际上并没有告诉我们太多关于这些进程内存使用情况的信息。

这背后发生了什么?是的,内核有每个进程的计数器来统计这个 RSS,即页表中填充的页面。这些被导出,以便像 ps 这样的工具可以读取并显示给你。如果我们映射(mmap)而不使用 MAP_POPULATE 标志,它实际上不会填充内存,所以页表中没有会增加 RSS 的页面。一旦我们写入那里,它才会发生。

当我们进行 fork 时,我们创建了进程的完全相同副本,它们现在应该有独立的内存内容。它所做的是复制所有的页表,使它们独立。但为了节省时间和内存,它并不立即复制所有包含数据的页面;它会透明地在 10 或 11 个进程之间共享它们。只有当每个子进程写入新数据时,通过一种称为写时复制(copy on write)的机制,这些页面才变得不共享(unshared)。而这只有在内存使用量增加时才发生,因为每个子进程获得每个页面的一个新副本。

这并没有反映在 RSS 中,因为它不知道这些页面是否被共享。可以有一个更精确的计数器,比如有一个定义叫 PSS(按比例分摊的内存大小,Proportional Set Size),它可以根据共享该页面的进程数量来衡量每个页面对内存占用的贡献。这本来会更好,但维护它的代价会很高(expensive),因为现在我们在修改一个进程时,还必须更新其他进程的计数器,这将非常不切实际(impractical)。而且,即使是 OOM 终结者(OOM killer)本身也没有更好的指标。在这种情况下,如果我们杀死 10 个子进程中的一个,我们甚至不会获得任何内存,因为内存仍然被其他 9 个子进程共享。

有一种方法可以通过获取 /proc/<pid>/maps/proc/<pid>/smaps_rollup 文件来获得这个更好的值,这些文件会实时计算快照。它确实是以困难的方式完成工作的:遍历所有页表,计算每个页面被多少个进程共享,然后计算这个值。但这是有代价的(costly),所以你必须在需要时付出那个代价。

是的,所以在这种情况下,我们本来可以看到,在 fork 之后 PSS 会很小。嗯,甚至还有一个计数器用于统计一个进程真正唯一使用了多少页面(Unique)。为了让这个更用户友好一些,有一个 smem 工具可以以类似于 ps 的方式显示输出。我们可以看到 PSS 计数器现在起作用了:在 fork 之后,这些值加起来是 1GB,而不是看起来像是每个进程都已经拥有私有 1GB 内存的 RSS 值。

回到如何调试缺失的内核内存:我说过计数器加起来应该接近 MemTotal,但有时它们不接近,并且存在内存泄漏(memory leak)。有一种方法可以找出内核中每一页是谁分配的,但它默认不启用,因为它有内存和 CPU 开销。不过我们分发的内核都配置支持这个功能,它叫做 page_owner。只需要在启动参数中加入 page_owner=on,这意味着它将激活,会有那个开销。但如果你在调试,你大概愿意付出这个代价。然后我们就有这些 debugfs 文件可以告诉我们,由每一个不同的分配函数调用栈(allocation stack of functions)分配的内存占用了多少内存。

这里有一个例子,显示某个东西是如何分配 slab 内存的(用了 1800 个页面)。类似的东西也存在于 slab 缓存(slab caches),只是需要启用一个稍微不同的调试选项(CONFIG_DEBUG_SLAB),然后你就可以看到类似的东西,用于单个类型的 slab 对象。在最近的(recent)内核中也有一些新功能,试图更轻量级(lightweight)并默认启用,那就是 memalloc profiling(内存分配分析)。但缺点是它没有完整的调用栈(complete stacks),只有直接进行分配(immediate caller)的函数。当有一些辅助函数(helper functions)从许多不同地方分配时,你就无法区分它们。所以如果我确实需要调试内存泄漏,我宁愿推荐使用 page_owner

是的,跟踪(tracing)也是可能的。但那是最后一张幻灯片了。所以感谢大家的关注。

(演讲结束后)我其实没有问题,我只想说谢谢你带我们精彩地(excellent)游览了内核呈现给普通用户(poor users)的那些最无用的计数器。我认为这证明了一个重要的观点:你通常不应该关心你有多少空闲内存,或者是什么在消耗那些内存,因为 A) 你没有完整的视图,B) 你不应该关心。但也许会有另一个演讲,由 Michael Kotny 讲,谈论如何通过测量来确认你是否正确地使用了你的内存。但请,请停止查看 free 命令或其他命令的输出,因为它很可能会给你非常误导的答案,并让你担心那些你不应该担心的事情。

(另一个提问者)实际上,有时我们会在…(被打断,可能是让提问者设置麦克风)。我想问一下写时复制(copy on write)机制是如何工作的?它是否涉及 TLB(Translation Lookaside Buffer)?一些页面错误(page faults)?

(演讲者回答)是的,它涉及将页面设置为写保护(write protected),然后在发生页面错误(page fault)时捕获它,并意识到这是写时复制的情况,然后在页面错误处理程序(page fault handler)中执行复制。

(提问者)我好奇… 那么页面错误也会在父进程中触发吗?
(演讲者)是的。
(提问者)哦,好的,谢谢。