1

浏览器应该使用所有的可用内存吗?

 3 years ago
source link: https://www.infoq.cn/article/o29jR1Lrcx3tBK7aJwkG
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

浏览器应该使用所有的可用内存吗?



2021 年 8 月 24 日

浏览器应该使用所有的可用内存吗?

本文最初发布于 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) 使用的页可能需要,也可能不需要刷写到磁盘,这要视它们的脏状态而定。

  • 匿名页对应分配给应用程序的内存块(考虑下 mallocnew )。在内核看来,这些内存的内容是无意义的,因为它们是由程序逻辑 "动态 "填充的,没有后备资源。这样一来,如果要驱逐这些页,就没有一个文件可供刷写,所以内核就得把它们放在其他某个地方。那个某个地方就是交换区(swap area)

重要提示:关于页驱逐的一个关键细节,也是你阅读本文接下来的内容时必须留意的一个关键细节,就是页的原始来源不同,页驱逐进程会有所不同。

如果系统无法找到足够的页来驱逐(例如,已经驱逐了所有文件页,也已经没有交换空间来驱逐剩余的匿名页),内核就会发生严重错误,或者开始终止进程,尝试释放已使用的内存。在 Linux 上,这是通过备受喜爱的 Out Of Memory killer 机制完成的。

至于如何决定从内存中驱逐哪些页,每个内核都有自己的算法。一般来说,内核会实现一个 LRU 算法驱逐最近最少使用的页。但是,它也会考虑每次驱逐的“成本”:

  1. 成本最低的做法是首先驱逐只读文件页,因为它们不需要通过写磁盘实现持久化,而且可以快速恢复。

  2. 成本第二低的是驱逐脏文件页,因为它们位于文件系统中,可以覆写。

  3. 成本最高的是驱逐匿名页,因为这不可避免地要写交换区——这反过来又可能会涉及某种空间分配。你永远不会希望系统到达必须将页移到交换区的程度,这么做时,性能就很糟糕了。

不过,这里就不介绍具体细节了,那和本文接下来的内容没什么关系。

同时运行 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 了。

原文链接:

Should the browser use all available memory?

划线
评论
复制

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK