Go 博客
Go 的演进:Go 垃圾收集器的旅程
这是我在 2018 年 6 月 18 日国际内存管理专题讨论会 (ISMM) 上发表的主题演讲的文字记录。在过去的 25 年里,ISMM 一直是发表内存管理和垃圾收集论文的首要场所,我很荣幸能被邀请发表主题演讲。
摘要
Go 语言的特性、目标和用例迫使我们重新思考整个垃圾收集器栈,并引领我们走向一个令人惊讶的地方。这段旅程令人兴奋。本次演讲将描述我们的旅程。这是一段由开源和 Google 的生产需求驱动的旅程。其中包括我们因数据引导而进入死胡同般的峡谷的插曲。本次演讲将深入探讨我们旅程的方式和原因,我们在 2018 年的位置,以及 Go 为下一段旅程所做的准备。
个人简介
Richard L. Hudson(Rick)因其在内存管理方面的工作而闻名,包括发明了 Train、Sapphire 和 Mississippi Delta 算法,以及使得 Modula-3、Java、C# 和 Go 等静态类型语言能够进行垃圾收集的 GC 栈图。Rick 目前是 Google Go 团队的一员,负责 Go 的垃圾收集和运行时相关问题。
联系方式:rlh@golang.org
评论:请参阅 golang-dev 上的讨论。
文字记录

我是 Rick Hudson。
本次演讲是关于 Go 运行时,特别是垃圾收集器的。我准备了大约 45 或 50 分钟的材料,之后我们将有时间进行讨论,我也会在附近,所以之后欢迎大家过来交流。

在开始之前,我想感谢一些人。
演讲中很多精彩的部分是由 Austin Clements 完成的。剑桥 Go 团队的其他成员,Russ、Than、Cherry 和 David,都是一群引人入胜、令人兴奋且有趣的合作伙伴。
我们还要感谢全球 160 万 Go 用户,他们为我们带来了许多有趣的问题需要解决。没有他们,很多问题可能永远不会被发现。
最后,我要感谢 Renee French 多年来创作了所有这些可爱的 Go 地鼠。在整个演讲中,您会看到其中的几个。

在我们深入讨论这些内容之前,我们必须先展示 GC 眼中的 Go 是什么样子。

首先,Go 程序拥有成千上万个栈。它们由 Go 调度器管理,并且总是在 GC 安全点被抢占。Go 调度器将 Go 例程多路复用到 OS 线程上,希望每个硬件线程运行一个 OS 线程。我们通过复制栈并更新栈中的指针来管理栈及其大小。这是一个本地操作,因此扩展性相当好。

接下来重要的一点是,Go 是一种遵循 C 语言风格的系统语言的传统,以值为主导的语言,而不是像大多数托管运行时语言那样以引用为主导。例如,这展示了 tar 包中的一个类型在内存中的布局方式。所有字段都直接嵌入到 Reader 值中。这让程序员在需要时能够更好地控制内存布局。可以将具有相关值关系的字段放在一起,这有助于提高缓存局部性。
值为主导的方式也有助于外部函数接口(FFI)。我们与 C 和 C++ 之间有一个快速的 FFI。显然,Google 有大量的可用设施,但它们都是用 C++ 编写的。Go 不可能等待将所有这些功能用 Go 重写,所以 Go 必须通过外部函数接口访问这些系统。
这个设计决策导致了运行时中一些更令人惊叹的事情。这可能是 Go 与其他支持 GC 的语言最重要的区别。

当然,Go 可以有指针,事实上它们可以有内部指针。这些指针会保持整个值存活,而且它们相当常见。

我们还采用了提前编译系统,因此二进制文件包含完整的运行时。
没有 JIT 重编译。这有优点也有缺点。首先,程序执行的可复现性大大提高了,这使得编译器改进的推进速度更快。
不足之处在于,我们没有机会像 JIT 系统那样进行反馈优化。
所以有利有弊。

Go 提供了两个旋钮来控制 GC。第一个是 GCPercent。这基本上是一个调整您想要使用多少 CPU 和多少内存的旋钮。默认值是 100,这意味着堆的一半用于存活内存,一半用于分配。您可以向任一方向修改它。
MaxHeap 尚未发布,但正在内部使用和评估,它允许程序员设置最大堆大小。内存不足 (OOM) 对 Go 来说是个棘手的问题;内存使用的临时峰值应该通过增加 CPU 成本来处理,而不是通过中止程序。基本上,如果 GC 检测到内存压力,它会通知应用程序应该减轻负载。一旦恢复正常,GC 会通知应用程序可以恢复正常负载。MaxHeap 也提供了更大的调度灵活性。运行时不再需要总是担心可用内存,它可以将堆大小调整到 MaxHeap 的大小。
这总结了我们关于对垃圾收集器很重要的 Go 部分的讨论。

现在我们来谈谈 Go 运行时以及我们如何走到今天,我们是如何走到现在的。

时间来到了 2014 年。如果 Go 不能以某种方式解决 GC 延迟问题,那么 Go 就不会成功。这一点很清楚。
其他新语言也面临同样的问题。像 Rust 这样的语言走了另一条路,但我们将讨论 Go 所选择的道路。
为什么延迟如此重要?

数学原理对此毫不留情。
99% 分位的独立 GC 延迟服务水平目标 (SLO),例如 99% 的时间 GC 周期耗时 < 10ms,根本无法扩展。重要的是在整个会话期间或一天多次使用应用程序过程中的延迟。假设一个会话浏览多个网页,最终在一个会话中发出 100 个服务器请求,或者发出 20 个请求,而您一天有 5 个这样的会话。在这种情况下,只有 37% 的用户能在整个会话中获得一致的低于 10ms 的体验。
如果您希望 99% 的用户拥有低于 10ms 的体验,正如我们所建议的,数学告诉您,您实际上需要达到 4 个 9,即 99.99% 分位。
因此,时间到了 2014 年,Jeff Dean 刚刚发表了他的论文《大规模系统的长尾问题》(The Tail at Scale),本文对此进行了进一步探讨。这篇论文在 Google 内部被广泛阅读,因为它对 Google 未来的发展和试图在 Google 规模下进行扩展具有严重的影响。
我们将这个问题称为“9的暴政”。

那么如何对抗“9的暴政”呢?
2014 年,人们正在做很多事情。
如果您想要 10 个答案,就多请求几个,然后取前 10 个,这些就是您放在搜索页面上的答案。如果请求超过 50% 分位,就重新发出或转发请求到另一台服务器。如果 GC 即将运行,就拒绝新的请求或将请求转发到另一台服务器,直到 GC 完成。等等等等。
所有这些变通方案都来自非常聪明的人,他们面临非常现实的问题,但这些方案并没有解决 GC 延迟的根本问题。在 Google 的规模下,我们必须解决根本问题。为什么?

冗余无法扩展,冗余成本很高。它需要新的服务器集群。
我们希望能够解决这个问题,并将其视为改善服务器生态系统的机会,在这个过程中拯救一些濒危的玉米田,让一些玉米粒在七月四日前长到齐膝高,并发挥其全部潜力。

所以这是 2014 年的 SLO。是的,确实是我在保守(或“放水”),我是团队的新成员,对我来说这是一个新的流程,而且我不想过度承诺。
此外,关于其他语言中 GC 延迟的演示文稿简直令人害怕。

最初的计划是实现一个无读屏障的并发复制 GC。那是长期计划。关于读屏障的开销有很多不确定性,所以 Go 想避免它们。
但在 2014 年短期内,我们必须振作起来。我们必须将所有的运行时和编译器转换为 Go。当时它们是用 C 编写的。不再使用 C,不再因 C 程序员不理解 GC 但对如何复制字符串有酷炫想法而导致一长串 bug。我们还需要快速完成某些工作,并且专注于延迟,但性能损失必须小于编译器带来的加速。因此我们受到了限制。我们基本上有一年的编译器性能改进可以被并发 GC 消耗掉。但也仅限于此。我们不能减慢 Go 程序的运行速度。这在 2014 年是站不住脚的。

所以我们稍作退让。我们不打算做复制部分。
决定是采用三色并发算法。在我职业生涯的早期,Eliot Moss 和我曾做过期刊证明,表明 Dijkstra 算法适用于多个应用程序线程。我们还证明了可以消除 STW(Stop-The-World)问题,并且我们有可以实现的证明。
我们也担心编译器的速度,也就是编译器生成的代码。如果我们在大部分时间关闭写屏障,编译器优化受到的影响将最小,编译器团队可以快速推进。Go 在 2015 年也迫切需要短期成功。

那么让我们看看我们做的一些事情。
我们采用了按大小分割的 Span(内存块)。内部指针是个问题。
垃圾收集器需要高效地找到对象的起始位置。如果它知道 Span 中对象的大小,它只需向下取整到那个大小,那就是对象的起始位置。
当然,按大小分割的 Span 还有其他一些优点。
低碎片:除了 Google 的 TCMalloc 和 Hoard,我在 C 语言方面的经验,以及我深度参与的 Intel Scalable Malloc 的工作,都让我们相信对于不可移动的分配器来说,碎片不会成为问题。
内部结构:我们完全理解并拥有相关经验。我们懂得如何按大小分割 Span,我们懂得如何实现低或零竞争的分配路径。
速度:非复制方式我们并不担心,分配速度诚然可能较慢,但仍在 C 语言的量级。可能不如 Bump Pointer 快,但这没关系。
我们还有外部函数接口的问题。如果我们不移动对象,那么我们就不必处理在使用移动收集器时可能遇到的冗长的 bug 尾部,这些 bug 会在您尝试固定对象并在 C 和您正在使用的 Go 对象之间引入间接层时出现。

下一个设计选择是把对象的元数据放在哪里。由于我们没有对象头,我们需要一些关于对象的信息。标记位保存在旁边,用于标记和分配。每个字都有 2 个与之关联的位,用于告诉您该字是标量还是指针。它还编码了对象中是否还有更多的指针,以便我们可以尽快停止扫描对象。我们还有一个额外的位编码,可以用作额外的标记位或进行其他调试。这对于让这些东西运行起来和查找 bug 非常有价值。

那么写屏障呢?写屏障只在 GC 期间开启。在其他时间,编译后的代码会加载一个全局变量并查看它。由于 GC 通常是关闭的,硬件会正确地推测并绕过写屏障的分支。当我们在 GC 内部时,该变量是不同的,写屏障负责确保在三色操作期间不会丢失任何可到达的对象。

代码的另一部分是 GC Pacer(GC 调节器)。这是 Austin 做的一些很棒的工作。它基本上基于一个反馈回路,用于确定何时最好地开始一个 GC 周期。如果系统处于稳定状态且没有发生阶段变化,标记将在内存耗尽时结束。
情况可能并非如此,所以 Pacer 还必须监控标记进度,并确保分配不会超过并发标记的速度。
如需,Pacer 会在加快标记速度的同时减慢分配速度。从宏观上看,Pacer 会暂停执行大量分配的 Goroutine,并让它执行标记工作。工作量与该 Goroutine 的分配量成正比。这既能加速垃圾收集器,又能减慢修改器(mutator)的速度。
当所有这些都完成后,Pacer 会结合从本次 GC 周期和之前周期中学到的东西,预测何时开始下一个 GC。
它做的远不止这些,但这是基本的方法。
其中的数学原理非常引人入胜,请联系我获取设计文档。如果您正在进行并发 GC,您真的应该看看这些数学原理,看看是否与您的计算方法相同。如果您有任何建议,请告诉我们。
*Go 1.5 并发垃圾收集器调节 和 提案:分离软硬堆大小目标

是的,所以我们取得了成功,而且很多。一个更年轻、更疯狂的 Rick 会把这些图表中的一些纹在肩膀上,我为此感到非常自豪。

这是一系列为 Twitter 生产服务器制作的图表。我们当然与那台生产服务器没有任何关系。Brian Hatfield 做了这些测量,而且奇怪的是,他在 Twitter 上发布了这些信息。
在 Y 轴上我们有 GC 延迟,单位是毫秒。在 X 轴上我们有时间。每个点代表该次 GC 期间的 Stop-The-World 暂停时间。
在我们的第一个版本(2015 年 8 月发布)中,我们看到延迟从大约 300 - 400 毫秒下降到 30 或 40 毫秒。这很不错,是数量级的改进。
我们在这里将 Y 轴从 0 到 400 毫秒大幅更改为 0 到 50 毫秒。

这是 6 个月后的情况。改进很大程度上归功于系统性地消除了我们在 Stop-The-World 期间执行的所有与堆大小成比例(O(heap))的操作。这是我们的第二个数量级改进,延迟从 40 毫秒下降到 4 或 5 毫秒。

其中存在一些我们需要清理的 bug,我们在 1.6.3 小版本发布时完成了这项工作。这使得延迟降到了远低于 10 毫秒,这是我们的 SLO。
我们即将再次更改 Y 轴,这次是降到 0 到 5 毫秒。

所以我们到了这里,这是 2016 年 8 月,距离第一个版本发布一年。我们再次继续消除这些与堆大小相关的 Stop-The-World 过程。我们这里讨论的是一个 18GB 的堆。我们有更大的堆,当我们消除了这些与堆大小相关的 Stop-The-World 暂停时,堆的大小显然可以显著增长而不会影响延迟。所以这在 1.7 版本中有所帮助。

下一个版本是在 2017 年 3 月。我们经历了最后一次大幅延迟下降,这是因为我们找到了如何避免在 GC 周期结束时进行 Stop-The-World 的栈扫描。这使我们进入了亚毫秒级别。同样,Y 轴即将更改为 1.5 毫秒,我们看到了第三个数量级的改进。

2017 年 8 月的版本改进不大。我们知道剩余的暂停是由什么引起的。这里的 SLO 目标大约在 100-200 微秒,我们将朝着这个目标努力。如果您看到任何超过几百微秒的情况,那么我们非常想与您交流,找出这是否属于我们已知的问题,或者是否是新的、我们尚未研究过的问题。无论如何,似乎对更低延迟的需求不大。重要的是要注意,这些延迟水平可能是由各种非 GC 原因引起的,俗话说“你不必跑得比熊快,你只需要跑得比你旁边那个人快。”
2018 年 2 月的 1.10 版本没有实质性变化,只是一些清理和处理边界情况。

新的一年,新的 SLO。这是我们 2018 年的 SLO。
我们将总 CPU 消耗降至 GC 周期中使用的 CPU 消耗。
堆大小仍保持在 2 倍。
我们现在的目标是每个 GC 周期 Stop-The-World 暂停时间为 500 微秒。这里可能有点保守。
分配将继续与 GC 协助成比例。
Pacer 已经改进了很多,所以我们期望在稳态下看到最小的 GC 协助。
我们对此非常满意。同样,这不是一个 SLA(服务级别协议),而是一个 SLO(服务级别目标),因此它是一个目标,而不是一个协议,因为我们无法控制操作系统等因素。

这些是成功的部分。现在我们来谈谈我们的失败。这些是我们的伤疤;它们就像纹身一样,每个人都会有。不过,它们伴随着更好的故事,所以我们来聊聊其中的一些。

我们的第一次尝试是实现一种叫做请求导向收集器(Request Oriented Collector,或简称 ROC)的东西。其假设可以在这里看到。

那么这意味着什么呢?
Goroutines 是轻量级线程,看起来像 Go 地鼠,所以这里我们有两个 Goroutine。它们共享一些东西,比如中间的两个蓝色对象。它们有自己的私有栈和各自的私有对象集合。假设左边的 Goroutine 想要共享绿色对象。

这个 goroutine 将它放入共享区域,这样另一个 Goroutine 就可以访问它。它们可以将其连接到共享堆中的某个东西,或者将其赋值给一个全局变量,然后另一个 Goroutine 就可以看到它。

最后,左边的 Goroutine 即将走向死亡,它快要死去了,真伤心。

如您所知,当您“死去”时,您无法带走您的对象。您的栈也不能带走。此时栈实际上是空的,并且对象是不可达的,因此您可以简单地回收它们。

这里重要的一点是,所有操作都是本地的,不需要任何全局同步。这与分代 GC 等方法根本不同,我们希望通过避免全局同步获得的扩展性足以让我们取得成功。

这个系统存在的另一个问题是写屏障始终开启。每当发生写入时,我们都必须检查是否正在将指向私有对象的指针写入公共对象。如果是这样,我们必须将被引用对象设置为公共的,然后对可到达的对象进行传递性遍历,确保它们也是公共的。这是一个相当昂贵的写屏障,可能导致许多缓存未命中。

话虽如此,哇,我们还是取得了一些不错的成功。
这是一个端到端 RPC 基准测试。标记错误的 Y 轴范围是 0 到 5 毫秒(越低越好),不管怎样,就是这样。X 轴基本上是压载物,或者说是内存数据库的大小。
正如您所见,如果开启了 ROC 并且没有太多共享,性能实际上扩展得相当不错。如果未开启 ROC,则效果远不如开启 ROC。

但那还不够好,我们还必须确保 ROC 不会减慢系统的其他部分。当时我们对编译器非常关注,不能减慢编译器的速度。不幸的是,编译器恰恰是 ROC 表现不佳的程序。我们看到了 30%、40%、50% 甚至更多的减速,这是不可接受的。Go 以其快速的编译器为傲,所以我们不能减慢编译器,当然更不能减慢这么多。

然后我们去查看了一些其他程序。这些是我们的性能基准测试。我们有 200 或 300 个基准测试集,这些是编译器团队认为对他们工作和改进很重要的。这些测试完全不是由 GC 团队选择的。结果数据普遍都很差,ROC 不可能成为赢家。

确实我们实现了扩展,但我们只有 4 到 12 个硬件线程系统,所以我们无法克服写屏障的开销。也许将来当我们拥有 128 核系统并且 Go 充分利用它们时,ROC 的扩展特性可能会带来胜利。当那发生时,我们可能会回来重新审视它,但目前 ROC 是一个失败的方案。

那么接下来我们要做什么呢?我们来试试分代 GC 吧。这是一个经典而有效的方法。ROC 行不通,所以我们回到我们更有经验的东西上来。

我们不会放弃我们的低延迟,也不会放弃我们是不可移动的这一事实。所以我们需要一个不可移动的分代 GC。

那么我们能做到吗?是的,但对于分代 GC,写屏障是始终开启的。当 GC 周期运行时,我们使用与现在相同的写屏障,但在 GC 关闭时,我们使用一个快速的 GC 写屏障,它会缓冲指针,然后在缓冲区溢出时将缓冲区刷新到卡片标记表。

那么在不可移动的情况下这会如何工作呢?这里是标记/分配映射。基本上,您维护一个当前指针。当您进行分配时,您会寻找下一个零,当找到那个零时,您就在那个空间中分配一个对象。

然后您将当前指针更新到下一个零。

您继续,直到某个时刻该进行分代 GC 了。您会注意到,如果在标记/分配向量中有一个一,那么该对象在上次 GC 时是存活的,因此它是成熟的。如果它是零并且您到达了它,那么您就知道它是年轻的。

那么您如何进行晋升呢?如果您发现标记为 1 的东西指向标记为 0 的东西,那么您只需将该零设置为一即可晋升被引用对象。

您必须进行传递性遍历,以确保所有可到达的对象都被晋升。

当所有可到达的对象都被晋升后,次要 GC 就终止了。

最后,为了完成您的分代 GC 周期,您只需将当前指针设置回向量的起始位置,然后就可以继续了。在那个 GC 周期中没有被访问到的零都变成了空闲空间,可以被重用。许多人可能知道,这被称为“粘性位”(sticky bits),是由 Hans Boehm 和他的同事发明的。

那么性能如何呢?对于大堆来说还不错。这些是 GC 应该表现良好的基准测试。一切都很好。

然后我们在我们的性能基准测试上运行它,结果就不太好了。那么发生了什么呢?

写屏障虽然快,但还不够快。此外,它很难优化。例如,如果在对象分配和下一个安全点之间有初始化写入,可以省略写屏障。但我们不得不转向一个系统,其中每个指令都有一个 GC 安全点,所以将来真的没有任何可以省略的写屏障了。

我们还有逃逸分析,而且它变得越来越好了。还记得我们之前谈论的值导向的东西吗?我们不是将指针传递给函数,而是传递实际的值。因为我们传递的是值,逃逸分析只需要进行过程内(intraprocedural)逃逸分析,而不需要进行过程间(interprocedural)分析。
当然,如果局部对象的指针逃逸了,那么对象就会在堆上分配。
并不是说分代假设对 Go 不成立,只是年轻对象在栈上生成并快速消亡。结果是,分代收集的效果远不如其他托管运行时语言中那么明显。

因此,这些反对写屏障的力量开始集结。如今,我们的编译器比 2014 年好得多。逃逸分析能够捕获很多对象并将它们放在栈上——这些对象本可以由分代收集器处理。我们开始创建工具来帮助用户找到逃逸的对象,如果问题不大,他们可以修改代码,帮助编译器在栈上分配。
用户越来越聪明地采用值导向的方法,并且指针的数量正在减少。数组和映射存储的是值,而不是指向结构体的指针。一切都很好。
但这并不是 Go 的写屏障未来发展举步维艰的主要令人信服的原因。

让我们看看这张图。这只是一个标记成本的分析图。每条线代表一个可能具有标记成本的不同应用程序。假设您的标记成本是 20%,这相当高,但有可能。红线是 10%,仍然很高。下面的线是 5%,这大约是如今写屏障的成本。那么如果您将堆大小加倍会发生什么呢?那就是右边的点。标记阶段的累计成本显著下降,因为 GC 周期不再那么频繁了。写屏障的成本是固定的,所以增加堆大小的成本会使得标记成本降到写屏障成本之下。

这里是写屏障更常见的成本,即 4%,我们看到即使如此,通过简单地增加堆大小,我们也可以将标记屏障的成本降到写屏障的成本之下。
分代 GC 的真正价值在于,在查看 GC 时间时,写屏障的成本被忽略了,因为它们分散在修改器(mutator)中。这是分代 GC 的巨大优势,它大大减少了完整 GC 周期的长时间 STW,但它并不一定能提高吞吐量。Go 没有 Stop-The-World 的问题,所以它必须更仔细地研究吞吐量问题,而这正是我们所做的。

这是很多失败,伴随失败而来的是食物和午餐。我照常抱怨道:“天哪,要是没有写屏障就好了。”
与此同时,Austin 刚花了一个小时与 Google 的一些硬件 GC 专家交流,他说我们应该和他们谈谈,试着弄清楚如何获得可能有所帮助的硬件 GC 支持。然后我开始讲一些我曾在一家大型硬件公司工作时,关于零填充缓存行、可重启原子序列以及其他未能实现的东西的“战争故事”。当然,我们确实把一些东西放进了名为 Itanium 的芯片里,但没能把它们放到如今更受欢迎的芯片中。所以故事的寓意就是:只使用我们现有的硬件。
总之,这让我们开始讨论一些疯狂的想法。

没有写屏障的卡片标记怎么样?结果发现 Austin 有些文件,他把所有疯狂的想法都写在这些文件里,出于某种原因他没有告诉我。我想这是一种治疗方式吧。我以前和 Eliot 也做过类似的事情。新的想法很容易被扼杀,在将它们公之于众之前,需要保护它们,让它们变得更强大。总之,他拿出了这个想法。
这个想法是,你在每张卡片中维护一个成熟指针的哈希值。如果指针被写入卡片,哈希值会改变,这张卡片就会被视为已标记。这会用哈希的成本来替代写屏障的成本。

但更重要的是,它是硬件对齐的。
现代体系结构拥有 AES(高级加密标准)指令。其中一个指令可以进行加密级别的哈希计算,并且有了加密级别的哈希,如果我们遵循标准的加密策略,就不必担心冲突。所以哈希计算不会花费我们太多,但我们必须加载要进行哈希计算的数据。幸运的是,我们是按顺序遍历内存的,所以内存和缓存性能非常好。如果您有一个 DIMM,并且您访问的是连续地址,那么这是一个优势,因为它们会比访问随机地址快。硬件预取器也会启动,这也会有所帮助。总之,我们有 50 年、60 年设计硬件来运行 Fortran、运行 C 和运行 SPECint 基准测试的经验。因此,硬件能快速运行这类操作也就不足为奇了。

我们进行了测量。结果相当不错。这是针对大堆的基准测试套件,应该效果不错。

接着我们讨论了性能基准测试的情况如何?不太好,有一些异常值。但现在我们将写屏障从总是在 mutator 中开启改为了作为 GC 周期的一部分运行。现在,关于是否执行分代 GC 的决定被推迟到了 GC 周期开始时。我们在那方面有更多控制,因为我们已经将卡片工作本地化了。现在我们有了工具,可以将其交给 Pacer,它能够很好地动态中止那些表现不佳且无法从分代 GC 中受益的程序。但这样做在未来会成功吗?我们必须了解或者至少思考未来的硬件会是什么样子。

未来的内存是什么样子的?

我们来看看这张图。这是典型的摩尔定律图。Y 轴是对数刻度,显示单个芯片中的晶体管数量。X 轴是 1971 年到 2016 年的年份。我要指出,这些年份是某人在某处预测摩尔定律已死的年份。
丹纳德定标(Dennard scaling)在大约十年前结束了频率的改进。新的工艺需要更长的爬坡时间。所以现在不是 2 年,而是 4 年或更长。因此很明显,我们正在进入摩尔定律放缓的时代。
我们只看红色圆圈中的芯片。这些是维持摩尔定律表现最好的芯片。
这些芯片的逻辑越来越简单,并且多次复制。大量相同的核心、多个内存控制器和缓存、GPU、TPU 等等。
随着我们不断简化和增加复制,我们最终会渐近地得到几根导线、一个晶体管和一个电容器。换句话说,就是一个 DRAM 存储单元。
换句话说,我们认为将内存加倍比将核心加倍更有价值。
原始图表位于 www.kurzweilai.net/ask-ray-the-future-of-moores-law。

我们看看另一张专注于 DRAM 的图。这些数字来自 CMU 最近的一篇博士论文。如果我们看这张图,会发现摩尔定律是蓝线。红线是容量,它似乎也在遵循摩尔定律。很奇怪的是,我看到过一张追溯到 1939 年使用磁鼓内存的图,容量和摩尔定律当时也在一同前进,所以这张图已经存在很长时间了,肯定比这个房间里的任何人都活得久。
如果我们将这张图与 CPU 频率或各种“摩尔定律已死”的图表进行比较,我们会得出结论,内存,或者至少是芯片容量,将比 CPU 更长时间地遵循摩尔定律。带宽(黄线)不仅与内存频率有关,还与芯片引脚的数量有关,所以它没有跟上得那么好,但表现也不差。
延迟(绿线)的表现非常差,尽管我要指出的是,顺序访问的延迟比随机访问的延迟要好。
(数据来自“Understanding and Improving the Latency of DRAM-Based Memory Systems Submitted in partial fulfillment of the requirements for the degree of Doctor of Philosophy in Electrical and Computer Engineering Kevin K. Chang M.S., Electrical & Computer Engineering, Carnegie Mellon University B.S., Electrical & Computer Engineering, Carnegie Mellon University Carnegie Mellon University Pittsburgh, PA May, 2017”。参见 Kevin K. Chang 的论文。引言中的原始图表格式不易于我绘制摩尔定律线,因此我更改了 X 轴使其更均匀。)

我们来看看实际情况。这是实际的 DRAM 定价,从 2005 年到 2016 年总体呈下降趋势。我之所以选择 2005 年,是因为大约在那个时候,丹纳德定标结束了,随之结束的还有频率的改进。
如果你看红色圆圈部分,这基本上是我们为降低 Go 的 GC 延迟而努力的时间段,我们会看到头几年价格表现良好。最近就不太好了,因为需求超过了供应,导致过去两年价格上涨。当然,晶体管并没有变大,在某些情况下芯片容量反而增加了,所以这是市场力量驱动的。RAMBUS 和其他芯片制造商表示,未来我们将在 2019-2020 年左右看到下一代工艺的缩小。
我不会对内存行业的全球市场力量进行过多猜测,只想指出定价是周期性的,并且从长远来看,供应趋向于满足需求。
从长远来看,我们相信内存价格的下降速度将远快于 CPU 价格。
(来源 https://hblok.net/blog/ 和 https://hblok.net/storage_data/storage_memory_prices_2005-2017-12.png)

我们看看另一条线。哎呀,要是我们在这条线上就好了。这是 SSD 的线。它在保持低价方面做得更好。这些芯片的材料物理比 DRAM 要复杂得多。逻辑更复杂,每个单元不是一个晶体管,而是大约六个左右。
展望未来,在 DRAM 和 SSD 之间有一条线,NVRAM(例如英特尔的 3D XPoint 和相变存储器 (PCM))将占据一席之地。在未来十年内,这种类型内存的可用性很可能会变得更加主流,这只会加强“增加内存是为服务器增加价值的廉价方式”这一观点。
更重要的是,我们可以期待看到 DRAM 的其他竞争性替代品。我不会假装知道五年或十年后哪种会更受欢迎,但竞争将非常激烈,堆内存将更接近此处突出显示的蓝色 SSD 线。
所有这些都强化了我们避免使用常开屏障而倾向于增加内存的决定。

那么这一切对 Go 未来发展意味着什么?

我们打算让运行时更灵活、更健壮,因为我们正在审视用户反馈的边缘案例。希望收紧调度器,获得更好的确定性和公平性,但我们不想牺牲任何性能。
我们也不打算增加 GC API 表面。我们已经快十年了,我们有两个旋钮(参数),感觉差不多够用了。目前还没有一个应用程序重要到需要我们为此添加新的标志(flag)。
我们还将研究如何改进我们已经相当不错的逃逸分析,并优化 Go 的值导向编程。不仅在编程中,还在我们提供给用户的工具中。
在算法层面,我们将专注于设计空间中那些最小化屏障使用的部分,尤其是那些总是开启的屏障。
最后,也是最重要的一点,我们希望能够顺应摩尔定律偏向内存而非 CPU 的趋势,未来五年肯定如此,未来十年也有望如此。
就是这样。谢谢大家。

附:Go 团队正在招聘工程师,协助开发和维护 Go 运行时和编译器工具链。
感兴趣?来看看我们的 招聘职位。
下一篇文章:使用 Go Cloud 进行可移植的云编程
上一篇文章:更新 Go 行为准则
博客索引