一、内存回收基本原理
内存资源是系统中最宝贵的系统资源,是有限的。当内存资源紧张的时候,系统的应对方法无非就是三种:
-
产生 OOM,内核直接将系统中占用大量内存的进程,将 OOM 优先级最高的进程干掉,释放出这个进程占用的内存供其他更需要的进程分配使用。
-
内存回收,将不经常使用到的内存回收,腾挪出来的内存供更需要的进程分配使用。
-
内存规整,将可迁移的物理页面进行迁移规整,消除内存碎片。从而获得更大的一片连续物理内存空间供进程分配。
我们都知道,内核将物理内存划分成一页一页的单位进行管理(每页 4K 大小)。内存回收的单位也是按页来的。在内核中,物理内存页有两种类型,针对这两种类型的物理内存页,内核会有不同的回收机制。
第一种就是文件页,所谓文件页就是其物理内存页中的数据来自于磁盘中的文件,当我们进行文件读取的时候,内核会根据局部性原理将读取的磁盘数据缓存在 page cache 中,page cache 里存放的就是文件页。当进程再次读取读文件页中的数据时,内核直接会从 page cache 中获取并拷贝给进程,省去了读取磁盘的开销。
对于文件页的回收通常会比较简单,因为文件页中的数据来自于磁盘,所以当回收文件页的时候直接回收就可以了,当进程再次读取文件页时,大不了再从磁盘中重新读取就是了。
但是当进程已经对文件页进行修改过但还没来得及同步回磁盘,此时文件页就是脏页,不能直接进行回收,需要先将脏页回写到磁盘中才能进行回收。
我们可以在进程中通过 fsync() 系统调用将指定文件的所有脏页同步回写到磁盘,同时内核也会根据一定的条件唤醒专门用于回写脏页的 pflush 内核线程。
关于文件页相关的详细内容,感兴趣的同学可以回看下笔者的这篇文章 《从 Linux 内核角度探秘 JDK NIO 文件读写本质》 。
而另外一种物理页类型是匿名页,所谓匿名页就是它背后并没有一个磁盘中的文件作为数据来源,匿名页中的数据都是通过进程运行过程中产生的,比如我们应用程序中动态分配的堆内存。
当内存资源紧张需要对不经常使用的那些匿名页进行回收时,因为匿名页的背后没有一个磁盘中的文件做依托,所以匿名页不能像文件页那样直接回收,无论匿名页是不是脏页,都需要先将匿名页中的数据先保存在磁盘空间中,然后在对匿名页进行回收。
并把释放出来的这部分内存分配给更需要的进程使用,当进程再次访问这块内存时,在重新把之前匿名页中的数据从磁盘空间中读取到内存就可以了,而这块磁盘空间可以是单独的一片磁盘分区(Swap 分区)或者是一个特殊的文件(Swap 文件)。匿名页的回收机制就是我们经常看到的 Swap 机制。
所谓的页面换出就是在 Swap 机制下,当内存资源紧张时,内核就会把不经常使用的这些匿名页中的数据写入到 Swap 分区或者 Swap 文件中。从而释放这些数据所占用的内存空间。
所谓的页面换入就是当进程再次访问那些被换出的数据时,内核会重新将这些数据从 Swap 分区或者 Swap 文件中读取到内存中来。
综上所述,物理内存区域中的内存回收分为文件页回收(通过 pflush 内核线程)和匿名页回收(通过 kswapd 内核进程)。Swap 机制主要针对的是匿名页回收。
那么当内存紧张的时候,内核到底是该回收文件页呢?还是该回收匿名页呢?
事实上 Linux 提供了一个 swappiness 的内核选项,我们可以通过 cat /proc/sys/vm/swappiness
命令查看,swappiness 选项的取值范围为 0 到 100,默认为 60。
swappiness 用于表示 Swap 机制的积极程度,数值越大,Swap 的积极程度越高,内核越倾向于回收匿名页。数值越小,Swap 的积极程度越低。内核就越倾向于回收文件页。
注意: swappiness 只是表示 Swap 积极的程度,当内存非常紧张的时候,即使将 swappiness 设置为 0 ,也还是会发生 Swap 的。
那么到底什么时候内存才算是紧张的?紧张到什么程度才开始 Swap 呢?这一切都需要一个量化的标准,于是就有了本小节的主题 —— 物理内存区域中的水位线。
二、内存水位
2.1、水位的定义
内核会为每个 NUMA 节点中的每个物理内存区域定制三条用于指示内存容量的水位线,分别是:WMARK_MIN(页最小阈值), WMARK_LOW (页低阈值),WMARK_HIGH(页高阈值)。
这三条水位线定义在 /include/linux/mmzone.h
文件中:
enum zone_watermarks {
WMARK_MIN,
WMARK_LOW,
WMARK_HIGH,
NR_WMARK
};
#define min_wmark_pages(z) (z->_watermark[WMARK_MIN] + z->watermark_boost)
#define low_wmark_pages(z) (z->_watermark[WMARK_LOW] + z->watermark_boost)
#define high_wmark_pages(z) (z->_watermark[WMARK_HIGH] + z->watermark_boost)
这三条水位线对应的 watermark 数值存储在每个物理内存区域 struct zone 结构中的 _watermark[NR_WMARK] 数组中。
struct zone {
// 物理内存区域中的水位线
unsigned long _watermark[NR_WMARK];
// 优化内存碎片对内存分配的影响,可以动态改变内存区域的基准水位线。
unsigned long watermark_boost;
} ____cacheline_internodealigned_in_smp;
注意:下面提到的物理内存区域的剩余内存是需要刨去 lowmem_reserve 预留内存大小。
2.2、水位的行为
-
当该物理内存区域的剩余内存容量高于 _watermark[WMARK_HIGH] 时,说明此时该物理内存区域中的内存容量非常充足,内存分配完全没有压力。
-
当剩余内存容量在 _watermark[WMARK_LOW] 与_watermark[WMARK_HIGH] 之间时,说明此时内存有一定的消耗但是还可以接受,能够继续满足进程的内存分配需求。
-
当剩余内容容量在 _watermark[WMARK_MIN] 与 _watermark[WMARK_LOW] 之间时,说明此时内存容量已经有点危险了,内存分配面临一定的压力,但是还可以满足进程的内存分配要求,当给进程分配完内存之后,就会唤醒 kswapd 进程开始内存回收,直到剩余内存高于 _watermark[WMARK_HIGH] 为止。
在这种情况下,进程的内存分配会触发内存回收,但请求进程本身不会被阻塞,由内核的 kswapd 进程异步回收内存。
-
当剩余内容容量低于 _watermark[WMARK_MIN] 时,说明此时的内容容量已经非常危险了,如果进程在这时请求内存分配,内核就会进行直接内存回收,这时请求进程会同步阻塞等待,直到内存回收完毕。
位于 _watermark[WMARK_MIN] 以下的内存容量是预留给内核在紧急情况下使用的,这部分内存就是 nr_reserved_highatomic。
我们可以通过 cat /proc/zoneinfo
命令来查看不同 NUMA 节点中不同内存区域中的水位线:
其中大部分字段的含义笔者已经在前面的章节中为大家介绍过了,下面我们只介绍和本小节内容相关的字段含义:
-
free 就是该物理内存区域内剩余的内存页数,它的值和后面的 nr_free_pages 相同。
-
min、low、high 就是上面提到的三条内存水位线:_watermark[WMARK_MIN],_watermark[WMARK_LOW] ,_watermark[WMARK_HIGH]。
-
nr_zone_active_anon 和 nr_zone_inactive_anon 分别是该内存区域内活跃和非活跃的匿名页数量。
-
nr_zone_active_file 和 nr_zone_inactive_file 分别是该内存区域内活跃和非活跃的文件页数量。
2.3 水位线的计算
在上小节中我们介绍了内核通过对物理内存区域设置内存水位线来决定内存回收的时机,那么这三条内存水位线的值具体是多少,内核中是根据什么计算出来的呢?
事实上 WMARK_MIN,WMARK_LOW ,WMARK_HIGH 这三个水位线的数值是通过内核参数 /proc/sys/vm/min_free_kbytes
为基准分别计算出来的,用户也可以通过 sysctl
来动态设置这个内核参数(单位为 KB)。
通常情况下 WMARK_LOW 的值是 WMARK_MIN 的 1.25 倍,WMARK_HIGH 的值是 WMARK_LOW 的 1.5 倍。而 WMARK_MIN 的数值就是由这个内核全局变量min_free_kbytes 来决定的。
下面我们就来看下内核中关于 min_free_kbytes 的计算方式:
min_free_kbytes 的计算逻辑
以下计算逻辑是针对 64 位系统中内存区域水位线的计算,在 64 位系统中没有高端内存 ZONE_HIGHMEM 区域。
min_free_kbytes 的计算逻辑定义在内核文件 /mm/page_alloc.c
的 init_per_zone_wmark_min
方法中(page_alloc.c - mm/page_alloc.c - Linux source code v5.4.285 - Bootlin Elixir Cross Referencer)用于计算最小水位线 WMARK_MIN 的数值也就是这里的 min_free_kbytes (单位为 KB)。 水位线的单位是物理内存页的数量。
///mm/page_alloc.c中定义全局变量
//int min_free_kbytes = 1024;
//int user_min_free_kbytes = -1;
int __meminit init_per_zone_wmark_min(void)
{
unsigned long lowmem_kbytes;
int new_min_free_kbytes;
// 将低位内存区域内存容量总的页数转换为 KB
lowmem_kbytes = nr_free_buffer_pages() * (PAGE_SIZE >> 10);
// min_free_kbytes 计算逻辑:对 lowmem_kbytes * 16 进行开平方
new_min_free_kbytes = int_sqrt(lowmem_kbytes * 16);
//user_min_free_kbytes就是/proc/sys/vm/min_free_kbytes
if (new_min_free_kbytes > user_min_free_kbytes) {
min_free_kbytes = new_min_free_kbytes;
// min_free_kbytes 的范围为 128 到 65536 KB 之间
if (min_free_kbytes < 128)
min_free_kbytes = 128;
if (min_free_kbytes > 65536)
min_free_kbytes = 65536;
}
// 计算内存区域内的三条水位线
setup_per_zone_wmarks();
// 计算内存区域的预留内存大小,防止被高位内存区域过度挤压占用
setup_per_zone_lowmem_reserve();
.............省略................
return 0;
}
首先我们需要先计算出当前 NUMA 节点中所有低位内存区域(除高端内存之外)中内存总容量之和。也即是说 lowmem_kbytes 的值为: ZONE_DMA 区域中 managed_pages + ZONE_DMA32 区域中 managed_pages + ZONE_NORMAL 区域中 managed_pages 。
lowmem_kbytes 的计算逻辑在 nr_free_zone_pages
方法中:
/**
* nr_free_zone_pages - count number of pages beyond high watermark
* @offset: The zone index of the highest zone
*
* nr_free_zone_pages() counts the number of counts pages which are beyond the
* high watermark within all zones at or below a given zone index. For each
* zone, the number of pages is calculated as:
* managed_pages - high_pages
*/
static unsigned long nr_free_zone_pages(int offset)
{
struct zoneref *z;
struct zone *zone;
unsigned long sum = 0;
// 获取当前 NUMA 节点中的所有物理内存区域 zone
struct zonelist *zonelist = node_zonelist(numa_node_id(), GFP_KERNEL);
// 计算所有物理内存区域内 managed_pages - high_pages 的总和
for_each_zone_zonelist(zone, z, zonelist, offset) {
unsigned long size = zone->managed_pages;
unsigned long high = high_wmark_pages(zone);
if (size > high)
sum += size - high;
}
// lowmem_kbytes 的值
return sum;
}
nr_free_zone_pages 方法上面的注释大家可能看的有点蒙,这里需要为大家解释一下,nr_free_zone_pages 方法的计算逻辑本意是给定一个 zone index (方法参数 offset),计算范围为:这个给定 zone 下面的所有低位内存区域。
nr_free_zone_pages 方法会计算这些低位内存区域内在 high watermark 水位线之上的内存容量( managed_pages - high_pages )之和。作为该方法的返回值。
但此时我们正准备计算这些水位线,水位线还没有值,所以此时这个方法的语义就是计算低位内存区域内被伙伴系统所管理的内存容量( managed_pages )之和。也就是我们想要的 lowmem_kbytes。
接下来在 init_per_zone_wmark_min 方法中会对 lowmem_kbytes * 16 进行开平方得到 new_min_free_kbytes。
最后在 setup_per_zone_lowmem_reserve() 方法中计算内存区域的预留内存大小,防止被高位内存区域过度挤压占用。
setup_per_zone_wmarks 计算水位线
这里我们依然不会考虑高端内存区域 ZONE_HIGHMEM。
物理内存区域内的三条水位线:WMARK_MIN,WMARK_LOW,WMARK_HIGH 的最终计算逻辑是在 __setup_per_zone_wmarks
方法中完成的:
static void __setup_per_zone_wmarks(void)
{
// 将 min_free_kbytes 转换为页
unsigned long pages_min = min_free_kbytes >> (PAGE_SHIFT - 10);
unsigned long lowmem_pages = 0;
struct zone *zone;
unsigned long flags;
// 所有低位内存区域 managed_pages 之和
for_each_zone(zone) {
if (!is_highmem(zone))
lowmem_pages += zone->managed_pages;
}
// 循环计算各个内存区域中的水位线
for_each_zone(zone) {
u64 tmp;
tmp = (u64)pages_min * zone->managed_pages;
// 计算 WMARK_MIN 水位线的核心方法
do_div(tmp, lowmem_pages);
if (is_highmem(zone)) {
...........省略高端内存区域............
} else {
// WMARK_MIN水位线
zone->watermark[WMARK_MIN] = tmp;
}
/*
* Set the kswapd watermarks distance according to the
* scale factor in proportion to available memory, but
* ensure a minimum size on small systems.
这段代码主要是通过内核参数 watermark_scale_factor 来调节水位线:
WMARK_MIN,WMARK_LOW,WMARK_HIGH 之间的间距
*/
tmp = max_t(u64, tmp >> 2,
mult_frac(zone->managed_pages,
watermark_scale_factor, 10000));
zone->watermark[WMARK_LOW] = min_wmark_pages(zone) + tmp;
zone->watermark[WMARK_HIGH] = min_wmark_pages(zone) + tmp * 2;
}
}
在 for_each_zone 循环内依次遍历 NUMA 节点中的所有内存区域 zone,计算每个内存区域 zone 里的内存水位线。其中计算 WMARK_MIN 水位线的核心逻辑封装在 do_div 方法中,在 do_div 方法中会先计算每个 zone 内存容量之间的比例,然后根据这个比例去从 min_free_kbytes 中划分出对应 zone 的 WMARK_MIN 水位线来。
比如:当前 NUMA 节点中有两个 zone :ZONE_DMA 和 ZONE_NORMAL,内存容量大小分别是:100 M 和 800 M。那么 ZONE_DMA 与 ZONE_NORMAL 之间的比例就是 1 :8。
根据这个比例,ZONE_DMA 区域里的 WMARK_MIN 水位线就是:min_free_kbytes * 1 / 8
。ZONE_NORMAL 区域里的 WMARK_MIN 水位线就是:min_free_kbytes * 7 / 8
。
计算出了 WMARK_MIN 的值,那么接下来 WMARK_LOW, WMARK_HIGH 的值也就好办了,它们都是基于 WMARK_MIN 计算出来的。
watermark_scale_factor 调整水位线的间距
为了避免内核的直接内存回收 direct reclaim 阻塞进程影响系统的性能,所以我们需要尽量保持内存区域中的剩余内存容量尽量在 WMARK_MIN 水位线之上,但是有一些极端情况,比如突然遇到网络流量增大,需要短时间内申请大量的内存来存放网络请求数据,此时 kswapd 回收内存的速度可能赶不上内存分配的速度,从而造成直接内存回收 direct reclaim,影响系统性能。
在内存分配过程中,剩余内存容量处于 WMARK_MIN 与 WMARK_LOW 水位线之间会唤醒 kswapd 进程来回收内存,直到内存容量恢复到 WMARK_HIGH 水位线之上。
剩余内存容量低于 WMARK_MIN 水位线时就会触发直接内存回收 direct reclaim。
而剩余内存容量高于 WMARK_LOW 水位线又不会唤醒 kswapd 进程,因此 kswapd 进程活动的关键范围在 WMARK_MIN 与 WMARK_LOW 之间,而为了应对这种突发的网络流量暴增,我们需要保证 kswapd 进程活动的范围大一些,这样内核就能够时刻进行内存回收使得剩余内存容量较长时间的保持在 WMARK_HIGH 水位线之上。
这样一来就要求 WMARK_MIN 与 WMARK_LOW 水位线之间的间距不能太小,因为 WMARK_LOW 水位线之上就不会唤醒 kswapd 进程了。
因此内核引入了 /proc/sys/vm/watermark_scale_factor
参数来调节水位线之间的间距。该内核参数默认值为 10,最大值为 3000。
那么如何使用 watermark_scale_factor 参数调整水位线之间的间距呢?
水位线间距计算公式:(watermark_scale_factor / 10000) * managed_pages 。
zone->watermark[WMARK_MIN] = tmp;
// 水位线间距的计算逻辑
tmp = max_t(u64, tmp >> 2,
mult_frac(zone->managed_pages,
watermark_scale_factor, 10000));
zone->watermark[WMARK_LOW] = min_wmark_pages(zone) + tmp;
zone->watermark[WMARK_HIGH] = min_wmark_pages(zone) + tmp * 2;
在内核中水位线间距计算逻辑是:(WMARK_MIN / 4) 与 (zone_managed_pages * watermark_scale_factor / 10000) 之间较大的那个值。
用户可以通过 sysctl
来动态调整 watermark_scale_factor 参数,内核会动态重新计算水位线之间的间距,使得 WMARK_MIN 与 WMARK_LOW 之间留有足够的缓冲余地,使得 kswapd 能够有时间回收足够的内存,从而解决直接内存回收导致的性能抖动问题。
ref: