【翻译】 不允许失败时的内存管理

本文由 LWN 订阅者提供

LWN.net 的订阅者成就了这篇文章以及与之相关的一切。 如果您喜欢我们的内容,请购买订阅,让下一组文章成为可能。

作者: Jonathan Corbet
2015年3月4日
去年 12 月,关于低内存情况下系统停滞的讨论揭示了内核中的 小内存分配从未失败过。 从那时起,关于如何以最佳方式处理低内存情况的讨论一直在继续,尤其关注内核无法承受内存分配失败的情况。 这次讨论暴露了内核中内存分配工作方式的一些重大分歧。

一些入门概念

内核的内存管理子系统负责确保内存在内核或用户空间进程需要时可用。 当大量内存空闲时,这项工作很容易完成,但一旦内存填满,这项工作就会变得越来越难--内存填满是不可避免的。 当内存变得紧张,而有人又要求获得更多内存时,内核有几个选择:(1)释放一些其他地方正在使用的内存,或(2)拒绝(失败)分配请求。

释放(或 "回收")内存的过程可能涉及将内存的当前内容写入持久存储。 这反过来又需要调用文件系统或块 I/O 代码。 但是,如果这些子系统中的任何一个实际上是分配请求的来源,那么回调它们可能会导致死锁和其他不幸的情况。 因此(除其他原因外),分配请求带有一组标志,用于描述在处理请求时可以执行的操作。 本文关注的两个标志是GFP_NOFS(不允许调用回文件系统)和GFP_NIOIO(不能启动任何类型的 I/O)。 前者会阻止将脏页面写回磁盘文件的尝试;后者会阻止将页面写入交换等活动。

显然,内存管理子系统受到的限制越多,就越有可能无法满足分配请求。 长期以来,内核开发人员一直被告知(几乎)任何分配请求都有可能失败;因此,内核中充满了错误处理路径,以应对这种可能发生的情况。 但最近发现,内存管理代码实际上并不允许较小的请求失败;相反,它会无限循环,试图释放一些内存。 尽管相关代码已经做好了处理分配失败的准备,但这种行为偶尔会导致系统锁定。 这种 "太小而不能失败 "的行为是有争议的,但目前还很难改变。

不过,内核中也有一些地方根本没有准备好处理分配失败,通常是因为分配发生在一系列复杂操作的深处,很难解开。__GFP_NOFAIL标志的存在是为了明确说明失败不是给定请求的一个选项,不过我们并不鼓励使用它。

下面的讨论归根结底集中在两个相关的问题上:(1) 内核是否真的应该把小规模的分配都当作设置了__GFP_NOFAIL的分配来处理;(2) 是否应该支持防故障分配,如果支持,如何才能使这种支持更加稳健?

不再小到无法失败

当 Tetsuo Handa注意到3.19 内核中的内存分配行为发生了变化,尤其是带有GFP_NOIOGFP_NOFS标志的小规模分配会在严重的内存压力下失败时,讨论(重新)开始了。 在以前的内核中,如果没有可用内存,此类分配会无限循环。 除其他原因外,这一变化还可能导致文件系统操作在内存紧张的系统中失败,而在以前,这些操作(最终)是可以成功的。

约翰内斯-韦纳(Johannes Weiner)为避免内存分配死锁而打的补丁,就是行为改变的结果。 该补丁的目的是避免在分配尝试中永远循环,如果在为分配释放一些内存方面似乎没有进展的话,但意外的是,它也完全避免了在GFP_NOIOGFP_NOFS情况下的循环。 因此,这些分配现在可能会失败;这是对以前内核工作方式的重大改变。

Johannes 最初希望保留这种新行为,认为它"更合理"。 但文件系统开发人员强烈反对。 文件系统代码中似乎有很多地方都依赖于可靠的分配成功,而其中很多地方都没有标记 __GFP_NOFAIL。 Ted Ts'o威胁说,如果不撤销这一修改,他将在 ext4 文件系统的分配调用中添加大量的__GFP_NOFAIL标志。 因此,内存管理开发人员不得不选择他们最不喜欢的选项。

最终,文件系统开发人员胜出;Johannes 在 4.0-rc2 中合并了一项变更,恢复了这些分配类型的循环行为。 这一修改很可能也会出现在 3.19 稳定版系列中。 原始补丁很好地证明了在开发周期后期拒绝 "清理 "补丁的做法。 它被合并到 3.19-rc7 预修补程序中,这意味着在最终的 3.19 版本发布之前,几乎没有时间让问题被注意到。

