Jason Pan

为什么要升级编译器

潘忠显 / 2024-08-17


本文借一个 Go 语言特性的例子,引出为什么需要升级编译器,介绍一下升级带来的好处,以及可能面临的挑战。

在《Software Engineering at Google》书中,在介绍「规模与效率」的时候,花了不小的篇幅介绍了 2006 年 Google 在编译器升级方面做过的工作,尤其是在五年没有更新过编译器带来了若干的挑战。其中,最后一句话是:

Stagnation is an option, but often not a wise one.

停滞不前是一种选择,但往往不是明智的选择。

一、引子

如果你是 Go/C++/Java 的开发者,可以来看看下边的一段 Go 代码。思考一下,这段代码给变量 pointers 赋了什么值?

nums := []int{1, 2, 3}
var pointers []*int

for _, num := range nums {  // <== 
    pointers = append(pointers, &num)
}

你可能会说「将 nums 中的每个变量地址存储到 pointers 的对应位置上」。真的是这样吗?

如果你有 Go 1.22 环境,构建出来的结果打印就是:

1 2 3

如果你使用 Go 1.22 之前的版本(比如 Go 1.19),上边代码运行之后打印 pointers 中的内容,会神奇发现,结果是:

3 3 3

在 1.22 之前,上边的 for 循环代码,总共就定义了一个变量 num,在整个循环周期中使用。可以理解为:

var num int
num = nums[0]
pointers[0] = &num
num = nums[1]
pointers[1] = &num
num = nums[2]
pointers[2] = &num

这样就很好理解,为什么会打印出 3 3 3 了:因为三个位置存的都是同一个变量的地址,而地址指向的变量在最后一次循环中,被赋值为 3

为了达到我们【直观预期】,我们有两种方式来修改上边的代码,来得到预期的结果:

【1-使用下标】直接获得数组中变量的地址

for i, _ := range nums {
    pointers = append(pointers, &nums[i])
}

【2-使用新变量】在 for 循环中定义新变量,来复制循环定义的变量内容,然后记录这个新变量的地址

for _, num := range nums {
    num := num
    pointers = append(pointers, &num)
}

这个「特性」或者说是「缺陷」给我们带来了很多不方便,尤其是一不小心忘记着个「特性」,就会造成 Bug。

而 Go 1.22 中已经做了调整:会在每次循环中,都创建一个新的变量:

Previously, the variables declared by a “for” loop were created once and updated by each iteration. In Go 1.22, each iteration of the loop creates new variables, to avoid accidental sharing bugs.

以此例子为引子,我们来聊聊为什么要升级编译器。

二、版本号的规范

在之前的文章中,有很多次提到过**语义化版本**。这是对版本号的一种规范,所谓“语义化”就是一种容易被人类理解的表达方式。

如果大家都遵守这个规范,就很容易理解软件的版本的发展,不同版本之前的先后顺序、是否兼容、是否稳定等信息。

semver

语义化版本 格式,简单的说就有三个字段:主版本号.次版本号.修订号

还可以有先行版本号,在正式版本发布之前可以加 - 的后缀,其版本发布先后顺序应该如下:

1.0.0-alpha 
  < 1.0.0-alpha.1
  < 1.0.0-alpha.beta
  < 1.0.0-beta
  < 1.0.0
  < 2.0.0
  < 2.1.0
  < 2.1.1

虽然很多软件的版本也是这样三段的版本号,但不是严格遵守以上规则,主要是比较难遵循不向后兼容的更改时,都要增加主版本号 这条规则。

以 Python 版本为例,以下是两个次版本变化引发的不兼容:


还有些软件描述自己版本会略有区别,比如 Go 的版本说明中:Go 1.15 这个叫主版本,而 Go 1.6.1 这样的版本被称为次版本,但实际跟语义化版本格式中的次版本、修订版本的含义类似。

Each major Go release is supported until there are two newer major releases. For example, Go 1.5 was supported until the Go 1.7 release, and Go 1.6 was supported until the Go 1.8 release. We fix critical problems, including critical security problems, in supported releases as needed by issuing minor revisions (for example, Go 1.6.1, Go 1.6.2, and so on).

