FreeBSD 吃掉了我的内存!
上个月我发了一篇关于将网站服务器从旧版 Ubuntu 迁移到 FreeBSD 的经历。一些 Hacker News 上的读者注意到,当我展示 fastfetch 的结果时,我说我对 RAM 使用情况感到困惑——与 btop 显示的数值不一致——并评论说 fastfetch 可能更准确。我决定深入这个兔子洞,尝试理解为什么在现代操作系统中报告空闲或已用内存比想象中要复杂得多。
另一位用户分享了《Linux ate my RAM》,这篇文章对 Linux 上的相同现象做了快速解释。如果你也想快速了解 FreeBSD 的情况:使用量有时看起来异常,是因为操作系统会把所有能从磁盘缓存的内容都塞进 RAM 以提升整体性能,但这个缓存是易失性的,在需要更多内存时会被释放。如果你想了解更详细的解释,请继续往下读。
不过在开始之前需要声明:我不是操作系统内核方面的专家,尤其不是 FreeBSD 方面的专家。这只是我利用业余时间花了数周研究后写下的记录。如果发现有任何错误,欢迎留言指正:知识(分)享就是关爱!
RAM 使用量很难定义
《Linux ate my RAM》的核心观点是:未使用的 RAM 就是浪费的 RAM。就像 CPU 缓存会缓存 RAM 的内容(因为 CPU 访问缓存更快),RAM 也会缓存磁盘数据来提升用户体验。这套缓存机制的工作原理要复杂一些,但在此之前,理解内核如何管理 RAM 至关重要。
大多数现代操作系统都拥有虚拟内存(VM)系统。它基本上将物理内存划分为通常为 4KiB 大小的页面(Page)。每个页面被加入不同的队列,内核可以在这些队列之间调度,确保所有进程在需要时能获得内存,并且整个系统能在内存紧张时继续运行。例如 swap 内存。我以前从未仔细想过 swap 内存的具体用法,只知道它是磁盘上一块独立的空间,在需要时临时存储部分 RAM 内容。简而言之,当操作系统发现已分配的 RAM 使用不频繁时,会将其标记为可在需要更多内存时存入磁盘。当这些页面被其所属的进程再次请求时,它们会被从磁盘移回 RAM。
每个操作系统都有不同的页面类型和管理规则。在 FreeBSD 上,页面队列的类型如下:
#define PQ_NONE 255
#define PQ_INACTIVE 0
#define PQ_ACTIVE 1
#define PQ_LAUNDRY 2
#define PQ_UNSWAPPABLE 3
#define PQ_COUNT 4
这些定义可以在 sys/vm/vm_page.h 中找到。所有其他基于 Unix 的系统也有类似的结构:Linux、OpenBSD、NetBSD、DragonFlyBSD。
如果我们查看一下 top,会发现它不仅仅是报告内存使用量,而是将内存分成了几个类别:

active:处于活动状态的页面,正在被(主要是)用户态进程使用中。
inactive:那些没有被进程访问一段时间的页面,会被移入非活动队列。
laundry:等待写入 swap 的页面队列。当系统需要分配空闲队列中不存在的内存空间时,会将非活动页面移入此队列。
wired:即 PQ_NONE、PQ_UNSWAPPABLE 中的内存,以及内核自身使用且不受 VM 管理的内存。
free:纯粹未使用的内存。
当一个曾经是 inactive 的页面进入 laundry、被写入磁盘(swap),然后又被其所属进程请求时,它会从磁盘取回到 inactive,最后回到 active。
现在我们开始明白为什么精确判断有多少内存正在使用、多少是空闲的不那么容易了。free 队列中的内存肯定是空闲的,但我们也可以说 inactive 队列中的也是空闲的,因为它是可回收的——内核在需要更多内存时会将其释放。Wired 内存大部分是锁定的,然而磁盘缓存也在这里,所以 wired 中部分内容也是可回收的,因此也属于"空闲"!
磁盘缓存
ZFS,如今 FreeBSD 的默认文件系统,拥有 ARC(Adaptive Replacement Cache,自适应替换缓存),这是一个专门的系统,用于在内存中缓存最近使用的数据,从而提高重复磁盘读取的性能。当系统需要更多内存时,这个缓存会自动缩小。内核本身也有实现缓存机制,但 ARC 绕过了它。所有相关信息可以通过内核参数 kstat.zfs.misc.arcstats.* 获取。使用 sysctl 可以获取全部内容:
sysctl kstat.zfs.misc.arcstats
这会显示所有可用的参数,但现在只有这些比较重要:
sysctl -n kstat.zfs.misc.arcstats.size
sysctl -n kstat.zfs.misc.arcstats.c_min
sysctl -n kstat.zfs.misc.arcstats.c_max
这些命令会显示当前缓存大小以及配置的最小值和最大值,单位均为字节。使用 gnumfmt 可以将其转换为可读的单位:
$ sysctl -n kstat.zfs.misc.arcstats.size | gnumfmt --to=iec
3.1G
这个信息在 top 中也有显示,而且更加详细。
以上是针对 ZFS 的情况,但你也可以在 FreeBSD 上使用其他文件系统。

为什么 fastfetch 和 btop 报告的结果不同?
现在到了有趣的部分。这两个工具,以及许多其他工具如 htop,都试图报告内存使用情况,以便用户(或系统管理员)了解系统状态。为此,它们都必须选择一个启发式规则;实际上就是决定什么算作"已用内存"。而差异的根源就在于它们选择了不同的启发式规则。
我深入研究了每个工具的源代码,看看它们是如何确定的。fastfetch 的做法是:
free memory = free + inactive + cache*
used memory = total - free memory
关于 cache 的内容后面再讲!
在我的旧 ThinkPad X230(运行 FreeBSD 15.0-RELEASE)上,效果如下:

而 btop 的做法是:
available memory = total memory - active - wired
free memory = free
used memory = active + wired
在同一时间运行它,显示的结果是:

为了更加有趣,我还检查了 htop。它在进度条中分别显示了各类内存,并在进度条末尾显示了已用量:

它使用的启发式规则是:
used memory = wired + active + laundry
然后我写了一个 Python 脚本,一次性展示所有启发式规则的结果。你可以在这里找到它。

除了 btop 偏差很大之外,其他看起来都正常。但如果你仔细观察,还会发现我之前分享的截图中 cache 的值也是空的。我花了数周时间才意识到这个问题。于是我开始了更深入的源码挖掘。
btop 在 FreeBSD 上的内存报告有严重错误
查看 btop 的源代码,特别是 src/freebsd/btop_collect.cpp 中获取内存信息的部分:
int mib[4];
u_int memActive, memWire, cachedMem, freeMem;
size_t len;
len = 4; sysctlnametomib("vm.stats.vm.v_active_count", mib, &len);
len = sizeof(memActive);
sysctl(mib, 4, &(memActive), &len, nullptr, 0);
memActive *= Shared::pageSize;
len = 4; sysctlnametomib("vm.stats.vm.v_wire_count", mib, &len);
len = sizeof(memWire);
sysctl(mib, 4, &(memWire), &len, nullptr, 0);
memWire *= Shared::pageSize;
mem.stats.at("used") = memWire + memActive;
mem.stats.at("available") = Shared::totalMem - memActive - memWire;
len = sizeof(cachedMem);
len = 4; sysctlnametomib("vm.stats.vm.v_cache_count", mib, &len);
sysctl(mib, 4, &(cachedMem), &len, nullptr, 0);
cachedMem *= Shared::pageSize;
mem.stats.at("cached") = cachedMem;
len = sizeof(freeMem);
len = 4; sysctlnametomib("vm.stats.vm.v_free_count", mib, &len);
sysctl(mib, 4, &(freeMem), &len, nullptr, 0);
freeMem *= Shared::pageSize;
mem.stats.at("free") = freeMem;
它使用 sysctl(FreeBSD 的 libc 提供的函数)直接从 vm.stats.vm.* 获取每个队列的页面数量。对于 active 使用 vm.stats.vm.v_active_count,wired 使用 vm.stats.vm.v_wire_count,没问题。然后它通过将页面数量乘以 Shared::pageSize 来计算字节数。不过这里有一个问题。数据存储在 memActive 中,这是一个 u_int,也就是无符号 32 位整数。不用精确计算,我记得我们切换到 64 位 CPU 时,听到最多的一个理由就是:"现在你可以拥有超过 4GB 内存了"。

