Go 1.18 已经发布,备受期待的泛型实现首次投入生产环境使用。泛型是整个 Go 社区中备受争议并常被请求的功能之一。

泛型可能导致 Go 代码更慢

Go 1.18 的发布标志着与之伴随的备受期待的泛型实现终于具备了生产环境的使用能力。围绕泛型的争论在 Go 社区中一直颇为激烈。一方面,批评者担忧引入泛型会增加代码的复杂性。他们害怕 Go 不可避免地会演变成一个啰嗦的、充斥”泛型工厂”(Generic Factories)的企业级”微型 Java”,或者更糟糕的是,退化成类似 Haskell 的脚本语言,使用 Monad 替代常规的条件判断。公平地讲,这些恐惧可能有些夸大其词。另一方面,泛型的拥护者认为,要在大规模应用中实现干净且可复用的代码,泛型是一个关键特性。

本博文没有站队也没有建议何时何地在 Go 中引入泛型,而是讨论泛型的争议中的第三个方面:系统工程师的视角。他们对泛型本身并不兴奋,但他们对单态化(monomorphization)及其性能影响充满期待。然而,令人失望的是,这部分期待在 Go 1.18 的泛型实现中尚未得到充分满足。


Go 1.18 中的泛型实现

实现参数化多态(即我们通常所说的“泛型”)在编程语言中有多种方法。为了更好地理解 Go 1.18 中的解决方案,我们在此简要讨论这一问题空间。由于这是关于系统工程的博文,我们将使理论探讨简洁易懂,并用“事物”(things)来代替术语。

假设我们想创建一个多态函数,即一个不区分类型操作不同事物的函数。粗略地说,可以通过两种方式实现:

第一种方式:使所有事物看起来和行为方式都相同
这种方法称为“装箱”(boxing),通常需要将事物分配到堆上并通过指针将它们传给函数。由于所有事物都具有相同的结构(指针类型),我们只需知道这些事物的方法所在的位置。因此,传递给函数的事物指针通常会携带一个函数指针表,这被称为虚方法表(virtual method table),简称 vtable。这种方法的典型实现包括:Go 的接口、Rust 的动态 traits(dyn Traits)以及 C++ 的虚类(virtual classes)。这些方法在实践中很容易使用,但它们受限于表达性并且具有运行时开销。

第二种方式:为函数需要操作的每种独特事物创建单独的函数拷贝
这种方法称为“单态化”(monomorphization),尽管听起来很吓人,其实现实际上相对直接。当使用某一类型调用一个泛型函数时,编译器会为该类型创建一个新的函数实例,替换掉泛型占位符类型,并对新函数进行编译。这是实现多态最简单的方法之一(虽然在使用时它有时会变得异常复杂),但它对编译器来说是代价高昂的。它最大的优势在于生成的代码性能大幅提升。例如,取消虚拟化函数调用和虚表的使用,甚至可以实现代码内联(inline),从而带来额外的优化。

单态化在系统语言(如 C++、D 和 Rust)中被广泛采用,用于实现泛型。原因很简单,基于更长的编译时间换取显著提升的代码性能。全面的单态化被认为是具有零运行时开销并可能拥有负性能开销的一种多态形式,它使泛型代码运行得更快。

所以,作为一个优化大型 Go 应用中性能的开发者,我承认自己对 Go 泛型本身并不是特别期待,而是期待通过单态化让 Go 编译器进行之前无法完成的优化。然而,这就是失望的起点:Go 1.18 的泛型实现并未完全采用单态化

当前实现的技术细节

Go 1.18 的泛型实现基于一种称为“垃圾收集形状(GCShape)的模版化与字典”的部分单态化技术。简而言之,完整单态化基于函数参数生成大量代码,而当前采用的部分单态化技术通过构造较广泛的参数“形状”(Shape)来减少生成代码的数量。Go 使用的形状主要包括具体类型和指针类型的分类,而指针类型形状导致性能受限——它们共享一个通用的形状,无论指向何种类型。因此,方法调用无法被去虚拟化(de-virtualized),优化机会减少。

泛型函数的实际运行需要传递“字典”,字典包含参数的元信息,如与接口类型的转换、方法调用地址等。这种基于字典的设计是一种折衷:虽然减少了代码量,但带来了两层间接调用以及额外的运行时开销。


性能影响的案例分析

通过上述讨论,得出以下几点具体结论:

  • 当一个函数的调用本质上寄托于接口时,转换为泛型并不会提升性能,反而会使函数运行更慢。传递接口类型到泛型函数会导致间接方法调用,这种设计使优化变得困难。
  • 尽管使用泛型统一 []byte 与字符串的 API 具有一定吸引力,但从性能角度出发,Go 当前版本的泛型更适合那些功能明确(操作固定参数类型),且能避免动态接口使用的场景。
  • 尽管泛型可能在某些情况下降低性能,但结合新的泛型设计和参数化类型方法,Go 编译器已展示出某些领域的优化潜力。如果未来版本能采用更激进的单态化策略,其优化能力将更加可观。

总结建议

对于 Go 泛型和性能优化,以下是一些具体的 DOs 与 DON’Ts 建议:

DOs:

  1. 在去重字符串和 []byte 的处理函数时,可使用其中的 ByteSeq 泛型约束以减少代码重复,同时保证性能不受影响。
  2. 在需要定义和操作数据结构时,优先考虑使用泛型。泛型数据结构较以前基于 interface{} 的实现更加类型安全且性能更优。
  3. 可对函数式辅助工具(如迭代器)使用泛型进行参数化,简单场景可能触发内联优化。

DON’Ts:

  1. 不要使用泛型来去虚拟化或内联方法调用。这种优化目前不可行。
  2. 不要将接口类型传递给泛型函数。接口调用在泛型中会引入额外的开销,应该避免。
  3. 不要将现有基于接口的 API 重写为泛型,除非针对性场景性能表现数据证明有效。
  4. 不要因当前泛型性能局限而失望。Go 泛型的初步实现虽然带来了运行时开销,但并不局限于未来更完善的单态化实现。

Go 1.18 的泛型为社区提供了更多灵活性,但它的实现选择,例如基于字典的解决方案,牺牲了一些性能潜力。我们期待未来 Go 编译器能更充分地利用单态化来提供更强大的优化能力,同时在编译时间和运行时性能之间实现更好的平衡。



泛型可能会让你的 Go 代码更慢插图

关注公众号:程序新视界,一个让你软实力、硬技术同步提升的平台

除非注明,否则均为程序新视界原创文章,转载必须以链接形式标明本文链接

本文链接:https://www.choupangxia.com/2025/05/23/generics-can-make-your-go-code-slower/