监控 Linux 系统上的内存使用情况
大家好!我是大聪明-PLUS!
从我记事起,我就一直对 Linux 内存计数器着迷:你看计数器htop——就 CPU 消耗而言,一切似乎都是 +/- 清晰的,但内存的计算方式总是与你最初预期的不同,很长一段时间以来,我对它的工作原理有着相当天真和错误的理解。
随着时间的推移,一些事情变得更加清晰,并且在一定程度上对底层工作原理有了一定的理解。在某个时候,我产生了一个实际需求,需要了解实际系统中内存的去向——而这件事再次证明了,在某些地方,系统的结构相当隐晦,这个问题并不总是容易回答。除了实际需求之外,我家里有一台服务器,里面存储着大量的指标,我一直想把它们以清晰的形式显示出来,这样以后就可以实时观察系统在各种进程发生时的行为。
在本文中,我将尝试解释如何实现此类监控以及如何解读其结果。需要说明的是,我从未从事过内核开发——以下所有信息仅基于个人经验、对内核源代码的粗略阅读以及大量的 Google 搜索。因此,我可能在某些方面存在不准确甚至完全错误的地方,但希望错得不要太多。
Linux 内存管理入门
如果不了解内核计数器究竟测量什么,那么查看内核计数器就毫无意义,因此,让我们先来描述一下它内部工作原理的基本原理(为了简化事情,否则,您需要写一整本书,而不是一篇文章)。
释放内存
或许首先值得一提的是,如果你观察一个运行了一段时间的系统,它通常会有非常少量的可用内存。这很正常,因为 Linux 将可用内存定义为完全空闲且未存储任何内容的内存。但这部分资源非常宝贵,不能闲置——内核总是试图充分利用所有可用内存,将其分配给缓存,以提高系统性能,但在需要时可以快速释放。因此,在大多数系统中,页面缓存通常会占用所有可用内存。
页面缓存
如果您编写一个程序将一些数据写入文件,然后该程序(或甚至另一个程序)读取它,您可能会注意到一个有趣的特性:即使文件非常大(千兆字节),但小于可用内存量,那么写入操作和从文件中读取操作都会非常快地发生 - 比磁盘处理它们的速度快得多。
重点在于,当程序将数据写入磁盘(执行write(2)系统调用)时,该系统调用的功能通常只是将数据写入内存并立即返回。只有此时(异步),内核才会将数据写入磁盘。写入后,数据通常保留在内存中,后续对同一文件的read(2)调用可以立即从内存中读取数据,而无需访问磁盘。
这个内核子系统称为页面缓存,其基本思想(简化)如下:在使用任何块设备时,所有读写操作(除非另有明确请求 - 参见O_DIRECTopen(2) )都通过页面缓存进行。如果内核需要从磁盘读取信息,它会以 4 KiB 的页面大小将其读入页面缓存,然后通过页面缓存访问数据。写入时,信息也首先进入页面缓存,然后才会异步刷新到磁盘(除非通过fsync(2)提前请求)。这导致了一个有趣的特性:在一般没有内存压力的情况下(如下所述),写入总是即时的(因为我们本质上是写入内存,而不是磁盘),但读取可能需要很长时间(如果文件尚未缓存在页面缓存中)。
总的来说,页面缓存是一种用途广泛且功能强大的机制,几乎随处可见。例如:
使用mmap(2),您可以将文件加载到内存中,并像操作普通内存一样对其进行操作。由于预取机制,当您访问特定的内存位置时,操作系统会自动加载数据,甚至在您尝试访问之前就会加载。
您知道程序是如何加载执行的吗?一个简单(且看似合乎逻辑)的答案是:内核分配一块内存,从磁盘读取二进制文件,然后转移控制权执行。但实际上,事情要有趣得多:启动应用程序时,Linux 本质上会通过mmap(2)将二进制文件映射到进程内存中,并将控制权转移到相应的指令。然后,当处理器在这些指令之间跳转时,它会将数据加载到页面缓存中,并从那里返回。因此,即使非常大的二进制文件,如果实际执行的代码只有一小部分,也会消耗少量内存。然而,还有另一个副作用:在内存压力较大的情况下(见下文),系统可能会刷新已加载二进制文件的页面缓存,即使您没有交换文件,您也会遇到与操作系统将数据交换到磁盘时完全相同的延迟。
缓冲区
令人惊讶的是,这个计数器是最令人困惑的。我在谷歌上找到的所有信息都自相矛盾,文档甚至指出“原始磁盘块的相对临时存储不应该太大(20MB 左右)”——尽管我经常看到它显示 GB 级。
但实际上,这很简单:在早期版本的 Linux 中,页面缓存子系统现在执行的工作是由两个独立的子系统执行的。现在情况已经不同了,但/proc/meminfo旧的行为仍然存在,并且计数器Cached会显示Buffers不同缓存类型的统计信息:
Cached负责在通过文件系统访问文件时缓存文件内容。Buffers它还负责缓存其他所有内容:包含文件系统元数据的块、磁盘布局以及直接读取磁盘时的原始块。
换句话说:
Cached由于以下命令而增加:cat /dev/urandom > out,cat big_file > /dev/null;Buffersls -laR / > /dev/null在和的情况下增加dd if=/dev/sda of=/dev/null bs=10M status=progress。
匿名记忆
从技术角度来看,页缓存是一个内存页面,用于缓存块设备的内容,因此与块设备绑定。与页缓存不同的是,页缓存是匿名内存,它没有备份文件,独立存在。
在这种情况下,无需讨论不必要的细节,最正确的说法是匿名内存本质上是进程的所有用户空间内存:堆栈、全局变量和堆。
shmem(共享内存)
共享内存指的是以下内容:
使用mmap(2) +创建的匿名内存块,
MAP_ANONYMOUS | MAP_SHARED用于在多个进程之间共享。tmpfs– 完全驻留在内存中的虚拟文件系统。例如,/run– 就是tmpfs。某些发行版也会挂载tmpfs在/tmp– 中,所以切勿在那里保存大文件!这就是/var/tmp(始终驻留在磁盘上)的用途。POSIX IPC API(shm_overview(7),sem_overview(7) ),实际上是在 Linux 中相同的 API 之上实现的
tmpfs,它安装在 中/dev/shm。
顺便提一下,一个有趣且不太明显的事实:在 Linux 中,所有文件系统都在页面缓存之上运行——因此,你放入其中的所有内容都tmpfs算作页面缓存。这只是一个不太常见的页面缓存——它下面没有文件,只有内存中的“缓存”页面。
交换
swap 是磁盘上的可选分区或文件,当长时间未使用或内核感觉可用内存不足时,内核可以转储匿名和 tmpfs 内存。
当进程访问已换出的内存时,内核不会立即将内存从交换区移至 RAM。它首先会将所需的页面加载到内存中,而不会将其从交换区中移除(这样,如果这些页面没有及时换出,内核可以快速地再次移除它们)。这种页面状态称为“交换区缓存”。
还有各种附加选项,包括zswap,它会在实际交换之前构建内存缓存,以压缩格式存储交换的页面。这是一个非常有趣的功能——我强烈推荐它。
总的来说,关于交换(交换的必要性)的话题颇具争议,值得另写一篇文章。
页表
您可能已经知道,每个进程都在其自己的虚拟内存空间中运行。虚拟内存是在硬件级别( MMU )
实现的抽象:对于每个进程,内核都会创建将虚拟地址映射到物理地址的表,在执行过程中,处理器会使用这些表为当前进程虚拟化内存。
这些表是多级的,以最小化它们的大小,以适应典型情况,即一个进程只使用其虚拟空间的一小部分 - 除了一些特殊情况,例如一组进程共享相同的内存块,页表可以被认为是对正在使用的物理内存的固定税,因此它们的大小总是可预测的并且相对较小。
活跃/不活跃/不可驱逐
所有匿名、页面缓存和交换缓存内存分为:
活跃的——最近访问过的页面;
不活跃 – 长时间未访问的页面;
如果内存不足,内核会先尝试清除非活动页面,然后再处理活动页面。
简单来说,活跃/不活跃划分如下:
每个页面都有
accessed一个-bit(在页表级别)。如果内核因任何原因处理对该页面的访问,它就会设置此标志。
如果进程不通过内核访问页面,则该标志由MMU设置。
内核定期扫描页面,如果发现设置
accessed位,则清除该位并将给定页面从非活动状态提升为活动状态。
需要注意的是,非活动页面不应被视为“N 秒内未访问的页面”——它并非如此。区分活动/非活动页面的目的并非跟踪页面活动,而是为了确定所有可用页面的回收优先级。因此,这种区分实际上相当随意,反映的是页面的相对需求,而非其实际使用情况。
slab、kmalloc、vmalloc
为了完成工作,内核需要为存储当前系统状态的各种结构体分配内存。为此,内核提供了一个slab分配器。其本质如下:最常用的结构体(例如,描述进程的结构体task_struct)拥有自己的内存池,可以访问该内存池并请求为新对象分配内存。当我们将对象返回到内存池时,内存并不会立即释放(因为我们预计新的分配请求可能会在短时间内到达)。因此,slab 分配器通常包含一定量的内存,如果其他地方需要,可以随时快速释放。
此外,slab 中的对象类型本身可以标记为可回收——这意味着它本质上是一个缓存,并且可以根据需要移除此类对象。这类对象最典型的例子可能是 dentry/inode 缓存,与页面缓存不同,它们缓存的是文件元数据和目录内容,而不是数据。有了它们,当你执行类似 [unclear/unclear] 的操作时open("/a/b/c/d/e", ...),系统不必每次都遍历所有目录来查找最终文件(负面搜索结果也会被缓存)。
通常,slab 分配器用于频繁且大量创建的结构。但是,如果您只需要在本地分配一些内存,则可以使用 malloc(3) 分配器kmalloc()(含义类似于malloc(3)),它实际上是 slab 分配器的超集。对于分配大块内存(但不保证物理页面一致性),可以使用伙伴分配器vmalloc(),它与 slab 无关,直接从伙伴分配器分配内存。
可回收内存和内存压力
在运行过程中,内核监视内存的当前状态并:
将未使用的内存移至交换区,以便更有效地利用它(例如,用于页面缓存);
如果有迹象表明内存可能很快就会耗尽,则主动释放内存;
当内存不足时积极释放内存。
为此,在内核中:
触发主动和积极内存回收的可用内存量阈值可配置。主动内存回收会提前为已分配的后台线程回收内存;积极内存回收会为进程本身回收内存,迫使进程花费 CPU 时间来寻找空闲的内存页。
有一种想法是,在内存不足的情况下,可以刷新上面提到的所有缓存(但是,如果是脏页缓存,则必须先将其写入磁盘)。
内存分为活动内存和非活动内存,内核首先尝试驱逐非活动内存页面。
内核堆栈
你可能知道,进程中的每个线程都有自己的堆栈。但有趣的是,它实际上有两个:用户空间和内核空间。我记得很久以前,我曾认为系统调用就像调用某个服务的 API:我们发送一个请求,它被放入队列等待处理,然后被搁置,直到另一端有人有时间处理它。但内核的工作方式并非如此。:) 当你进行系统调用时,会发生上下文切换——本质上,你的线程会继续工作——但是处理系统调用的内核代码现在正在执行,而这段代码需要自己的堆栈才能运行。
user顺便说一下,CPU 消耗被分为和正是出于这个原因system:即,这确实是特定进程/线程(内核术语中的任务)的运行时间,但在不同的上下文中。
监视/proc/meminfo
我先声明一下,我不会试图在本文中设定任何特别宏大的目标,尽可能详细地监控所有可能的情况。我将仅限于提供的信息/proc/meminfo。
幸运的是,在指标收集方面,一切都已为我们完成——Prometheus将所有计数器显示/proc/meminfo为指标node_memory_*。然而,解释结果值需要一些工作:首先,其中一些值在它们所占的内存页面上重叠,并且并不总是清楚需要减去或加总哪些值才能得到细分。其次,它们的文档有时非常模糊,而且并不总是能立即清楚它们究竟测量什么。
下面,每个标题代表一个单独的图表,我们将从中形成我们的仪表板。
内存使用情况
首先,让我们尝试创建一个图表,从鸟瞰的角度查看整体情况,将“实际使用”的内存与各种缓存分开:
Free = MemFreeCaches = Cached - Shmem + Buffers + KReclaimableUsed = MemTotal - Free - Caches
已用内存
这里我们详细说明了所有非缓存的内存:
Anonymous = AnonPagesSlab = SUnreclaimSwap cached = SwapCachedzswap = Zswap– zswap子系统消耗的内存Page tables = PageTables + SecPageTablesKernel stacks = KernelStackvmalloc = VmallocUsed - KernelStackpercpu = Percpushmem = Shmem
缓存
这里我们将更详细地展示哪些缓存占用了多少空间:
Page cache = Cached - ShmemBuffers = BuffersSlab = SReclaimableMisc = KReclaimable - SReclaimable
让我们启动它sync && echo 3 > /proc/sys/vm/drop_caches并查看系统实际上可以释放多少个缓存(剧透:不是全部)。
值得注意的是,也可能出现相反的情况——可回收内存的实际数量比乍一看的要大。
未知
尽管我们希望详细列出所有内存,但不可能做到 100% 准确——因为有些分配是直接由伙伴分配器进行的,并且不旋转任何计数器。因此,分配的内存中的一部分(通常很小)将超出我们的计算范围,在这里记住这一点很重要:
Unknown = MemTotal - MemFree - (AnonPages + SwapCached + Zswap + SUnreclaim + VmallocUsed + PageTables + SecPageTables + Percpu) - (Cached + Buffers + KReclaimable)
顺便说一句,这里有必要解释一下什么是页面MemTotal。内核启动时会检测系统中所有可用的内存,并为每个物理页面创建一个struct page结构体,供伙伴分配器使用。MemTotal页面大小反映了可供分配的总内存,粗略地说,等于页面数量struct page乘以页面大小(不包括伙伴分配器本身占用的内存,以及为硬件保留的内存和包含内核代码的内存)。这就是为什么它总是小于物理内存大小的原因。
可用内存
这里我们只有一个指标——MemAvailable它表示应用程序在不切换到交换的情况下可分配的内存估计量。
它的计算方法如下:占用所有空闲页面,将整个页面缓存和可回收的内核内存添加到其中,并根据水位线、内存压力以及仍然不可能完全回收所有内存的事实进行调整,因为事实上,系统正常运行仍然需要其中的一部分。
内存交换
这里我们将尝试从回收功能的角度展示内核如何看待用户内存:
Active anonymous = Active(anon)Inactive anonymous = Inactive(anon)Active page cache = Active(file)Inactive page cache = Inactive(file)Unevictable = Unevictable
页面缓存写回
在此图中,我们将监控脏页缓存的容量以及系统如何处理将其页面同步到磁盘。
Writeback = Writeback– 当前正在写入磁盘的脏页缓存量Dirty = Dirty- 仍在等待轮到的脏页
交换使用情况
Cashed = SwapCached– 交换缓存在内存中的页面zswapped = Zswapped– 交换到zswap的内存量Swapped out = SwapTotal - SwapFree - SwapCached - Zswapped– 内存卸载(而不是缓存)到磁盘
zswap
这里我有自己的zswap导出器,它允许我额外监控:
我们实际获得的压缩程度;
填充 zswap 池;
页面飞过 zswap 的原因。
数据一致性
这可能并不明显,但它输出的计数器/proc/meminfo(以及 proc/sysfs 中的许多其他文件)可能彼此不一致。具体来说,在收集统计信息时,内核会以原子方式读取/计算各个值,但不使用任何锁来确保一致性。这是内核开发人员刻意做出的妥协,以确保收集用于监控的数据(这种不一致性通常并不重要)不会降低系统速度。因此,如果您将它们用于比绘制图表更重要的事情(例如,从其他数字中减去一些数字偶尔会产生负值),则值得考虑这一事实。
结论
上面,我们监控了整个系统。这当然非常有趣,但它会立即让您想要更进一步——看看哪个服务触发了图表上的特定变化。这要归功于 systemd 和 cgroups!systemd 将整个系统划分为易于理解的进程组(与 PID 不同,进程组具有人类可读的名称,而且最重要的是,数量有限)——如果您准备好迎接数量大幅增加的指标,您可以单独监控每个服务,从而将系统的可观察性提升到一个全新的水平。
在本文中,我不会尝试涵盖所有可能涉及的内容,因此我只是暗示了这种可能性——也许有一天这会成为另一篇文章的主题。我编写它的目的并非为了除我之外的任何人使用它,但也许它可以为某些人提供灵感,或作为如何设置此类监控的一个实际示例。
