我们如何以及为什么优化了用于清理Linux内核中SLAB缓存的算法

容器的日益普及及其与控制组的结合使用显示出严重的可伸缩性问题,这导致大型计算机的性能显着下降。 问题在于,SLAB高速缓存的旁路时间二次取决于容器的数量,并且在短时间内大量消耗内存的活动会导致系统进入繁忙的循环,从而消耗100%的处理器时间。 今天,我想告诉您,我们如何通过更改使用memcg控制组的计费算法来使用SLAB缓存对象并优化shrink_slab()函数来解决此问题。

记忆清理

为什么会出现内核中的进程优化问题? 一切始于这样一个事实:我们的一位客户积极使用容器和内存控制组(memcg),引起了人们对不时出现的处理器资源消耗高峰的关注。 正常的系统负载约为50%,在高峰时间占用了100%的处理器时间,几乎所有的负载都被内核消耗了(系统时间)。
该节点本身是多用户的,并在其上启动了大约200个OpenVZ容器。 分析表明,大量用户创建了嵌套的Docker容器和内存控制组的多级层次结构。 每个用户级别的顶级容器包含大约20个安装点和systemd创建的20个控制内存组(memcg)。 此外,上述Docker还创建了挂载点和控制组。 简而言之,该节点的负载很重,并且其负载要比我们所有其他客户的平均负载强得多。 我们有兴趣找出出现这些峰值的原因,因为在不那么忙碌的机器上也可能出现相同的问题,而这在机器上几乎不明显(例如,在5%的系统时间提供峰值,这会降低性能)。

通过操纵性能,我设法赶上了顶峰并消除了足迹。 事实证明,处理器的大部分时间都用于清除SLAB缓存,即超级块缓存:

- 100,00% 0,00% kswapd0 [kernel.vmlinux] [k] kthread - 99,31% balance_pgdat - 82,11% shrink_zone - 61,69% shrink_slab - 58,29% super_cache_count + 54,56% list_lru_count_one 


在这里有必要对此问题进行解释和详细介绍。 每个人都知道内核会在最终释放内存之前先缓存一段时间。 内核充分利用了这一原理。 例如,页面缓存包含与文件相关的数据页面,这在读取时极大地加快了对其的重复访问(因为您无需再次访问磁盘)。 在我们的案例中,问题出现在两个LRU列表(s_dentry_lru和s_inode_lru)中包含的超级块元数据缓存中。

LRU(最近最少使用)

struct lru_list指向一个链接列表数组,每个活动的memcg对应于此数组中的一个元素(list_lru_one)。 当某个内核不再使用某个SLAB对象时,内核会将其添加到数组的链表之一(取决于该对象所属的memcg,或大致来说,取决于创建该对象时使用的进程属于哪个memcg)。 数组本身描述如下(lru_list ::节点:: memcg_lrus):

 struct list_lru_memcg { struct rcu_head rcu; /* array of per cgroup lists, indexed by memcg_cache_id */ struct list_lru_one *lru[0]; /*    */ }; struct list_lru_one { struct list_head list; /*    */ /* may become negative during memcg reparenting */ long nr_items; /*     */ }; 

lru [0]表示与ID为0的memcg相关的对象列表;
lru [1]表示与ID为1的memcg相关的对象列表;
...
lru [n]表示与ID为n的memcg相关的对象列表;

LRU列表s_dentry_lru和s_inode_lru出现在我们的问题中,并且顾名思义,它们包含未使用的dentry和inode文件系统对象。
将来,如果系统中没有足够的内存或特定的memcg,则最终将释放某些列表项,并使用称为收缩器的特殊机制来执行此操作。

收缩机

当内核需要分配内存页面,但NUMA节点或系统上没有可用内存时,便开始了清理它的机制。 他正试图抛出或丢弃一定数量的磁盘:1)页面缓存中文件内容的页面; 2)与交换中的匿名内存相关的页面,以及3)缓存的SLAB对象(我们遇到的问题与它们有关)。

丢弃部分缓存的SLAB对象不会直接影响页面的释放:通常,它们的大小比页面大小小得多,并且一个页面包含数百个对象。 释放部分对象后,SLAB页面中将出现可用的内存间隙,可用于创建其他SLAB对象。 该算法被内核有意接受:它简单且非常有效。 有兴趣的读者可以在do_shrink_slab()函数中查看用于选择要清洗的对象的一部分的公式。

此函数执行实际的部分对象清理,并以结构收缩器中传递给它的描述为指导:

 static unsigned long do_shrink_slab(struct shrink_control *shrinkctl, struct shrinker *shrinker, int priority) { … /*   */ freeable = shrinker->count_objects(shrinker, shrinkctl); if (freeable == 0) return 0; total_scan = _(freeable); while (total_scan >= batch_size) { /*   */ ret = shrinker->scan_objects(shrinker, shrinkctl); total_scan -= shrinkctl->nr_scanned; } ... } 

关于收缩器超级块,这些功能的实现如下。 每个超级块都维护自己的s_dentry_lru和s_inode_lru与之相关的未使用对象的列表:

 struct super_block { ... struct shrinker s_shrink; /* per-sb shrinker handle */ ... struct list_lru s_dentry_lru; struct list_lru s_inode_lru; … }; 


.count_objects方法返回对象数:

 static unsigned long super_cache_count(struct shrinker *shrink, struct shrink_control *sc) { total_objects += list_lru_shrink_count(&sb->s_dentry_lru, sc); total_objects += list_lru_shrink_count(&sb->s_inode_lru, sc); /*     ) */ total_objects = vfs_pressure_ratio(total_objects); return total_objects; } 


.scan_objects方法实际上释放对象:

 static unsigned long super_cache_scan(struct shrinker *shrink, struct shrink_control *sc) { /*     s_dentry_lru */ prune_dcache_sb(sb, sc); /*     s_inode_lru */ prune_icache_sb(sb, sc); } 

要释放的对象数在sc参数中传递。 另外,在那里显示了memcg,其对象应该从LRU中抛出:

 struct shrink_control { int nid; /* ID NUMA  */ unsigned long nr_to_scan; /*   */ struct mem_cgroup *memcg; /* memcg */ }; 

因此,prune_dcache_sb()从数组struct list_lru_memcg :: lru []中选择一个链表,并对其进行处理。 Prune_icache_sb()的作用相同。

旧的收缩器旁路算法

使用标准方法时,内存不足时会从SLAB中“弹出”对象
sc-> target_mem_cgroup的发生情况如下:

 shrink_node() { … struct mem_cgroup *root = sc->target_mem_cgroup; /*      sc->target_mem_cgroup  */ memcg = mem_cgroup_iter(root, NULL, &reclaim); do { … shrink_slab(memcg, ...); … } while ((memcg = mem_cgroup_iter(root, memcg, &reclaim))); ... } 

我们遍历所有子memcg,并为每个子例程调用rinke_slab()。 接下来,在rinkle_slab()函数中,我们遍历所有收缩器,并为每个收缩器调用do_shrink_slab():

 static unsigned long shrink_slab(gfp_t gfp_mask, int nid, struct mem_cgroup *memcg, int priority) { list_for_each_entry(shrinker, &shrinker_list, list) { struct shrink_control sc = { .nid = nid, .memcg = memcg, }; ret = do_shrink_slab(&sc, shrinker, ...); } } 

回想一下,对于每个超级块,其自己的收缩器将添加到此列表中。 让我们计算一下200个容器(每个容器20个memcg和20个挂载点)的情况下do_shrink_slab()会被调用多少次。 总共,我们有200 * 20个安装点和200 * 20个控制组。 如果最顶层的memcg中没有足够的内存,我们将被迫绕过其所有的子memcg(即,一般情况下的所有内容),并且对于它们中的每个memcg,都要从rinker_list中调用每个收缩器。 因此,内核将对do_shrink_slab()函数进行200 * 20 * 200 * 20 = 16000000的调用。

而且,对该函数的绝大多数调用将是无用的:容器通常相互隔离,并且CT1将使用在CT2中创建的super_block2的可能性通常较低。 或者,是一样的,如果memcg1是CT1中的一个控制组,那么super_block2-> s_dentry_lru-> node-> memcg_lrus-> lru [memcg1_id]数组的对应元素将是一个空列表,为它调用do_shrink_slab()没有意义。

可以使用一个简单的bash脚本对这个问题进行建模(此处使用了来自补丁集的数据,该数据随后传递给内核):
 $echo 1 > /sys/fs/cgroup/memory/memory.use_hierarchy $mkdir /sys/fs/cgroup/memory/ct $echo 4000M > /sys/fs/cgroup/memory/ct/memory.kmem.limit_in_bytes $for i in `seq 0 4000`; do mkdir /sys/fs/cgroup/memory/ct/$i; echo $$ > /sys/fs/cgroup/memory/ct/$i/cgroup.procs; mkdir -ps/$i; mount -t tmpfs $is/$i; touch s/$i/file; done 

让我们看看如果连续调用缓存重置过程5次会发生什么:
 $time echo 3 > /proc/sys/vm/drop_caches 

第一次迭代持续14秒,因为缓存的对象确实在内存中: 0.00用户13.78系统0:13.78使用了 99%的CPU。
尽管没有更多对象,但是第二次迭代需要5秒: 0.00user 5.59system 0:05.60经过 99%的CPU。
第三次迭代耗时5秒: 0.00user 5.48系统0:05.48经过 99%CPU
第四次迭代耗时8秒: 0.00用户8.35系统0:08.35已使用 99%CPU
第五次迭代耗时8秒: 0.00用户8.34系统0:08.35已使用 99%CPU

显而易见,香草核所使用的收缩旁路算法不是最佳的,我们需要对其进行更改以更好地实现可伸缩性。

新的收缩绕过算法

通过新算法,我想要实现以下目标:

  1. 让他摆脱旧的缺点
  2. 不要添加新的锁。 仅在有意义时(即s_dentry_lru数组或s_inode_lru数组中的对应链表不为空)调用do_shrink_slab(),但不要直接访问链表存储器。

显然,这只能由异构收缩器之上的新数据结构来提供(不仅有超级块的收缩器,还有本文未介绍的其他数据对象的收缩器。读者可以通过搜索关键字prealloc_shrinker()来熟悉它们。在内核代码中)。 新的数据结构应允许对两种状态进行编码:“调用do_shrink_slab()有意义”和“调用do_shrink_slab()毫无意义”。

IDA类型数据结构被拒绝,因为 他们在自己内部使用锁。 位字段的数据结构完全适合此角色:它允许原子修饰单个位,并且与内存屏障结合使用,您可以构建有效的算法而无需使用锁。

每个收缩器都有自己的唯一ID(shrinker :: id),每个memcg都有一个位图,该位图能够包含当前注册的最大ID。 当第一个元素添加到s_dentry_lru->节点-> memcg_lrus-> lru [memcg_id]列表中时,相应的memcg位图将设置为1位,其数字为rinker-> id。 与s_inode_id相同。

现在,可以优化shrink_slab()中的循环以仅绕过必要的收缩器:

 unsigned long shrink_slab() { … for_each_set_bit(i, map, shrinker_nr_max) { … shrinker = idr_find(&shrinker_idr, i); … do_shrink_slab(&sc, shrinker, priority); … } } 

(当收缩器进入状态时,也将执行位清理。“调用do_shrink_slab()没有任何意义。有关更多详细信息,请参见Github 提交

如果重复缓存重置测试,则使用新算法将显示出明显更好的结果:
 $time echo 3 > /proc/sys/vm/drop_caches 

第一次迭代: 0.00user 1.10系统0:01.10已使用 99%CPU
第二次迭代: 0.00user 0.00system 0:00.01经过 64%CPU
第三次迭代: 0.00user 0.01system 0:00.01经过 82%CPU
第四次迭代: 0.00user 0.00system 0:00.01经过 64%CPU
第五次迭代: 0.00user 0.01system 0:00.01经过 82%CPU
第二次到第五次迭代的持续时间为0.01秒, 比以前快548倍。

由于在计算机上内存不足时都会发生类似的重置缓存的操作,因此这种优化可以显着改善具有大量容器和内存控制组的计算机的操作。 香草核已接受一组补丁 (17个),您可以从4.19版开始在此处找到它。

在审查补丁的过程中,一位Google员工出现了,事实证明他们也有同样的问题。 因此,在不同类型的负载上进一步测试了补丁。
结果,补丁集从第9次迭代开始采用; 它进入香草核心大约需要4个月的时间。 也是今天,补丁集包含在我们自己的Virtuozzo 7内核中,从版本vz7.71.9开始

Source: https://habr.com/ru/post/zh-CN435694/


All Articles