不过,讨论并不局限于一个迟到的内存管理补丁所带来的意外影响。 如何避免低内存情况下的死锁,以及如何确保重要任务能在这些情况下继续执行,这些更大的问题仍未得到解决。

OOM 杀手

内存不足(OOM)杀手牵涉到许多死锁情况。 在去年 12 月报告的原始问题中,OOM 杀手会选择一个被锁阻塞的受害者,但该锁被等待内存分配(永远)的进程所持有。 因此,受害者无法退出,也就无法释放内存。由于 OOM 杀手每次只攻击一个进程,因此一切都会在此时停止。

约翰内斯建议改变OOM 杀手的工作方式:如果目标进程在五秒钟后无法退出,OOM 杀手就会放弃,转而攻击另一个受害者。 不过,这个想法并不大受欢迎。 David Rientjes指出,无法保证下一个受害者比前一个受害者更合适。 戴夫-钱纳(Dave Chinner)更广泛地声称,调整 OOM 杀手的努力方向是错误的:

我真的不关心 OOM 杀手的角落案例--这完全是错误的开发方向,你不可能说服我。OOM 杀手是用来证明内存分配子系统合理的一根拐杖,它无法为有需要的调用者提供向前推进的保证机制。

最终的结果是,OOM 杀手超时可能不会很快进入内存管理子系统。

__GFP_NOFAIL 和循环

从内存管理开发人员的角度来看,如果任何分配请求都能在必要资源不可用时失败,那么很多事情都会变得简单。 这意味着要摆脱 "小分配永不失败 "的隐式规则,但除此之外,还需要摆脱显式的__GFP_NOFAIL调用站点。 Michal Hocko 可能是这方面最直言不讳的人,他 __GFP_NOFAIL"已经过时了,不应该再用了"。 他还建议重新实现现有的__GFP_NOFAIL调用位点,使其能够从分配失败中恢复。

Dave 对这一观点表示反对,他说防止分配失败是 XFS 文件系统的硬性要求。 他说,如果要重新设计 XFS,使其能够在分配失败时回滚脏事务,将大大增加其复杂性;该项目需要几年时间才能达到投入生产使用的程度。 他总结说:"我不会为了让虚拟机摆脱 GFP_NOFAIL 用户而花几年时间重写 XFS。 奇怪的是,也没有其他开发人员自愿承担这项工作。

当代文件系统是复杂的巨兽,必须满足各种各样的需求。 它们包含了复杂的事务机制,可以帮助它们在各种可能的情况下保持文件系统的完整性。 要实现这样一种机制,使其能够在资源已提交、锁已锁定等情况下,从事务中间的内存分配失败中恢复过来,并不是一件简单的事情。 Linux 上的文件系统开发人员并没有承担这项任务,因为最终他们认为没有必要这样做。 事实证明,在几乎所有情况下,不允许失败的分配已经足够了。

一旦人们承认需要某种防故障分配机制,下一个问题就是:应该如何实现? __GFP_NOFAIL标志是一种解决方案,但事实证明,内核中的很多代码并没有使用它。 相反,内核中有许多地方在不使用__GFP_NOFAILkmalloc()调用上实现了自己的重试循环。 这是内存管理开发人员所不喜欢的;这些开发人员宁愿不使用 __GFP_NOFAIL,但他们仍然更喜欢使用它来替代在内存管理子系统之外实现的重试循环。 例如,考虑一下Johannes 的这条信息,它说 XFS 开发人员应该用一个__GFP_NOFAIL调用来替代重试循环。

这种循环的存在有几个原因。 其中一个原因是,__GFP_NOFAIL 已于 2009 年被明确弃用;(安德鲁-莫顿(Andrew Morton)提供的)补丁中写道:

__GFP_NOFAIL是一个糟糕的虚构。 分配_可以_失败,调用者应该检测并适当处理这种情况(而且也不是蹩脚地将无限循环上移到调用者级别)。

这一修改生效后,包含__GFP_NOFAIL的代码就更难通过审核了。 无论是否蹩脚,手工编写的无限重试循环都比容易被抓取的__GFP_NOFAIL使用更容易潜入内核。 因此,开发人员就这么做了。