我们讨论的是无符号 32 位整数,所以最大值是 4,294,967,295。如果 4GiB 换算成字节,那就是 4,294,967,296。这意味着任何超过这个值的数都会溢出。看看我的脚本输出的 btop "已用内存" 是 4.42 GiB,而 btop 自己显示的是 422 MiB,很明显是溢出了。
缓存为空的问题
在我写那篇关于将博客服务器迁移到 FreeBSD 的文章时,我一开始没有意识到的另一个问题是,Cached 内存桶是空的。为了进一步验证,我创建了两台虚拟机,分别运行 FreeBSD 13.5-RELEASE(使用 ZFS)和 15.1-RELEASE(使用 UFS),再加上我本机运行 15.0-RELEASE(使用 ZFS)的笔记本电脑。

通过 pkg 安装 btop(以及其他工具)后,我开始测试:

在两种文件系统上,都可以看到 Cached 桶是空的。
在检测代码中,它从 vm.stats.vm.v_cache_count 获取信息:
len = sizeof(cachedMem);
len = 4; sysctlnametomib("vm.stats.vm.v_cache_count", mib, &len);
sysctl(mib, 4, &(cachedMem), &len, nullptr, 0);
cachedMem *= Shared::pageSize;
mem.stats.at("cached") = cachedMem;
当我尝试自己用 sysctl 获取这个值时:
$ sysctl -n vm.stats.vm.v_cache_count
0
我了解到 -d 参数可以显示每个参数的描述,不过大多数参数都没有文档,所以帮助不大。但 v_cache_count 例外:
$ sysctl -d vm.stats.vm.v_cache_count
vm.stats.vm.v_cache_count: Dummy for compatibility
没错,它返回 0 因为这是一段遗留代码。实际上,FreeBSD 从 12.0 版本就开始报告 "Dummy for compatibility" 了!我们可以对比一下 v_cache_count 的描述差异:
FreeBSD 12.0(sys/vm/vm_meter.c):
#ifdef COMPAT_FREEBSD11
/*
* Provide compatibility sysctls for the benefit of old utilities which exit
* with an error if they cannot be found.
*/
SYSCTL_UINT(_vm_stats_vm, OID_AUTO, v_cache_count, CTLFLAG_RD,
SYSCTL_NULL_UINT_PTR, 0, "Dummy for compatibility");
SYSCTL_UINT(_vm_stats_vm, OID_AUTO, v_tcached, CTLFLAG_RD,
SYSCTL_NULL_UINT_PTR, 0, "Dummy for compatibility");
#endif
而之前的版本 FreeBSD 11.4:
VM_STATS_VM(v_cache_count, "Pages on cache queue");
但有趣的是,缓存队列(cache queue)从 FreeBSD 6.3.0 开始就已经不存在了。我安装了 11.4 版本,虽然 sysctl -d vm.stats.vm.v_cache_count 返回的描述是 "Pages on cache queue",但实际上获取的值总是 0。我还试了 6.3.0,v_cache_count 也是 0。所以不知道为什么会这样。
btop 对 v_cache_count 的使用从第一个 FreeBSD 构建版本就有了,而这个版本是在 FreeBSD 12.0 之后发布的,所以也不清楚为什么它能一直存在这么久。
着手修复
于是我开始了修复工作。首先,我将存储队列字节数的变量精度提高了一倍,这样溢出问题就解决了。然后我思考如何正确跟踪文件系统缓存。在翻阅 htop 源码寻找指导时,我发现了这段注释:
// comment by Pierre-Marie Baty <pm@pmbaty.com>
//
// FreeBSD has the following memory classes:
// active: userland pages currently mapped to physical memory (i.e. in use)
// wired: kernel pages currently mapped to physical memory, cannot be paged out nor swapped
// buffers: subcategory of 'wired' corresponding to the filesystem caches
// free: pages that haven't been allocated yet, or have been released
//
// With ZFS, the ARC area is NOT counted in the 'buffers' class, but is still counted in the 'wired'
// class. The ARC total must thus be subtracted from the 'wired' class AND added to the 'buffer' class,
// so that the result (ARC being shown in buffersMem) is consistent with what ZFS users would expect.
// This adjustment is done in Platform_setMemoryValues() in freebsd/Platform.c.
更具体来说是"ARC 总量必须从 wired 类中减去"。这说得通,但我注意到它并没有这样做。这促使我为 htop 提交了一个 PR 来修复这个问题,同时将 cache 和 buffers 内存类合并。这是一个支线任务的支线任务,不过 PR 已经被合并了:

vfs.bufspace 报告 FreeBSD 自身的文件系统元数据缓存(ARC 绕过此机制)现在我有了在 btop 中推进修复的计划:简单来说就是从 wired 内存中减去 ARC 缓存的大小(ARC cache 是可变大小的)。同时,就像 htop 一样,将 vfs.bufspace 视为可回收缓存。这是我的PR。截至撰写本文时,它尚未被审查或批准。
以下是我在 PR 中对 src/freebsd/btop_collect.cpp 中处理缓存部分的代码:
// cached
len = 2;
if (sysctlnametomib("vfs.bufspace", mib, &len) == 0) {
uint64_t bufSpace = 0;
len = sizeof(bufSpace);
if (sysctl(mib, 2, &bufSpace, &len, nullptr, 0) == 0) {
// vfs.bufspace is the filesystem metadata buffer cache,
// which is reclaimable
cachedMem = bufSpace;
}
}
// For ZFS, also add ARC size to cached
size_t arcSize = 0;
len = 4;
if (sysctlnametomib("kstat.zfs.misc.arcstats.size", mib, &len) == 0) {
len = sizeof(arcSize);
if (sysctl(mib, 4, &arcSize, &len, nullptr, 0) == 0) {
cachedMem += arcSize;
// ARC is counted in wired, so subtract it
if (memWire >= arcSize) {
memWire -= arcSize;
}
}
}
mem.stats.at("used") = memWire + memActive;
mem.stats.at("cached") = cachedMem;
这个修复实现了两件事:
1. 不再有 32 位整数溢出——所有变量都改为 uint64_t,可以正确报告超过 4GiB 的内存。
2. 缓存现在正确显示了——通过将 ARC 和 vfs.bufspace 计入缓存并从 wired 中扣除,用户现在可以看到文件系统缓存的实际大小,以及真正可回收的内存量。
总结
所以,FreeBSD 并没有"吃掉"你的内存——它只是充分利用了它。差异完全取决于你如何定义"已用"和"空闲"。如果你运行的是 ZFS,ARC 会占用大量 wired 内存作为缓存,但这完全是正常且有益的。不同的工具(fastfetch、btop、htop)选择不同的启发式规则来呈现内存使用情况,从而导致数值差异巨大。
我们在 btop 中发现并修复了两个 bug:32 位整数溢出(在具有 4GiB 以上内存的系统上会导致错误的数值)和一个从不返回有用数据的废弃 sysctl 参数。修复方法是从 ARC 和 vfs.bufspace 中正确计算可回收缓存,并从 wired 计数中减去 ARC 部分。
下次看到你的 FreeBSD 系统显示高内存使用率时,先深呼吸,运行 sysctl kstat.zfs.misc.arcstats,检查一下你的系统实际上正在为你缓存了多少内容。很可能你的内存正在以非常高效的方式被使用着。