三、升级编译器的好处

1. 使用语言的新特性

2014 年刚来公司的时候,还没有现在这么方便的 CI/CD 环境和工具,也没有自己的 dev-cloud 的个人 CVM。

大家共用一台开发机,在上边进行构建,然后将二进制文件进行分发。而生产环境也不是云主机和镜像,部署环境也只有一种。

受限于此,当时开发 C++ 就只能使用特定的 GCC 版本进行构建,那个低版本的 GCC 还没支持 -std=c++11,印象最深的是很多方便的特性都不能用,比如最基本 unique_ptr 等智能指针,还得自己再蹩脚的实现一遍。

升级编译器,才能够使用到、享受到语言的新特性

每种编程语言都会经历持续的演进过程。新特性的引入通常始于一个提案,然后经过社区的广泛讨论和审查。一旦达成共识,会以标准的形式固定下来。编译器开发者或厂商就会实现这些新特性。

C++ 有 11、17、20、23 等标准,Python 有 2.7、3.11等版本以 PEP 为标准,Go 语言有1.17、1.22、1.23 等版本。

这些版本号/标准是反映了语言的语法、标准库和其他核心特性的版本。

2. 程序更高效

如果用 C/C++ 开发,我们会对 -O2 之类的优化选项印象深刻,程序的执行效率可能成倍甚至上几十倍的提升。这是在同一个编译器版本上,通过加不同选项能体现出来的。

编译器版本升级,也能往往能够在标准库算法寄存器分配循环展开/指令选择/数据流分析并行/向量化垃圾回收机制等方面作出优化,使得同样的代码,构建出来的程序有效率上的提升。

以Go 垃圾回收(Garbage Collection, GC)机制为例,其 1.5 版本开始增加了三色标记算法并发标记和清除,1.7 版本引入了并发清理,1.9 引入并发辅助清理,1.14 引入了非阻塞GC机制。这些优化显著减少了GC暂停时间,提高了内存利用率和应用程序的整体性能。

mark-and-sweep

3. 程序更安全

编译器在将高级语言转换为机器代码的过程中,可能存在多种安全隐患,包括编译器自身的漏洞、代码优化错误、未定义行为处理不当、后门和恶意代码注入、不安全的默认设置、不安全的库和依赖、错误处理不当以及编译器配置和环境的安全问题。

有些莫名其妙的问题,可能不是你程序设计的 Bug,而是编译器自身的缺陷造成的。

再以 Go 语言为例,其所有的 CVE(Common Vulnerabilities and Exposures,公共漏洞和暴露)信息可以在 https://pkg.go.dev/vuln/list 这里查询到。比如我们搜 net/http 库相关的,可以看到一些漏洞。这些漏洞发现之后,会在后续的 Go 版本中得到修复。

go-cve

4. 获得最新的支持

最着新版本的不断发布,会造成一个局面:大家使用的语言版本会五花八门。比如现在 Go 发布到 1.23 了,那么大家可能在用 1.18、1.19、1.20、1.21、1.22、1.23。

这时候如果发现有一个安全问题,要增加一个修订版本,那么编译器开发厂商会怎么操作呢?当前在用的若干次要版本上继续增加修订版本,进行修复。

在 2024-08-06 的时候,Go 语言发布两个修订版本 1.22.6 和 1.21.13,两个内容差不多,都是修复了一些小问题:

go-mirro-version

他们不会所有的版本都修复,因为这样工作量太大了,他们只会修复最近的几个次版本。比如,Go 的修订版本只维护最近的两个版本。

Each major Go release is supported until there are two newer major releases. For example, Go 1.5 was supported until the Go 1.7 release, and Go 1.6 was supported until the Go 1.8 release.

为了更清楚的展示,我这里画了个图,展示一下在 Go 1.22 发布前后,修订版本的发布情况。可以看到1.22.0 发布之后,就只会更新 1.22 和 1.21 的次版本了。

go-verion-changing

要理解以上这些版本的更新,大家可以参考 Go 的版本发布记录:https://go.dev/doc/devel/release

5. 更多的第三方库

语言和编译器的升级,往往会向下兼容。

假设有两个第三方库,一个要求的最低 Go 版本是 1.18,另外一个要求 1.20。

但相反,如果你是第三方库的提供方,你应该选择依赖最低的版本去构建,以及在 go.mod 中指定该版本。这样能让更多的使用者得到支持。如果可能的话,你应该在多个版本中测试你的库,以确保它在这些版本中都能正常工作。

四、升级带来的挑战

尽管升级编译器有诸多好处,但也不可忽视它可能带来的负担和挑战。

清楚地了解这些潜在的风险和问题,可以帮助我们评估是否真的去升级,以及确定升级之后的过程中,更好地规划和执行,确保平稳过渡。

相比于 2006 年 Google 升级编译器的那些困难,今时今日因为 CI/CD 等工具的出现和升级,很多挑战已经不复存在了。

1. 兼容性问题

兼容性问题是升级的根本性问题,其他的所有问题都是由此引申出来的。

虽然许多语言和编译器的升级尽量保持向下兼容,但并非所有的升级都能做到这一点。这可能会导致在旧版本中工作的代码在新版本中无法运行。因此,升级编译器或语言版本时,需要仔细阅读发布说明,了解可能影响到你的代码的更改。

比如上边提到的 Python 3.5 中增加关键字,那么之前使用这些关键字命名过变量的代码,肯定就不能用了。不能运行还是好的,如果恰好冲突了还能运行起来,才更恐怖。

2. 团队协作成本

学习曲线:新版本的编译器通常会引入新的语言特性工具链调试工具,开发团队需要时间学习和掌握这些新特性。

尤其是团队协作时,真不是每个人都会追求更新技术,也有同学更倾向于使用完全熟悉的技术,去实现不断迭代的功能,哪怕在开发效率、运行效率上有少许牺牲。

环境更新:如果是一个团队项目,你这里直接把 Go 从 1.18 升级到 1.22,然后 Push 到远端仓库 ,会带来什么问题呢?

无论是克服学习曲线,还是环境的更新,都会给整个团队带来时间成本

因此,团队项目如果要升级,应该在团队内部充分的沟通以及准备

3. 服务质量风险

如果你是一个第三方库或框架的提供方,或者你当前服务本身在线上存在多个版本。

更新时编译器时,会面临跟普通的 API 做不兼容更新类似的问题。可能因为你的升级,依赖你库的项目,可能因为 Go 版本的升级,而无法再使用你的最新的版本。

如果发现历史版本有Bug,你可以选择【1. 给多个次版本的增加修订版本】就会带来很多的工作量;你也可以选择【2. 只更新主线版本】就可能造成老的服务存在风险的继续运行。

4. 测试成本

升级编译器后,需要进行全面的回归测试,以确保新编译器生成的代码在功能和性能上与旧版本一致。

如果平时就有写比较清楚的测试,在升级编译器的时候,就会觉着比较放心。覆盖尽可能多的关键功能和性能指标。使用自动化测试工具,提高测试效率和覆盖率。

5. 本地构建环境

前边有提到团队协作,团队成员的开发环境如果不一致会有一些问题。

为了能跟大家保持一致,大家可以使用统一的构建镜像,但是大家更习惯使用本地环境。

现代 IDE 中都可以选择不同的 SDK 版本:

go-sdk-in-ide

各种语言也比较方便的命令行工具来切换不同的版本,比如 MacBook 上可以使用 gvm 来切换不同的 Go 版本:

go-sdk-in-term

结语

对于个人或者小团队来说,尽管升级过程中可能会遇到一些挑战和负担,但总体成本比较低。

而对语言的新特性、效率提升的掌握,对团队成员和项目的好处都非常明显。通过升级不仅有助于保持技术的先进性和竞争力,还能为团队的持续发展和创新提供坚实的基础。

大团队/大项目不会升级编译器太频繁,但如果长期维护的项目,也不可能长期不升级。隔段时间评估一下项目是否升级,从长远来看,也是值得的做的一件重要的事情。