你所有的内存,都归谁所有¶
标题:All your memory are belong to… whom
日期:2024/10/01
作者:Vlastimil BABKA
链接:https://www.youtube.com/watch?v=1RSKNTVf7tU
注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。
备注一:站内有相同主题相同作者的演讲,可以只看一份,这份排版会好一些。
备注二:我自己也写过相关的文章。不过演讲人是维护者,而我是门外汉🙃
我的名字是 Vlastimil,你可能从《我如何删除了 SLOB 分配器》以及其续集《我如何又删除了 SLAB 分配器》这两部影片中记得我。 现在,我负责维护剩下的 SLUB 分配器,并活跃于内存管理系统(MMS)的其他部分。 但过去两年我一直在谈论删除 SLOB/SLAB 分配器的事情,所以我决定换个主题。我喜欢的另一种演讲形式是,介绍一些介于调试和解释内核内部原理之间的内容,这总是一个很好的机会,可以深入研究源代码和历史,甚至去了解那些我平常不会去看的细节,并从中学习到一些东西,包括那些我希望自己没学到的东西——这次演讲就完全是这种情况。
如果你对标题里糟糕的语法感到困惑,那说明你太年轻了,不记得 2000 年左右的一个网络迷因,它引用了 80 年代末一款翻译得很烂的日本电子游戏。 当然,在那个时候,还不是“你所有的内存”(all your memory),而是“你所有的基本页”(all your base pages),因为那时还没有巨页(huge pages),Linux 甚至都还不存在。我实在忍不住想在幻灯片里放一些更令人难忘的台词,所以提前为此道歉。
free 命令与内存的“假象”¶
那么,如果你想知道你的系统上发生了什么,谁在使用你的内存,也许今天你的桌面环境里已经有了一些图形化的仪表盘。但最基本的东西是一个叫做 free
的命令。今天如果你运行它,它会给你这样的输出。我把它设置成在我家里的台式机上以兆字节(megabytes)为单位输出。你可以看到总内存(total),你可能会期望已用内存(used)和空闲内存(free)加起来等于总内存,因为这听起来很合逻辑。
但事实并非如此。我想它过去可能有所不同,但今天它真的加不起来。 那么这里发生了什么呢?你可能知道 Linux 内核的内存管理原则之一是:
未使用的内存就是浪费的内存。所以,如果你访问了某个文件,比如读取、写入或访问内存映射文件,当你把它从磁盘带到内存时,内核会尝试将它保留在那里,直到内存被其他东西需要为止。只有在其他东西需要时,它才会被丢弃。
这个东西被称为 页缓存(page cache)。这里的 used
实际上试图表达的是有多少内存被占用,并且不能被轻易地丢弃。否则,这个数字就不再有用了,因为在系统运行一段时间后,你总会有足够的页缓存数据,导致 used
看起来总是接近总内存。
所以,不去看 free
命令的源代码,我们可以尝试自己推导一下它是如何工作的:
我提到了页缓存,那么
buff/cache
这个字段是否与之相关?也许内存的构成是
used + free + buff/cache = total
?这个思路接近了,但计算结果并不完全匹配。那么,也许
available = free + buff/cache
?这个组合也比较接近,但仍然不是精确相等。或者,used + available = total
?
考虑可能的模数或舍入误差后,这个关系看起来更接近事实,表明我们取得了一些进展。
我们现在还不知道 available
和 shared
到底意味着什么。
深入 /proc/meminfo¶
free
命令是一个用户空间工具,它当然需要从内核的某个地方获取信息。它获取信息的方式是通过内核导出的 /proc/meminfo
文件,这个文件包含的字段远比 free
命令显示的多。 它的字段非常多,即使我把它分成三列,字号还是很小。
让我们去掉一些字段以便讨论。
与内存碎片化相关的字段:这些字段并不说明内存被谁使用,只是描述内核直接映射的内存碎片化情况的内部信息,对我们用处不大。
HugeTLBfs 相关的字段:对于 HugeTLBfs 页面,我们有一些会计机制来区分空闲(free)和预留(reserved)的,这也超出了这里的讨论范围。
硬件错误相关的字段:比如
HardwareCorrupted
,它统计了因 ECC 错误等原因被发现损坏并丢弃的内存数量。特殊用途字段:
CmaTotal
/CmaFree
在手机上很有用,我知道有些人在服务器上也用它,但这也超出了讨论范围。Unaccepted
是机密计算(confidential computing)的 guest 可以使用的,在所有内存被接受之前,启动后可能会看到非零值。
如果我们丢弃刚刚描述的字段,剩下的仍然很多,但现在更易于管理了。我们可以将它与 free
命令的输出进行比较,看看哪些字段对应。
total
对应MemTotal
。free
对应MemFree
。shared
对应Shmem
(共享内存,稍后描述)。buff/cache
实际上是Buffers
、Cached
和SReclaimable
(Slab 可回收部分)这些字段的总和。available
直接从内核的MemAvailable
字段获取。
接下来,我尝试将这些字段分为:用户空间内存、内核内存,以及那些不代表任何已消耗内存的字段。用斜体表示的名称是冗余的,因为它们是其他某些字段的另一种视图。
meminfo 字段分类解析¶
非消耗内存字段¶
MemTotal
: 正如你所期望的,这是总内存。 它不是你系统全部的物理内存,而只是在内核加载、初始化早期页表、哈希表等之后,可供内核主页分配器使用的内存。 不过,我这台 32GB 的机器,大部分内存最终都进入了页分配器。 有趣的是,这个值会随着内存热插拔而改变,但我之前甚至不知道,虚拟机 guest 的内存气球(memory ballooning)也可以改变这个总内存。
内核内存字段(红色部分)¶
Zswap
/Zswapped
:Zswap
表示持有换出的压缩页面的 zswap 机制实际占用了多少内存。Zswapped
则表示这些页面在被压缩前的大小。 比较这两个字段可以了解压缩效率。Slab
: 这个字段表示所有 slab 缓存占用的内存总量。 它也包括了非常大的kmalloc
分配,这些分配太大,我们不会为它们创建 slab 缓存,而是直接通过页分配器,但仍然会计入Slab
。 所以,如果你把/proc/slabinfo
里所有字段加起来,发现比这里的Slab
值小很多,那可能就是因为这些大的kmalloc
分配。SReclaimable
/SUnreclaim
: 我们区分了一些可以在需要释放内存时自我收缩的 slab 缓存,这就是可回收部分(SReclaimable
),其余的是不可回收部分(SUnreclaim
)。KReclaimable
: 我曾经好奇这个字段统计的是什么,检查后发现它实际上和SReclaimable
是同一个值,没有在其之上增加任何东西。 我当时就想,是哪个白痴会加入这个冗余的字段? 于是我git blame
了一下,发现那个人是 2018 年的我。我完全忘了。 当时的想法是,除了 slab 缓存,内核里还会有更多可回收的东西。 那时候考虑的是用于 GPU 驱动的 ion 分配器。 现在他们有了基于Shmem
的新分配器,而且自那以后也没有其他人添加任何计数器。 我们现在可能无法移除这个字段了,因为某些读取它的程序可能会损坏。KernelStack
: 内核栈占用的内存。这不包括用户空间栈,而是内核栈。 每个用户空间进程都有一个内核栈,因为当它进行系统调用或处理中断时,执行会切换到内核栈。PageTables
: 进程使用的页表占用的内存。 如果这个值异常高,可能意味着某个进程在故意碎片化其地址空间以占用尽可能多的页表。 如果你有 IOMMU 或者运行 KVM guest,它们有时会创建二级页表,这些有单独的计数器。NFS_Unstable
: 一个历史遗留字段,现在硬编码为零,因为它是我们曾经引入但现在无法移除的东西。Bounce
: 用于临时 I/O 缓冲,当无法进行零拷贝 DMA 时使用。WritebackTmp
: 类似于 Bounce,主要用于 FUSE 文件系统,当需要将文件写回到后端存储时,会临时分配内存。Committed_AS
/CommitLimit
:AS
我没找到确切含义,但我推测是地址空间(Address Spaces)。 它计算的是所有进程中可以贡献内存使用量的虚拟内存区域(VMA)的大小,主要是那些私有的、可写的区域。CommitLimit
应该是对这个值的限制,但你可以看到我的Committed_AS
已经超过了CommitLimit
。 为什么我的进程没被内核杀死? 因为这个限制只在系统以“从不超售”(overcommit_never)模式运行时才生效,而我没有运行在这种模式下。CommitLimit
默认是RAM / 2 + Swap
。VmallocTotal
/VmallocUsed
/VmallocChunk
:VmallocTotal
是一个编译时常量,表示内核中用于 vmalloc 分配的虚拟地址空间大小,完全没用。VmallocUsed
更有用,它显示了这部分虚拟地址空间实际被占用了多少内存。VmallocChunk
也是历史遗留,现在硬编码为零。Percpu
: 内核使用大量 per-CPU 变量,这个字段告诉我们这些变量占用了多少内存,可以用来判断是否存在这类内存的泄漏。
用户空间页(绿色部分)¶
我们区分匿名页(Anonymous Pages)和文件页(File-backed Pages)。
匿名页: 主要由进程通过
mmap
与MAP_PRIVATE
和MAP_ANONYMOUS
标志来分配。 当进程访问这些区域时,页面被分配并且对该进程是私有的。 如果内存不足,这些页面必须被换出(swap out)到交换分区或文件中,因为文件系统上没有真实文件来支持它们。文件页(页缓存): 这些页面由文件系统上的文件或块设备支持。 内核会尽可能地将它们保留在内存中。 它们的生命周期与进程无关,即使没有进程映射它们,缓存也可以保留。 如果数据不是脏的(dirty),丢弃它们很容易。
共享内存(
Shmem
/tmpfs
): 这是介于两者之间的混合体。 它在/dev/shm
中以tmpfs
形式存在,也用于某些共享匿名映射。 它像页缓存一样,可以独立于进程存在,但因为它没有持久文件系统的文件支持,所以它必须像匿名页一样被换出到交换空间。
meminfo
中用户空间页的相关计数器关系非常复杂:
基本构成:
AnonPages
: 匿名类型的页面总数。AnonHugePages
: 其中作为透明巨页(THP)存在的匿名页数量。Cached
: 页缓存的主要部分,包括了Shmem
。Buffers
: 与块设备相关的页缓存。重要:
Shmem
是Cached
的一部分,不能直接相加。
LRU 列表视图 (用于内存回收):
为了高效回收,内存页被放在最近最少使用(LRU)列表上,分为
Active
和Inactive
,并进一步分为File
和Anon
。Active(anon)
/Inactive(anon)
: 匿名页的活跃/非活跃列表。Active(file)
/Inactive(file)
: 文件页的活跃/非活跃列表。关键的怪异之处:
Shmem
虽然被计入Cached
(文件视图),但它被放在Anon
LRU 列表上,因为它需要被交换出去。
因此,我们可以通过两种方式来近似验证内存使用量:
AnonPages + Shmem ≈ Active(anon) + Inactive(anon)
(Cached - Shmem) + Buffers ≈ Active(file) + Inactive(file)
在我系统上的计算显示,这些值都非常接近,证明了这种对应关系。如果它们相差很大,可能预示着内存泄漏等问题。
未计入的内存和 MemAvailable¶
回到 meminfo
的概览,我用粗体标出了那些应该加起来等于 MemTotal
的字段。 在我的系统上,我把所有这些字段(内核内存、用户空间内存和空闲内存)加起来,得到了 31422 MB
。 而 MemTotal
是 32019 MB
。这意味着有将近 600 MB 的内存没有被任何计数器计入。
这个计算并不简单,你必须知道该加哪些字段。 我曾经提议增加一个 Unaccounted
计数器,但反馈不一,因为有些内存使用者可能很突出但没有在 meminfo
中体现,这会看起来像内存泄漏。
那么 MemAvailable
到底是什么? 它试图告诉你,在回收了所有可能回收的内存后,你还有多少可用内存。
它的计算公式近似于:
MemAvailable ≈ MemFree + (大部分 Page Cache) + KReclaimable
这是一个乐观的估计,但也是我们能得到的最好的估计值。
进程内存:RSS 的误导性¶
现在,我们知道了内核层面的内存分布,但具体是 哪些进程 在使用内存呢? 你可能会用 ps
、top
或 htop
等工具。
VSZ
或VSS
: 进程映射的虚拟地址空间大小,但这并不代表实际占用的物理内存。RSS
: 驻留集大小(Resident Set Size),指虚拟地址空间中实际有物理内存支持的部分。这好得多,但我们将会看到它也并不完美。
让我们通过一个例子来演示 RSS 是如何误导人的。
分配内存: 一个程序通过
mmap
映射了 1GB 的匿名内存。此时VSZ
约为 1GB,但RSS
很小,因为页面还未被写入(faulted in)。写入内存: 程序写入这 1GB 内存。现在
RSS
增长到约 1GB,系统的已用内存也相应增加了 1GB。这符合预期。Fork 子进程: 程序 fork 出 10 个子进程。
ps
显示所有 11 个进程(1 父 + 10 子)的RSS
都是 1GB。但系统的总已用内存几乎没有增加! 如果你把所有进程的 RSS 加起来(11GB),会得到一个完全错误的数字。子进程写入: 每个子进程都重写自己的那块内存区域。现在,它们的
RSS
仍然是 1GB,但系统的总已用内存猛增了 10GB。
这是为什么呢? 当 fork
发生时,内核并不会立即复制父进程的内存页。相反,它利用了**写时复制(Copy-on-Write, CoW)**机制。 父子进程共享相同的物理页面,直到其中一个进程尝试写入。只有在写入时,内核才会复制该页面,使其成为私有。
因此,fork
本身不增加物理内存使用,但 RSS
计数器会为每个进程都计入这些共享的页面,因为它只看页表是否引用了物理页。
更精确的度量:PSS 和 USS¶
为什么内核不提供更准确的计数器呢?因为实时跟踪的开销太大了。
PSS (Proportional Set Size): 按比例计算共享内存。如果一个页面被 N 个进程共享,每个进程的 PSS 只计入
1/N
的页面大小。 所有进程的 PSS 加起来就约等于总的物理内存使用。但要实时跟踪它很昂贵,因为一个进程发生 CoW,就得去更新所有其他共享该页的进程的计数器。USS (Unique Set Size): 只计算一个进程独占的内存。这是 OOM Killer 最理想的度量标准,因为它告诉我们杀死这个进程能确定释放多少内存。 但同样,实时跟踪的开销太大。
解决方案:按需计算
既然内核无法承受实时跟踪的开销,我们可以在需要时付出代价来获取这些精确的指标。 这可以通过 /proc/[pid]/smaps
或其汇总版本 /proc/[pid]/smaps_rollup
来实现。 读取这个文件非常昂贵,因为它需要遍历进程的页表,检查每个页面的属性。
在我们的例子中,fork
之后,smaps_rollup
会显示:
Rss
: 1GBPss
: 约 1/11 GB (因为1个父进程和10个子进程共享)Private_Dirty
+Private_Clean
(这就是 USS): 几乎为 0 (因为所有页都是共享的)
当子进程写入后,它们的 Pss
和 Private_Dirty
(USS) 都会增长到 1GB。 有一个名为 smem
的工具可以帮你友好地展示这些信息。
内核内存泄漏调试¶
如果你的系统上“未计入”的内存非常大,或者随时间增长,那很可能意味着内核中存在内存泄漏。 我们有一些工具来定位它。
CONFIG_PAGE_OWNER
: 编译进内核后,可以通过内核启动参数page_owner=on
启用。 它会记录每个页面分配的堆栈跟踪,开销较大,但只在需要时开启。 你可以查看输出,找到哪些分配堆栈持有的页面数量异常高或持续增长。SLAB_DEBUG
: 类似于PAGE_OWNER
,但针对 slab 分配器。MEMALLOC_PROFILING
: 一个较新的替代方案,旨在实现足够低的开销以便在生产环境中运行,但目前仍在开发中。使用 BPF/eBPF 进行实时追踪: 如果你不想重启内核,并且泄漏仍在发生,可以使用 BPF 追踪工具(如 BCC 中的
memleak
)。 它可以捕获内存分配和释放事件,并报告那些分配了但尚未释放的内存的堆栈跟踪。 我用一个故意泄漏内存的内核模块演示了这一点,memleak
工具成功地找出了泄漏的源头。
关于交换(Swapping)行为的现代变化¶
最后,我想谈谈一个让很多人困惑的话题:交换。
过去的行为: 内核的内存回收策略非常偏向于丢弃页缓存。 它会尽可能地回收页缓存,只有在万不得已时才会去换出(swap out)匿名内存。 所以,除非系统压力极大,你很少会看到交换分区被使用。
现在的行为 (自内核 5.9 左右): 情况变了。现在的内核在追踪哪些页面“真正”不被使用方面做得更好了。
想象一个场景:你的系统工作负载完全可以装入内存,不需要交换。 但是,有一些夜间活动,比如杀毒软件扫描或备份,会访问大量文件,导致页缓存激增。 在旧内核上,这只会导致页缓存被回收。 但在新内核上,你可能会发现,即使系统负载不重,也有一些内存被交换出去了。
这是 bug 吗?不。 这意味着内核识别出,有一些匿名页面(比如某个进程启动时分配但从未再接触过的内存)长期处于闲置状态。 内核认为,将这些真正“冰冷”的匿名页换出,来为可能被再次访问的“温热”的页缓存腾出空间,是更高效的做法。
所以,如果你看到这种行为,请不要想当然地认为这是个 bug。
问答环节¶
问1: 关于内存泄漏,内核里还有 kmemleak
。您对它的误报、漏报以及在生产环境启用有何看法?
答: 我认为它和基于 BPF 的追踪工具类似。它对于调试或内核机器人进行的测试很有用。 我不熟悉在生产环境中使用它的情况,我猜它的开销可能不是你想要的。但对于调试来说,它也可以使用。
问2: /proc/meminfo
的值多久更新一次?可以配置吗?我猜它是每秒快照一次?
答: 它是实时的。 或者说,其中一些计数器可能由分布式计数器支持,只有在跨越某个阈值后才会变得精确。 但它应该是即时的,你可以随心所欲地快照它,而且没有明显开销,不像 smaps
,读取它会阻塞你正在快照的进程。
问3: 关于您提到的写时复制共享时的 RSS 记账问题。您是否考虑过将非共享的 RSS 与共享的 RSS 分开显示?如果每个页面有一个用户计数,当计数大于 1 时,就可以把它显示在不同的部分。然后在系统全局层面,可以计算一个平均每页用户数,用它作为除数来近似计算共享 RSS 部分。
答: 这听起来是个值得考虑的事情,但我需要思考一下。谢谢。
谢谢大家。