memcg lru lock 血泪史

 

凌云时刻 · 技术

导读:超大超长的内存链表和频繁的 lru 操作造成了 zone lock 和 lru lock 2 个著名的内核内存锁竞争,多次在阿里内部造成麻烦,系统很忙,但是业务应用并没得到多少 cpu 时间, 大部分 cpu 都花在 sys 上了。

作者|奎亮

来源|云巅论剑

背景

自电子计算机诞生以来,内存性能一直是行业关心的重点。内存也随着摩尔定律,在大小和速度上一直增长。现在的阿里云服务器动辄单机接近 TB 的内存大小,加上数以百记的 CPU 数量也着实考验操作系统的资源管理能力。

作为世间最流行的操作系统 Linux, 内核使用 LRU(Last Recent Used)链表来管理全部用户使用的内存,用一组链表串联起一个个的内存页,并且使用 lru lock 来保护链表的完整性。

所有应用程序常用操作都会涉及到 LRU 链表操作,例如,新分配一个页,需要挂在 inactive lru 链上, 2 次访问同一个文件地址, 会导致这个页从 inactive 链表升级到 active 链表, 如果内存紧张, 页需要从 active 链表降级到inactive 链表, 内存有压力时,页被回收导致被从 inactive lru 链表移除。不单大量的用户内存使用创建,回收关系到这个链表, 内核在内存大页拆分,页移动,memcg 移动,swapin/swapout, 都要把页移进移出 lru 链表。

可以简单计算一下 x86 服务器上的链表大小:x86 最常用的是 4k 内存页, 4GB 内存会分成 1M 个页, 如果按常用服务器 256GB 页来算, 会有超过 6 千万个页挂在内核 lru 链表中。超大超长的内存链表和频繁的 lru 操作造成了 2 个著名的内核内存锁竞争, zone lock 和 lru lock。这 2 个问题也多次在阿里内部造成麻烦,系统很忙,但是业务应用并没得到多少 cpu 时间, 大部分 cpu 都花在 sys 上了。一个简单 2 次读文件的 benchmark 可以显示这个问题, 它可以造成 70% 的 cpu 时间花费在 LRU lock 上。https://git.kernel.org/pub/scm/Linux/kernel/git/wfg/vm-scalability.git/tree/case-lru-file-readtwice

作为一个知名内核性能瓶颈, 社区也多次尝试以各种方法解决这个问题, 例如,使用更多的 LRU list, 或者 LRU contention 探测(https://lwn.net/Articles/753058/)。

但是都因为各种原因被 Linux 内核拒绝。

寻找解决方案

通过仔细的观察发现, 内核在 2008 年引进内存组 -memcg 以来, 系统单一的 lru lists 已经分成了每个内存组一个 lru list, 由每个内存组单独管理自己的 lru lists。那么按道理 lru lock 的 contention 应该有所减小啊?为什么还是经常在内部服务器观察到 lru lock hot 引起的 sys 高?

原来, 内核在引入 per memcg lru lists 后,并没有使用 per memcg lru lock, 还在使用旧的全局 lru lock 来管理全部 memcg lru lists。这造成了本来可以自治的 memcg A, 却要等待 memcg B 释放使用的 lru lock。然后 A 拿起的 lru lock 又造成 memcg C 的等待。

那么把全局 lru lock 拆分到每一个 memcg 中, 不是可以理所当然的享受到了 memcg 独立的好处了吗?这样每个memcg 都不会需要等待其他 memcg 释放 lru lock。锁竞争限制在每个 memcg 内部了。

要完成 lru lock 拆分,首先要知道 lru lock 保护了多少对象, 通常情况中, page lru lock 需要保护 lru list 完整性, 这个是必须的。与 lru list 相关的还有 page flags 中的 lru bit,这个 lru bit 用作页是否在 lru list 存在的指示器, 可以避免查表才能知道页是否在 list 中。那么 lru lock 保护它也说的通。

但是 lru lock 看起来还有一些奇怪的保护对象,承担了一些不属于它的任务:

1. PageMlock bit 保护 munlock_vma 和 split_huge_page 冲突, 

其实, 上述 2 个函数在调用链中都需要 page lock, 所以冲突可以完全由 page lock 来保证互斥。这里 lru lock 使用属于多余。

2. pagecache xa_lock 和 memcg->move_lock,

xa_lock 并没有需要 lru lock 保护的场景,这个保护也是多余。相反,lru lock 放到 xa_lock 之下, 符合xa_lock/lock_page_memcg 的使用次序。反而可以优化 lru lock 和 memcg move_lock 的关系。

3. lru bit in page_idle_get_page 用在这里是因为担心 page_set_anon_rmap 中, mapping 被提前预取访问,造成异常。用 memory barrier 方式可以避免这个预取, 所以可以在 page idle 中撤掉 lru lock。

+  WRITE_ONCE(page->mapping, (struct address_space *) anon_vma);

经过这样的修改, lru lock 可以在 memory lock 调用层次中降级到最底层。

这时,lru lock 已经非常简化,可以用 per memcg lru lock 来替换全局的 lru lock 了吗?还不行,使用 per memcg lru lock 有一个根本问题,使用者要保证 page 所属的 memcg 不变,但是页在生命周期中是可能转换 memcg 的,比如页在 memcg 之间 migration,导致 lru_lock 随着 memcg 变化, 拿到的 lru lock 是错误的,好消息是 memcg 变化也需要先拿到 lru lock 锁,这样我们可以获得 lru lock 之后检查这个是不是正确的锁:

如果不是, 由反复的 relock 来保证锁的正确性。bingo!完美解决!

由此, 这个 feature 曲折的 upstream 之路开始了。

最终解决

这个 patchset 2019 年发出到社区之后, Google 的 Hugh Dickins 提出, 他和 facebook 的 Konstantin Khlebnikov 同学已经在 2011 发布了非常类似的 patchset,当时没有进主线。不过 Google 内部生产环境中一直在使用。所以现在 Hugh Dickins 发出来他的 upstream 版本。关键路径和我的版本是一样的。

2 个相似 patchset 的 PK, 引起了memcg 维护者 Johannes 的注意, Johannes 发现在 compaction 的时候, relock 并不能保护某些特定场景:

所以他建议,也许增加原子的 lru bit 操作作为 lru_lock 的前提也许可以保护这个场景。Hugh Dickins 则不认为这样会有效,并且坚持他 patchset 已经在 Google 内部用了 9 年了。一直安全稳定。

Johannes 的建议的本质是使用 lru bit 代替 lru lock 做 page isolation 互斥,但是问题的难点在其他地方, 比如在通常的一个 swap in 的场景中:

swap in 的页是先加入 lru,然后 charge to memcg,这样造成页在加入 lru 时,并不知道自己会在那个 memcg 上, 我们也拿不到正确的 per memcg lru_lock,所以上面场景中左侧 CPU 即使提前检查 PageLRU 也找不到正确的 lru lock 来阻止右面 cpu 的操作, 然并卵。

正确的解决方案, 就是上面第 9 步移动到第 7 步前面, 在加入 lru 前 charge to memcg。并且在取得 lru lock 之前检查lru bit是否存在, 这样才可以保证我们可以拿到的是正确的 memcg 的 lru lock。由此提前清除/检查 lru bit 的方法才会有效。这个 memcg charge 的上升, 在和Johannes讨论后, Johannes 在 5.8 完成了代码实现并且和入主线。

在新的代码基础上, 增加了 lru bit 的原子操作 TestClearPageLRU, 把 lru bit 移出了 lru lock 的保护,相反用这个 bit 来做 page isolation 的互斥条件, 用 isolation 来保护页在 memcg 间的移动, 让 lru lock 只完成它的最基本任务, 保护 lru list 完整性。至此方案主体完成。lru lock 的保护对象也由 6 个减小到一个。编码实现就很容易了。

测试结果

方案完成后, 上面提到的 file readtwice 测试中,多个 memcg 的情况下,lru lock 竞争造成的 sys 从 70% 下降了一半,throughput 提高到260%(80 个 cpu 的神龙机器)。

Upstream 过程

经过漫长 4 轮的逐行 review, 目前这个 feature 已经进入了 linus 的 5.11。https://github.com/torvalds/Linux

第一版 patch 发到了社区后, Google 的 skakeel butt 立刻提出, Google 曾经在 2011 发过一样的 patchset 来解决 per memcg lru lock 问题。所以,skakeel 要求我们停止自己开发, 基于 Google 的版本来解决这个问题。然后我才发现真的 2011 年 Google Hugh Dickins 和 Facebook   Konstantin Khlebnikov 就大约同时提出类似的 patchset。但是当时引起的关注比较少,也缺乏 benchmark 来展示补丁的效果, 所以很快被社区遗忘了。不过 Google 内部则一直在维护这组补丁,随他们内核版本升级。

对比 Google 的补丁,我们的实现共同点都是使用 relock 来确保 page->memcg 线性化, 其他实现细节则不尽相同。测试表明我们的 patch 性能更好一点。于是我基于自己的补丁继续修改并和 Johannes 讨论方案改进。这也导致了以后每一版都有 Google 同学的反对:我们的测试发现你的 patchset 有 bug,请参考 Google 可以工作的版本。并在 Linux-next 上发现一个小 bug 时达到顶峰(https://lkml.org/lkml/2020/3/6/627)。Google 同学批评我们抄他们的补丁还抄出一堆 bug。

其实这些补丁和 Hugh Dickins 的补丁毫无关联, 并且在和 Johannes 的持续讨论中,解决方案的核心:page->memcg 的线性化已经进化了几个版本了, 从 relock 到 lock_page_memcg,再到 TestClearPageLRU,和 Google 的补丁是路线上的不同。 

面对这样的无端指责,memcg 维护者 Johannes 看不下去, 出来说了一些公道话:我和 Alex 同学都在尝试和你不同的方案来解决上次提出的 compacion 冲突问题,而且我记得你当时是觉得这个冲突你无能为力的:

之后 Google 同学分享了他们的测试程序,然后在这个话题上沉默了一段时间。 

后来 memcg charge 的问题解决后, 就可以用 lru bit 来保证 page->memcg 互斥了。v17 coding 很快完成后。intel 的 Alexander Duyck,花了 5 个星期, 逐行逐字的 review 整个 patchset,并其基于补丁的改进, 提出了一些后续优化补丁。5 个星期的 review, 足以让一个 feature 错过合适的内核 upstream 窗口。但是也增强了社区的信心。

(重大内核的feature 的merge窗口是这样的:大的 feature 在进入 linus tree 之前, 要在 Linux-next tree 待一段时间, 主要的社区测试如 Intel LKP、Google syzbot 等等也会在着重测试 Linux-next。所以为了保证足够的测试时间, 进入下个版本重要 feature 必须在当前版本的 rc4 之前进入 Linux-next。而当前版本 -rc1 通常 bug 比较多,所以最佳 rebase 版本是 rc2,错过最佳 merge 窗口 rc2-rc4. 意味着需要在等2个月到下一个窗口。并且还要适应新的内核版本的相关修改。)

基于 5.9-rc2 的 v18 版本完成后, Google hugh dickins 同学强势归来,主动申请测试和 review,根据他的意见 v18 做了很多删减和合并,甚至推翻了一些 Alexander Duyck 要求的修改。patch 数量从 32 个压缩到 20 个。Hugh Dickin 逐行 review 了整整 4 个星期。也完美错过了 5.10 和入窗口。之后 v19,Johannes 同学终于回来开始 review。Johannes 比较快,一个星期就完成了 review。现在 v20, 几乎每个 patch 都有了 2 个 reviewed-by: Hugh/Johannes。

然而, 这次不像以前, 以前 patchset 没有人关心, 这次大家的 review 兴趣很大,来了就停不住, SUSE 的 Vlastimil Babka 同学又过来开始 review, 并且提出了一些 coding style 和代码解释要求。不过被强势的 Hugh Dickins 驳回:

Hugh 的影响力还是很大的, Vlastimil 和其他潜在的 reivewer 都闭上了嘴。代码终于进了基于 5.10-rc 的 Linux-next。不过这个驳回也引起一个在 5.11 提交窗口的麻烦, memory 总维护者 Andrew Morthon 突然发现 Vlastimil Babka 表示过一些异议。所以他问我:是不是舆论还不一致, 还有曾经推给你一个 bug, 你解决了吗?

I assume the consensus on this series is "not yet"?

Hugh 再次出来护场:我现在觉得 patchset 足够好了, 足够多人 review 过足够多的版本了, 已经在 Linux-next 安全运行一个多月了,没有任何功能和性能回退,  Vlastimil 也已经没有意见了。至于那个 bug, Alex 有足够的证据表明和这个补丁无关。

最终这个 patchset 享受到了 Andrew 向 Linus 单独推送的待遇。进了 5.11。

后记

在 Linux 上游做事情,有很多成就感,也可以保证自己需要的 feature,一直在线, 免去了内核升级维护之苦。但也会面临荆棘和险阻, 各种内部不关心的场景都要照顾到, 不能影响其他任何人的 feature。所以相比 coding, 大量的社区讨论大概是 coding 的 3~5 倍时间,主要是反复的代码解释和修改。

在整个 upstreaming 的过程中特别值得一提的是一些 Google 的同学态度转变,从一开始的反对,到最后加入我们。从 Google 方面来说, Google 在内存方面有很多优化都依赖于 per memcg lru lock。这个代码加入内核也解除了他们 9 年来的代码维护痛苦。 

在社区沟通上 Google Hugh Dickins 同学帮了很多忙!Intel 的 LKP 测试系统也持续提供测试支持,特别感谢!

END

新年礼物第三弹,精品机械键盘抽奖中!!

邀请伙伴助力中奖几率翻倍

开奖时间:2021 年 1 月 12日

赶紧转发至朋友圈,呼唤好友一起

抽  奖 吧 !


长按扫描二维码关注凌云时刻

每日收获前沿技术与科技洞见

相关推荐