本文最初发布于 Julio Merino 的个人博客,经原作者授权由 InfoQ 中文站翻译并分享。
这篇文章在我的草稿箱里呆了 3 年了。我之所以一直犹豫着没发,一部分原因是本文探讨的内容是一个比较难以达成共识的想法。另一部分原因是,如果只看这个激进的标题,可能会引发不小的争议。无论如何,现在是时候发布出来了!
根据Betteridge的标题法则,标题中的问题,答案一般是“不“。我同意,但有一些点需要注意。
我们都见过类似这样的讨论,尤其是在 Hacker News 上:首先是有人抱怨,像谷歌 Chrome 这样的应用程序很浪费资源,因为它会消耗好几 GB 的内存。然后就有人过来说,这些内存是用来提升速度的,因此,这是正确的行为:如果计算机有数 GB 的空闲内存,像 Chrome 这样的应用程序应该将所有可用内存用作缓存,从而尽可能地提高响应速度。好像很有道理,是吗?
是的,有道理,但前提得是只有 Chrome 一个应用程序在运行。然而,这种情况并不常有,不是吗?总是会有多个程序同时运行(考虑下系统服务,还有像 Teams 这样的重量级程序),也就是说,那也许不是最好的主意。
本文将探讨两个问题,一是为什么最好不要允许应用程序占用所有的可用内存作为缓存,二是关于这一问题的可能的解决方案。我将以 Chrome 为例,因为它经常成为抱怨的对象,但本文探讨的内容也同样适用于所有其他的浏览器,以及大多数现代化的大型程序。但是,在此之前,让我们先回顾一些内存管理的基础知识,以确保我讲的和你想的是同一件事。
内存分页回顾
对于运行在其上的每个应用程序(进程),现代计算机提供的内存地址空间基本上可以说是无限的。每个进程都认为只有自己在运行,有海量的内存可供自己使用,从地址 0 到 2^64(在 64 位的机器上)。
显然,事实并非如此:还没有计算机有 2^64 物理字节的内存。计算机处理器将物理内存划分成固定大小的页帧(page frames)(通常是 4KB),将每个进程的虚拟内存划分成页(pages)。然后,操作系统内核负责将虚拟内存页子集映射到物理页帧,对于每一次内存访问,处理器会处理虚拟地址和物理地址之间的转换。
在这种设计下,虚拟地址空间比物理内存要大许多,多个进程(这样会有多个虚拟地址空间)可以同时运行。换句话说:物理内存被故意过度使用(over-committed)。当一个进程在其虚拟地址空间中分配了较多的内存,可能会没有足够的物理内存页来支撑新分配的虚拟页。这样就会导致内存紧张(memory pressure),内核将做些事情来缓解这种情况,尝试满足新的内存请求。
在内存紧张的情况下,内核会做什么,这取决于操作系统,但通常来说,内核必须找到已经映射的页(可以是任何进程的)并把它们驱逐,从而释放页帧,为新的内存请求腾出空间。
我们可以将内存页分成以下两类。要了解更多背景信息,我建议你读下NetBSD关于统一缓冲区缓存的论文:
文件页对应的内存块直接来自磁盘文件。如果没有修改的话,这些页可以随意丢弃,如果修改了,则可以刷写到它们的后备文件。例如:用于运行可执行代码的页总是可以丢弃,因为它们是只读的,而且磁盘上有后备文件,而通过
mmap(2)
使用的页可能需要,也可能不需要刷写到磁盘,这要视它们的脏状态而定。匿名页对应分配给应用程序的内存块(考虑下
malloc
和new
)。在内核看来,这些内存的内容是无意义的,因为它们是由程序逻辑 "动态 "填充的,没有后备资源。这样一来,如果要驱逐这些页,就没有一个文件可供刷写,所以内核就得把它们放在其他某个地方。那个某个地方就是交换区(swap area)。
重要提示:关于页驱逐的一个关键细节,也是你阅读本文接下来的内容时必须留意的一个关键细节,就是页的原始来源不同,页驱逐进程会有所不同。
如果系统无法找到足够的页来驱逐(例如,已经驱逐了所有文件页,也已经没有交换空间来驱逐剩余的匿名页),内核就会发生严重错误,或者开始终止进程,尝试释放已使用的内存。在 Linux 上,这是通过备受喜爱的 Out Of Memory killer 机制完成的。
至于如何决定从内存中驱逐哪些页,每个内核都有自己的算法。一般来说,内核会实现一个 LRU 算法驱逐最近最少使用的页。但是,它也会考虑每次驱逐的“成本”:
成本最低的做法是首先驱逐只读文件页,因为它们不需要通过写磁盘实现持久化,而且可以快速恢复。
成本第二低的是驱逐脏文件页,因为它们位于文件系统中,可以覆写。
成本最高的是驱逐匿名页,因为这不可避免地要写交换区——这反过来又可能会涉及某种空间分配。你永远不会希望系统到达必须将页移到交换区的程度,这么做时,性能就很糟糕了。
不过,这里就不介绍具体细节了,那和本文接下来的内容没什么关系。
同时运行 Chrome 和 Bazel
在介绍完背景信息后,让我们回到最初的讨论。
为了证明 Chrome 大量使用内存的合理性,通常人们给出的论据是,浏览器使用所有内存作为缓存。持有这种观点的人认为,这是好事,因为人们想要快速的浏览体验,尽量多地缓存数据有助于实现那种体验。没错,不过他们忽略了一个小事实,就是 Chrome 不是在真空中运行。
我将采用 Raymond Chen 的分析方法“如果两个程序这样做会怎么样?”,说明这为什么是个坏主意。饥饿的浏览器可能与其他程序同时运行,而其中某些程序也可能是内存密集型的。为了使示例场景更真实,我们把 Bazel 加入进来,这个应用程序也喜欢占用大量的内存,以缓存工作空间中数 GB 的构建图。在这个场景中,我们有一名程序员使用 Chrome 在线研究一些信息,编写一些代码(使用某种重量级 IDE),最后使用 Bazel 构建生成的项目。
在这种情况下,程序员可能首先会密集地使用 Chrome 做些研究。在这个过程中,Chrome 的内存使用可能会逐渐增加,占用所有可用的内存来缓存页面和图片。然后,程序员可能会运行 Bazel 构建项目。而 Bazel 可能需要额外消耗大量的内存来加载完全依赖图。但是,此时,Bazel 可能没有找到足够的可用内存,所以操作系统将需要换出 Chrome 的缓存内存。这可能会导致后续切回 Chrome 的时候反应速度慢很多,因为浏览器缓存的东西都被换出了。
这里的问题是,像 Chrome 和 Bazel 这样的应用程序使用匿名内存来运作它们自己的缓存。按定义,缓存内存可以在任意时间随意丢弃,当出现内存压力时,内核唯一能看到的是这些应用程序分配了大量的匿名内存。内核并不知道这些页中是包含必须持久化的宝贵数据,还是可随意丢弃的易失性数据。由此导致的恶果是,内核可能会决定将缓存数据移到交换区,我前面提到过,一旦用到了交换区,从性能的角度来说,你已经输了。
关于这一点,我们有什么可以做的吗?
协作型内存分配
你会说,“好吧,我们显然不能允许应用程序把所有内存都拿来自己用。我们应该限制他们最多使用 X%的内存,要留一些内存给其他应用程序使用”。(事实上,这就 Bazel 采用的方案。)
这无法解决任何问题:如果应用程序本身负责查看当前可用的内存,然后独自决定应该使用多少内存,那么要么我们最终还会面临上面的情况,要么就是应用程序没有足够的内存可以使用。设想一下,如果我们允许 Chrome 使用所有内存的 80%,因为它知道要留下 20%。然后,Bazel 要运行了,按照配置,它也可以使用可用内存的 80%……是 20% 的 80%,已经很小了。Bazel 受到了巨大的惩罚,只因为它是第二个运行的。
你可以调整这个模型,提出一种有效的方案,但是,通过分布式决策决定如何使用内存是不够的。首先,你需要一个能在整个机器上做出明智决策的神使(内核);其次,你不能相信所有的应用程序都会遵守规则。毕竟,那就是几十年前我们从协作型多任务转到抢占式多任务的原因。
文件缓存
我们考虑下这样一个场景:有很多应用程序,它们几乎不使用匿名内存,但会大量使用文件系统。这些应用程序每个都会打开许多非常大的文件,对它们执行随机读写操作,而且还打开很长时间。我们同时运行着这些应用程序的多个实例。
在这种情况下,我们可能会看到,系统总体的内存使用率接近 100%,和之前一样,但交换区仍然是空的。更重要的是,虽然系统可能因为 CPU 和 I/O 使用率高而变慢,但其性能是可预测的:从命令行执行一条简单的命令 ls
瞬间就可以完成,不会受内存抖动拖累。
这里的情况是,内核现在将所有内存作为其文件缓冲区缓存的一部分;应用程序本身不控制内存。这种内核级的缓存可以跟踪文件页(不是匿名页),通过优化随机 I/O 和顺序访问(通过预取)来提升 I/O 性能。通常,该缓存可以占用所有可用的内存。
与前面介绍的 Chrome 和 Bazel 所采用的方案相比,这种基于文件的方案有一个很大的不同,就是由内核控制一个统一的跨应用程序的缓存,内核对缓存中的内容了如指掌。内核可以针对缓存中的内容从整体视角做出决策,尽量保证所有应用程序的正常运行:如果只有一个应用程序在运行,那么所有的文件缓冲区缓存将都供它使用;但是,如果有两个或两个以上的应用程序在运行,那么它们将“公平地”共享缓存——我这里之所以加引号,是因为确实存在相互干扰的问题。
根本原因
那么,允许 Chrome 使用所有内存作为缓存,真正的问题在哪里?
简单来说,就是操作系统无法查看应用程序的匿名内存,不能自己做决策。由此导致的结果是,当出现内存压力时,内核唯一能做的事情是,将匿名内存页推出到磁盘——即使那些页包含了易失性缓存——后续再从交换区还原它们成本很高。
有什么解决方案吗?
设想一下,如果内核和应用程序之间有一种可以回收内存的反馈机制。内核可以说,“嗨,Chrome,我内存不够用了,把你不是特别需要的内存释放出来一些吧”,以此请求回收内存。
遗憾的是,这是不可行的,因为这得要求所有应用程序配合,在所有情况下都不能做错。流氓应用程序或是存在缺陷的应用程序会囤积内存,导致更糟糕的情况。
尽管如此,Android 就实现了这样一种方案。Android 的设计就是,系统可以彻底驱逐应用程序的某些部分(活动)。其原理是,系统和应用程序之间有一种协议,通过它可以实现受控的驱逐动作:系统首先会友好地请求应用程序释放内存,并允许应用程序刷写数据,但是,即使应用程序没有按照要求释放内存,系统也会销毁那部分内存。这两种情况都有可能出现,因此,在设计应用程序时必须保证,不管是被优雅地关闭还是强制关闭,它都可以重建状态——就像它从未退出过。Android 之所以有这样一种设计,一个原因是它首先是面向移动设备的,这类设备的内存很小,另一个原因是移动设备同一时间主要运行一个应用。
另一种解决方案是,有一个系统级的缓存服务,可以处理运行应用程序产生的任意内存对象。这样一个服务可以跨应用程序就内存使用做出协商一致的决策,均匀地删除缓存条目。但是,和前面的解决方案一样,需要所有应用程序的配合。否则,一个不合格的应用程序可能会囤积内存,使得其他所有按规则行事的应用程序都受到惩罚。
这就说到了另一种解决方案:划分内存,预先指定每个程序可以使用的内存大小。容器就是这么做的,但对于个人计算机来说这并不是一个好方案:硬性划分无法动态适应用户的行为。有时候,你就是只想浏览网页,在那种情况下,你会希望浏览器使用所有可用的资源。
最后,我们可以尝试将应用程序都塞进现有系统的设施中。如果应用程序使用文件而不是匿名内存来实现缓存,那么系统的文件缓冲区缓存就可以正常运行。设想一下,每个应用程序都使用单个的文件来存储可缓存的对象,而不是使用 malloc
来获取匿名内存。这时,应用程序会使用打开/读取/关闭循环来访问那些缓存的对象。在这种情况下,内核中的文件缓存就可以跨应用程序做该做的事:经常使用的缓存条目(文件)会驻留内存,如果内存紧张,就可以把它们驱逐到后备文件中,而且成本很低。性能可能会因为额外的系统调用而受影响,但总的结果还是要好些。事实上,Varnish就是这样做的!
遗憾的是,上面所有这些解决方案都需要某种跨程序协同,并且需要所有程序都按规则行事。这在设计新系统时也许可行(就像 Android 所做的那样),但将这些东西加装到目前的系统中,肯定是不行的,虽然我希望它可以。
最后,你可能会认为,在现如今的世界里,上面这样的情况没什么问题,因为计算机有足够的内存。但它们确实还是问题。我之前在谷歌的时候,就想着要让人们能够在 16GB 的笔记本上愉快地使用 Chrome 和 Bazel。每次我的 Surface Go 2 变慢(我一年前新买的机器,但只有 8GB 内存),我就会想起这些问题。
那么,让我们回到最初的问题:“浏览器应该使用所有可用的内存吗?”不应该,不能像现在这样做。但是,如果有更好的机制可以实现有效的跨应用程序缓存,答案就是 Yes 了。
原文链接:
评论