INTRODUCTION
主持人:徐拯(ARM 中国 - 软件工程总监)
嘉宾:Ian Lance Taylor(Go 语言维护者,GCC 总维护者、gccgo 作者,gold 链接器作者,Google - 首席工程师)
其他参与者:史斌、张芳、方水明
翻译:欧长坤
校对:曹春晖、李保坤
译者的话
对部分对话的内容进行了适当的扩充。某些对话内容虽然在对话双方看来非常明确,但读者可能需要对语境和问题背景有一定的了解。为了增加可读性,对谈话的内容进行了少量的扩充,增加了适当的背景知识和参考资料。
对部分对话进行了精简。将部分对话中的短句和重复性问题进行了合并,形成了相对紧凑的对话。
针对内容进行了意译及二次创作。部分对话内容的文本表达的含义存在似乎信息的缺失,这部分在翻译时做了一定程度的猜测;同时很多语句直译会比较尴尬,大部分内容都做了一定程度上的意译。
可能存在不符合习惯的术语翻译。某些技术术语比较新,译者不确定是否有明确的标准翻译,可能会存在不符合习惯的术语翻译。比方说 NUMA-aware 被翻译成了 NUMA 感知等等。。。
目标读者
本次的对话包含一些我们认为重要但很容易被遗漏的信息。在阅读对话之前,读者需要先了解以下几点内容:
Google 已经为许多想法做过了对应的实验。这些想法和实验可以为读者提供参考。如果读者在某些特定的应用场景遇到 Go 的问题并希望能够改进,可以先了解一下这些实验,避免浪费额外精力。
讨论中包含了一些 Go 语言的设计决策,它可以帮助读者理解 Go 语言的哲学进而写出更好的代码。
我们同样讨论到了一些现有的挑战,这也能帮助读者提前理解来自上级负责人的一些不合理的 KPI 指标。
最吸引人的特性
徐拯:
首先请允许我介绍一下我们的嘉宾:Ian Lance Taylor。他是 Google 的一名首席工程师和最活跃的 Go 语言维护者之一,同时也是最近广泛讨论的泛型特性的原始想法和设计的作者之一。这次的讨论我们提前在线下收集到了一些问题,在我们讨论从现场实时收集到的问题之前,不妨从这些预先收集的问题出发。
让我们先一个相对个人的问题开始。我们都知道,Ian 您也是 GCC 的维护者之一。现在,您把兴趣转移到了 Go 语言上。那么 Go 语言中最吸引你的部分是什么?
Ian:
首先,我也同样非常感谢能收到你们的邀请。Go 语言中最吸引我的部分是它对多核编程的支持。在 Go 语言里,我们可以很容易地启动一个并发执行的代码段,称为 Goroutine。这些 Goroutine 可以彼此安全地进行通信。程序能够以便捷的方式利用现代多核处理器的力量。Goroutine 还能与通道、切片、信号相结合,让语言的用户获得强大的多核编程范式, 这些特性可以安全且正确地被使用。这就是我对 Go 语言最感兴趣的地方。
分代 GC
徐拯:
我们来看看 Go 语言在面对真实的商业业务系统时发现的一些挑战,这些问题可能有些棘手并都和 GC 有关。我们知道目前的 Go 语言中的 GC 是一个无分代的实现,即每个 GC 周期, 运行时都会无差别的扫描所有分配过的对象,并回收那些不再需要的对象。我们观察到在一些人工智能预测系统中, 通常需要加载大量的模型权重数据,而正好这类数据天然的长期不会被回收, 更没有反复扫描的必要。一个由 GC 导致的问题就是当堆内存中存在的对象非常多时, 对应用本身的影响可能会很大。Go 团队是否考虑在未来的某个时间点引入分代式 GC 呢?
Ian:
是的,没有分代式的 GC 可能会给某些场景下的 Go 程序带来实际问题。我们的运行时团队正在研究这个问题, 并试图了解是否有办法实现一个分代垃圾收集器。现在,如你所说,GC 的每个周期都会扫描所有的内存, 扫描有 CPU 上的开销,但这些代价实际上被浪费了。在分代式 GC 的实现下,运行时只需扫描最近分配的内存, 偶尔才扫描所有的内存。由于分代 GC 涉及到从一个世代到另一个世代的内存移动,包含了潜在的成本。所以我们可能会从中找到一个平衡,并尝试建立一种机制,在合适的周期中,只关注新分配的对象, 从而达到缩短 GC 周期的目的。也许最终不会做一个完整的分代式 GC,我们的运行时团队正在研究这个问题, 并正在努力了解是否有办法解决它,但是目前还没有任何计划来宣布这个问题何时能得到解决。
徐拯:
您提到说也许我们不会把对象从一个世代复制到另一代,有什么具体的考虑吗?
Ian:
避免复制的主要原因是:在没有读屏障的情况下,运行时无法保证内存复制后程序行为的正确性。打开读屏障意味着在垃圾回收的一个周期内,我们必须对每一次内存的读行为进行检测, 才能在指针指向的内存移动时进行追踪,这对性能的影响是巨大的。可惜到目前为止, 我们还没有找到一个不开启读屏障的方法。如果能找到一种低成本的方法来实现读屏障, 那么分代 GC 就可能会成为 Go 语言的一部分,但目前看来似乎不太可能。所以,与其浪费(精力研究怎么做到这一件似乎不太可能的事情),不妨直接扫描所有的内存。
徐拯:
不仅是读屏障,而且写屏障也会对性能产生影响。开启读屏障的成本为什么会这么高?因为相比写行为而言读行为更多吗?
Ian:
是的,写屏障有一定成本,但读屏障的成本更高。从实践来看,读行为确实远大于写行为, 对缓存的影响更是非常糟糕。所以引入读屏障是无法接受的,我们已经做过了一些尝试, 并且进行了大量的测试,对整体性能的影响我们无法接受。
译者注:几年前年 Go 团队已经探索过 Request-oriented collector (ROC), 最终抛弃了这一方案 的原因正式因为读屏障的成本太高,对运行时的性能的损耗相当大,参见 Rick Hudson 在 ISMM 会议上的演讲稿。
徐拯:
那么您有没有为这些应用场景下使 GC 的行为变得更好的一些建议呢?
Ian:
这个问题很好,但我个人并没有任何建议。如果有人有办法解决这个问题会非常好。可惜我目前没有什么解决它的好想法。
徐拯:
看来这确实是一个非常棘手的问题。如果很容易得到答案,人们应该已经解决了这个问题,不会在这里提出这个问题。
NUMA 和调度的可扩展性
徐拯:
让我们来看一个关于调度扩展性的问题。您刚才已经提到了。Go 语言对您而言最吸引人的特点是 Goroutine, 我们可以很容易地创建很多并行运行的 Goroutine。有些公司不仅将 Go 语言用于云原生和微服务。某些情况下,他们希望应用程序能在尽可能多的核心上运行,比如某些数据库的实现。但问题在于, 这可能会引发一些 NUMA 节点之间的流量,进而对性能造成很大的影响。看起来 Go 语言的 GC 和调度器均假设了底层的 CPU 拓扑结构是均匀访存的架构, 而不是非均匀结构的 NUMA。那么,Go 团队是否考虑过运行时在 NUMA 系统上的改进呢?
Ian:
是的,我们有考虑过。这确实是一个问题,而且随着处理器数量的增加以后带来的问题会越来越多, 我们也还是没有任何真正值得去做的想法。其实我们很早就意识到了这个问题,并试图了解我们到底能做些什么。正如你所说的,需要一个 NUMA 感知的垃圾回收器和一个 NUMA 感知的 Goroutine 调度器。这就要求有一种方法来建立线程与 Goroutine,以及处理器与线程相关的联系, 同时还需要为每个线程或处理器提供独立的内存分配器。我们正在努力了解如何使其尽可能地自动化, 因此需要以一种更有效的方式将其纳入运行时系统。从 Go 语言的哲学来说,我们希望为语言的用户屏蔽掉这些细节, 即人们不需要操心 Goroutine 都被调度到了哪个核心上,所以最终让调度器能够自动的将 Goroutine 和所需的内存进行结合并调度是(我希望的)最终目标。
译者注:Go 团队早在 2014 年就已经考虑加入 NUMA 的支持,但从讨论中可以看到,支持 NUMA 不仅仅只是调度器层面的工作,同时还涉及内存分配器和垃圾回收器的配合,参见 NUMA-aware scheduler for Go 设计文档。
徐拯:
Go 语言在中国的互联网公司中有广泛的应用,并且被使用到了各种不同的领域。据我们观察,在美国 Go 语言似乎只被应用于云原生和微服务。在美国是否也有除云原生和微服务以外的其它使用场景么?
Ian:
你说得很对,我们的用户里最多的使用场景就是云原生系统,但也有其它类型的应用,在美国也是一样的。除了常规的,比如在 Go 的应用场景中存在大量的服务器应用以外,Go 还被作为胶水语言来编写脚本以连接不同的系统。当然也有很多微服务,我们在美国也看到了很多其它领域的需求,不仅仅来自外部,Google 内部也有。
徐拯:
所以我们并不孤单 :)
Ian:
在 Google 内部也有这些关于 NUMA 的忧虑。
并发库和并发数据结构
徐拯:
我们看到 Go 并没有像 C++ 和 Java 那样提供并发库。Go 语言只是通过 「Don't communicate by sharing memory; share memory by communicating」 这样的口号来鼓励人们使用通道,但不是每个人能很好的理解这一点。除此之外, 某些场景下使用并发数据结构比通过通道传递数据更方便。对此您有什么建议呢?我们是应该总是坚持使用通道吗?或者我们应该考虑引入和实现并发库?Go 在未来是否会将并发库作为语言功能的一部分来支持呢?
Ian:
interface{}
原子操作的内存顺序
徐拯:
您提到了并发安全,我们来看一个相关但相对底层一些的问题。现在,当我们实现并发数据结构时, 可能会需要执行一些原子操作来获得更好的性能。但目前为止,Go 语言虽然有一份关于内存顺序的文档, 但并没有真正意义上严格的定义语言中并发原子操作下的内存顺序。根据 Russ Cox 曾经在 golang-dev 讨论组中的公开言论,Go 的原子操作总是期望按照顺序一致的语义执行。但我们知道, 顺序一致性这一假设对于那些想要实现更快速并发的数据结构,是否太强了?我们会不会在未来引入一些更弱的内存顺序?我们会不会像其他一些语言,比如 C/C++,为原子操作提供更清晰且精确的内存顺序定义呢?
译者注:Russ Cox 关于使用顺序一致语义言论来自在场参与者张芳在 golang-dev 中的一个讨论, 参见这里。
Ian:
我们会将通过 Go 语言的内存模型为原子操作提供一个明确的定义,但目前我们还没有做到这一点。这是我们的问题。内存模型的定义可能会涉及到顺序一致性,因为这是最容易理解的模型。当然你可以 用较弱的内存模型更有效地编写某些操作,在某些情况下这是可取的。但我不认为我们会将它们纳入 sync/atomic 标准库的实现中, 因为它很难正确使用。Intel 处理器上的内存模型比 ARM 处理器上的内存模型强得多。如果不使用顺序一致性,很容易出现程序在英特尔处理器上正确运行, 但在设计为弱序的 ARM 处理器上却不能正确运行的情况。也许可以开发一些检查器之类的工具(来避免出现这种程序代码), 但我们目前并没有这样的工具。所以,现在我们对核心库使用顺序一致性来进行描述和沟通, 但这并不意味着我们无法且不能在真正有区别的情况下编写适当的汇编代码。事实上,我们已经在运行时中使用了 Load-Aquire 和 Store-Release 内存语义,以优化标准库的实现。
译者注:原子操作的内存模型只是 Go 语言内存模型的一个部分。Go 语言的内存模型还涉及到 诸如通道这类语言级的并发原语所支持的内存模型的相关定义,参见现有的内存模型 的文档。
泛型
徐拯:
我们还有一个关于泛型实现的问题。到目前为止,我们还不能导出泛型类型或函数。我们想了解一下未来实施方案的细节。我们将如何在不同的包上共享泛型结构进而更易于复用?
Ian:
我们有一些实验性的泛型实现,这些对于在不同包中的共享来说效果并不好。完整的泛型实现, 预计会在 1.18 版本中发布。届时将能够从一个包中导出泛型类型和函数,并在另一个包中使用。这应该是一种自然的方式。至于具体的实现方式,因为这些泛型类型只是导出数据的一部分, 就像任何其他函数的导出类型一样。导出数据会描述,这是一个泛型类型,它有这些类型参数和这些约束条件, 然后在数据中索引,与其他类型类似。当查询这个这个名称时,编译器会在导出数据中查找, 并得到其定义。然后便可以使用泛型所定义的函数,和其他导出的类型一样。而对于函数自身而言,我们可以期望函数使用一个字典,这个字典是一个描述满足参数的真实类型的结构。所以,导出的数据将定义一个函数和它的类型参数,从而表示它所需的字典。当对编译后的函数进行调用时候,编译器可以将这个构造的字典传入。于是真实类型的参数将传递到函数中。当然,如果函数很小,整个函数的主体还可以在调用方直接对其进行内联。所以,整体的实现方式与我们今天导出数据的方式没有什么不同,只是导出的数据将被扩展到描述类型参数和约束条件, 并被扩展到如何调用一个泛型函数。
徐拯:
根据我们对 C++ 的经验,模板是在编译时进行特化的。在调用的模板会被转化为实际的代码。这与我们将在 Go 语言中实现的方式类似吗?我们会在产生不同的调用时拿到不同的特化实例吗?
Ian:
一般来说,不会。Go 的实现不是这样的。有些函数,我们可能决定以这种方式工作。以这种方式工作的函数,将是一个非常小的函数,因为这样的话,对其进行内联的意义更大。亦或者一个需要非常有限的参数的函数,比如说一个只对整数类型工作的函数,我们可能会说, 为整数类型单独编译它,从而产生不同的函数特化。但一般来说,我们不沿用 C++ 的方法。我们要对一个泛型函数进行一次编译,它要接受一个字典。然后在调用方建立字典, 并以可用的方式传递字典和所有参数。C++ 的方法没有什么问题,但导致了非常大的编译时间开销。在一个庞大的程序系统中,就会得到很多重复的模板实例,都是经过编译的。当进行链接时, 链接器会把所有重复的东西抛弃掉。这也是为什么 C++ 的方式能够得到更快的执行效率。我们为了保证编译效率不受影响(也是 Rob Pike 创立 Go 语言的初衷),我们选择对每个泛型只编译一次。另外,这种方式的另一个好处是我们也不必在最后处理筛选重复的特化。总的来说,我们希望保证 Go 语言程序编译效率的前提下使用泛型,这对 Go 来说非常重要。目前我们认为这样的系统是存在的,并且我们可以做到这一点。
有关泛型的最终实现方式,Go 团队已经发布过相关的文档,感兴趣的读者可以参考基于字典的泛型实现。
徐拯:
那么看来 Go 的泛型结合了 C++ 模板的设计和 Java 模板的实现方式。
Ian:
在某种程度上,是的。事实是 Go 语言目前的泛型与 Ada 模板最为相似。可惜现在使用 Ada 的工程师并不多。另外这和 Rust 的工作方式区别其实也不大。虽然我不知道 Rust 的编译模型是什么, 但与 Rust 肯定是有一些相似之处的。对于泛型的引入,确实是对语言本身的一个大改动, 除此之外它对整个构建系统的改动也非常大。我们会编写更多的文档对其进行加以解释。无论怎样, 随着时间的推移而学习,我们都将会从中学到相当多关于如何才能保持快速执行时间和构建、 编译时间的最佳编译方法的经验,每个新的版本都会有所改进。
未来语言特性的演变
徐拯:
在 1.18 的第一版泛型推出后,关于语言特性、标准库等方面的计划还有哪些?
Ian:
其实我们并没有相对正式的未来计划。一个非官方的计划可能是在 1.18 版本之后, 会有一到两个包在标准库中使用泛型。随着我们对泛型知识的了解, 我们希望了解用户们想如何使用泛型,以及已经给出的泛型效果如何。我们将据此,在标准库中添加更多使用泛型的包。从而能看到人们如何使用, 以及怎样才能令其应用到更多的场景去。一旦标准库中提供了一些泛型的实现, 人们就会用它来创建自己的泛型库并分享出来,就像从其他社区开源出来的包一样。而我们同样也会从中吸取关于如何更好的使用泛型的经验。
GPU 交互
徐拯:
目前当我们需要和 GPU 进行交互时总是需要使用 cgo,未来是否有任何让 Go 原生支持 GPU 的计划呢?
Ian:我很想看到这一点,不过我不知道是否有人正在做这件事,至少 Go 团队没有在做这方面的工作。如果有人希望这么做,那将是一件伟大的事情。但我觉得这很难,因为很多 GPU 基础设施相关技术都是专利化的。所以,如果真的能做到,我会非常乐意看到它们的出现。
ASAN 和 MTE
张芳:
ARM 引入了一个新的架构特性,叫做 MTE。我们相信它对 C/C++ 应该是相当有用的。但我们不知道它对那些已经提供某种程度的内存安全的语言有多大作用。由于 MTE 提供了与 ASAN 类似的功能, 我们想用 ASAN 来了解它的作用。我已经为 Go 语言提交了提案、展开了一些讨论, 还提交了可能的实现方式,请问您能帮助审查这个 CL 吗?如果它能被合并,我们可以开始在一些实际应用中使用它,看看它是否能发现任何问题。同时我们也想知道您对 ASAN 或 MTE 有什么感觉呢?它是否有用?还是没那么有用?
Ian:
我读了你的提案。我会去审查一下 ASAN 的代码更改。正如之前所讨论的, 我认为它对纯粹的 Go 应用用处不大。Go 语言已经通过其语言定义提供了内存安全。但我想 ASAN 对 Go 和 C 之间的交互应该是有用的,但我对 MTE 没有任何了解。
SIMD 支持
方水明:
我们是否有计划在 Go 中更好地支持 SIMD 呢?到目前为止, 所有的 SIMD 指令都需要手工写成汇编,我们会考虑加入自动矢量化的支持吗?
Ian:
我们的编译器中还有很多优化,比如循环优化。如果没有特别强烈的需求,我们需要先做这些基本的优化。
float16 支持
史斌:
是否有计划支持 float16 类型?16 位的浮点数是在 IEEE 中定义的, 虽然但在 X86 架构下缺少相关指令的支持,但在 ARM 和 RISCV 中都有原生的标量指令支持。
Ian:
我不确定谁在做这件事,但添加它应该是一件很容易的事情。
社区合作
徐拯:
我们的一些 Go 贡献者有 +2 和合并代码的权限。但我们对如何使用这些权利并不十分清楚。有时,一些更改可能会搁置很长时间。如果这些代码更改是合理的,我们是否可以在没有来自谷歌 工程师代码审查的评论下合并这些更改呢?使用批准权利的最佳实践应该是什么?
Ian:
你当然可以直接提交。我们在给贡献者提交代码权限的原因正是因为信任。Go 是在社区内开发的。我们需要不同贡献者的努力。只要你对代码的质量有信心,你就可以提交更改。
徐拯:
但通常我们也会希望代码被来自谷歌的工程师做一次最终的审批。我们觉得如果 Go 的贡献者直接提交这些代码,也隐含着需要对代码的质量承担更多的责任。
Ian:
承担更多的责任是自然是好事。我们也希望与社区中的不同人分担责任。不过如果代码最终被提交到了代码库中,无论如何,这些代码最终总会有机会被一些谷歌的工程师进行审查的。