内存管理开发人员不喜欢 "必须成功 "的分配,因为它们会使代码复杂化,而且偶尔会出现死锁。 如果必须进行这样的分配,他们更愿意看到在内存管理代码中进行循环,如果发现没有进展,可以调整行为并采取适当的措施(例如启动 OOM 杀手)。 不过在现实世界中,根据TedDave 的说法,循环实际上运行得很好。 戴夫说,XFS 代码有一个 "金丝雀",当循环时间过长时会发出警告:

因此,尽管你们声称自己的方法*好*,但我们的代码已经在崎岖不平的生产环境中经过了尝试、测试和验证。这比你所说的代码已经坏了,需要修复,更能证明代码不应该改变。

也许有人会认为,XFS 开发人员目前对用__GFP_NOFAIL调用来替换他们自己的循环不感兴趣。 但实际上,他们在内存管理代码之外保留一个循环还有另一个原因:他们希望保留对文件系统如何应对低内存条件的控制。 在他们看来,这是内存管理代码缺乏信息来处理的政策决定。 目前正在计划将部分政策公开到用户空间,允许管理员配置文件系统的低内存响应。

保留意见

尽管如此,人们对这一想法并没有真正的分歧:在失败的内存分配中循环是不可取的,最好尽可能避免。 因此,当开发人员讨论到完全避免分配失败的话题时,可能是讨论中最有用的部分。 有几种方法可以实现这一目标。

其中之一就是预分配--在代码无法退出事务之前分配所有需要的内存资源。 预分配在内核中的很多情况下都会用到,而且效果很好,因此内存管理开发人员自然会问,在这种情况下是否也可以使用预分配。 Dave 很快就否定了这个想法

然而,预分配是一项愚蠢、复杂、CPU 和内存密集型工作,而且会对性能产生巨大影响。 将 10-100 个页面分配到我们几乎*不会使用*的储备中,然后*在每个事务中*再次释放这些页面,这是大量不必要的额外快速路径开销。 因此,"为每个上下文预先分配 "的储备池并不是一个可行的解决方案。

还有人提出了Mempool的可能性。 它们是一种预分配形式,可以避免上述的一些开销。 但Dave 认为,它们并不适合当前的问题。 Mempool 处理的是单一大小的对象,而文件系统事务需要各种各样的对象;这意味着在堆栈的不同层级需要多个 Mempool。 此外,对象生命周期之间的不匹配也会导致 mempool 难以在多个事务中使用。 因此,mempool 似乎也不是一种选择。

Dave 建议在内存管理子系统中加入 "保留 "的概念。 在进入事务之前,文件系统代码会通知内存管理代码,它需要保证访问一定量的内存;计算大约的内存需求显然并不难。 然后,内存管理代码将确保必要数量的内存可用;如果需要,后续的分配请求将使用储备内存。 只要对储备内存大小的估算足够充分,在事务处理过程中就不会出现分配失败的问题。

预留看起来很像预分配,但有一个关键区别。 内存管理代码已经保留了一个 "水印",即除非绝对必要,否则它不愿意低于这个水平的可用内存。 预留只会提高水印,使整个系统的可用内存减少一点。 如果保留会使水印超过当前可用内存的数量,请求就会被阻塞,直到有更多内存被回收。 在最简单的情况下,保留将表示为单个整数变量值的增加。

添加保留机制似乎得到了一些普遍支持,但一旦考虑到细节,情况就不那么明朗了。 安德鲁-莫顿(Andrew Morton)提出了一种方案,即进行保留的进程将获得一定数量的 "代币";该进程随后进行的分配将首先来自保留。 戴夫不喜欢这个想法,认为它没有考虑到在事务中分配的许多对象很快就会被释放(也许是被其他人释放),因此不应该从预留中获取。 相反,他认为保留是一个内存范围,除非别无选择,否则根本不会被触及;即便如此,也只有使用GFP_RESERVE标志的分配才能获得该内存。 在他看来,当内核以其他方式启动 OOM 杀手时,保留才会发挥作用。

相反,Johannes认为这种方法行不通。 问题在于"我们根本不知道可回收内存耗尽的确切时间点",因此内存管理子系统无法轻松保证戴夫所描述的那种松散的保留。 戴夫不同意这种说法,这几乎是不言而喻的。

保留是解决内核内存分配难题的一个很有前途的想法

但目前,它还只是一个想法,既没有代码,也没有设计共识。 讨论的速度暂时放慢了,但几乎可以肯定这只是暂时的。 截至本文撰写之时,距离一年一度的Linux 存储、文件系统和内存管理峰会还有不到一周的时间。

本文索引条目
内核
内存
管理/页面分配器


登录后可发表评论

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值