Go 博客

向后兼容性、Go 1.21 和 Go 2

Russ Cox
2023 年 8 月 14 日

Go 1.21 包含了增强兼容性的新特性。在您停止阅读之前,我知道这听起来很无聊。但无聊可能是件好事。回想 Go 1 的早期,Go 充满活力和惊喜。每周我们都会发布新的快照版本,每个人都要掷骰子看看我们改变了什么以及他们的程序会如何崩溃。我们发布了 Go 1 及其兼容性承诺,正是为了去除这种兴奋感,让 Go 的新版本变得无聊。

无聊是件好事。无聊意味着稳定。无聊意味着您可以专注于自己的工作,而不是关注 Go 有哪些不同。本文讨论的是我们在 Go 1.21 中为保持 Go 的无聊性所做的重要工作。

Go 1 兼容性

十多年来,我们一直专注于兼容性。对于 Go 1,早在 2012 年,我们就发布了一份题为“Go 1 和 Go 程序的未来”的文档,其中明确阐述了意图

意图在于,按照 Go 1 规范编写的程序,在其规范的整个生命周期内,无需修改即可继续正确编译和运行。…… 今天可以工作的 Go 程序,即使在 Go 1 的未来版本出现时,也应继续工作。

对此有一些限定条件。首先,兼容性意味着源代码兼容性。当您更新到 Go 的新版本时,确实需要重新编译代码。其次,我们可以添加新的 API,但方式不会破坏现有代码。

文档的末尾警告说,“不可能保证未来的任何更改都不会破坏任何程序。”然后它列举了一些程序可能仍然会崩溃的原因。

例如,如果您的程序依赖于某个有问题的行为而我们修复了该问题,您的程序可能会崩溃,这是合理的。但我们非常努力地减少破坏,并保持 Go 的无聊性。到目前为止,我们主要使用了两种方法:API 检查和测试。

API 检查

关于兼容性,最清楚的一点也许是我们不能移除 API,否则使用它的程序将会崩溃。

例如,这是有人编写的一个我们不能破坏的程序

package main

import "os"

func main() {
    os.Stdout.WriteString("hello, world\n")
}

我们不能移除 os 包;我们不能移除全局变量 os.Stdout(它是 *os.File 类型);我们也不能移除 os.File 方法 WriteString。很明显,移除其中任何一个都会破坏这个程序。

可能不太清楚的是,我们根本不能改变 os.Stdout 的类型。假设我们想把它变成一个具有相同方法的接口。我们刚刚看到的那个程序不会崩溃,但这个程序会

package main

import "os"

func main() {
    greet(os.Stdout)
}

func greet(f *os.File) {
    f.WriteString(“hello, world\n”)
}

这个程序将 os.Stdout 传递给一个名为 greet 的函数,该函数需要一个 *os.File 类型的参数。因此,将 os.Stdout 更改为接口将破坏这个程序。

为了帮助我们开发 Go,我们使用一个工具,它维护一个列表,其中包含每个包的导出 API,这些列表存储在与实际包文件分开的文件中

% cat go/api/go1.21.txt
pkg bytes, func ContainsFunc([]uint8, func(int32) bool) bool #54386
pkg bytes, method (*Buffer) AvailableBuffer() []uint8 #53685
pkg bytes, method (*Buffer) Available() int #53685
pkg cmp, func Compare[$0 Ordered]($0, $0) int #59488
pkg cmp, func Less[$0 Ordered]($0, $0) bool #59488
pkg cmp, type Ordered interface {} #59488
pkg context, func AfterFunc(Context, func()) func() bool #57928
pkg context, func WithDeadlineCause(Context, time.Time, error) (Context, CancelFunc) #56661
pkg context, func WithoutCancel(Context) Context #40221
pkg context, func WithTimeoutCause(Context, time.Duration, error) (Context, CancelFunc) #56661

我们的一个标准测试会检查实际的包 API 是否与这些文件匹配。如果我们在一个包中添加了新的 API,除非将其添加到 API 文件中,否则测试会失败。如果我们更改或移除了 API,测试也会失败。这有助于我们避免错误。然而,像这样的工具只能发现特定类型的问题,即 API 的更改和移除。还有其他方法可以对 Go 进行不兼容的更改。

这就引出了我们用来保持 Go 无聊的第二种方法:测试。

测试

发现意外不兼容性的最有效方法是针对下一个 Go 版本的开发版本运行现有测试。我们持续地使用 Google 内部所有的 Go 代码来测试 Go 的开发版本。当测试通过时,我们将该提交安装为 Google 的生产环境 Go 工具链。

如果某个更改破坏了 Google 内部的测试,我们假设它也会破坏 Google 外部的测试,并寻找减少影响的方法。大多数情况下,我们会完全回滚该更改,或者找到一种方法重写它,使其不破坏任何程序。然而,有时我们断定这个更改很重要,并且是“兼容的”,尽管它确实破坏了一些程序。在这种情况下,我们仍然会努力将影响降到最低,然后在发布说明中记录潜在的问题。

这里有两个我们通过在 Google 内部测试 Go 发现的、但仍然包含在 Go 1.1 中的此类微妙兼容性问题的示例。

结构体字面量和新字段

这是一些在 Go 1 中运行良好的代码

package main

import "net"

var myAddr = &net.TCPAddr{
    net.IPv4(18, 26, 4, 9),
    80,
}

main 包声明了一个全局变量 myAddr,它是 net.TCPAddr 类型的复合字面量。在 Go 1 中,net 包将 TCPAddr 类型定义为一个包含两个字段 IPPort 的结构体。这些字段与复合字面量中的字段匹配,因此程序可以编译。

在 Go 1.1 中,程序停止编译,编译器错误提示“结构体字面量中初始化器太少”。问题在于我们在 net.TCPAddr 中添加了第三个字段 Zone,而这个程序缺少第三个字段的值。解决方法是使用带标签的字面量重写程序,这样它就可以在 Go 的两个版本中构建

var myAddr = &net.TCPAddr{
    IP:   net.IPv4(18, 26, 4, 9),
    Port: 80,
}

由于此字面量未指定 Zone 的值,它将使用零值(在这种情况下为空字符串)。

兼容性文档中明确指出了标准库结构体需要使用复合字面量的要求,并且 go vet 会报告需要标签以确保与 Go 后续版本兼容的字面量。这个问题在 Go 1.1 中非常新颖,值得在发布说明中简短提及。现在我们只会提及新增的字段。

时间精度

我们在测试 Go 1.1 时发现的第二个问题与 API 毫无关系。它与时间有关。

Go 1 发布后不久,有人指出 time.Now 返回的时间精度为微秒,但通过一些额外的代码,可以返回纳秒精度的时间。这听起来不错,对吧?更高的精度更好。所以我们做了那个更改。

这破坏了 Google 内部少量结构上类似于这个的测试

func TestSaveTime(t *testing.T) {
    t1 := time.Now()
    save(t1)
    if t2 := load(); t2 != t1 {
        t.Fatalf("load() = %v, want %v", t1, t2)
    }
}

这段代码调用 time.Now,然后通过 saveload 往返处理结果,并期望获得相同的时间。如果 saveload 使用的表示方式仅存储微秒精度,这在 Go 1 中工作良好,但在 Go 1.1 中就会失败。

为了帮助修复这类测试,我们添加了 RoundTruncate 方法来丢弃不必要的精度,并在发布说明中记录了可能的问题以及帮助解决问题的新方法。

这些例子展示了测试如何发现与 API 检查不同类型的不兼容性。当然,测试也不能完全保证兼容性,但它比单纯的 API 检查更全面。我们在测试过程中发现许多问题,经判断它们确实违反了兼容性规则,并在发布前进行了回滚。时间精度更改是一个有趣的例子,它虽然破坏了程序,但我们仍然发布了它。我们进行更改是因为提高精度是更好的,并且在函数的文档行为允许范围内。

这个例子表明,有时尽管付出了巨大的努力和关注,但改变 Go 意味着会破坏 Go 程序。严格来说,这些更改在 Go 1 文档的意义上是“兼容的”,但它们仍然会破坏程序。这些兼容性问题大多可归为三类:输出更改、输入更改和协议更改。

输出更改

输出更改发生在函数给出的输出与以前不同时,但新输出与旧输出一样正确,甚至更正确。如果现有代码被编写为只期望旧输出,它就会崩溃。我们刚刚看到了一个例子,就是 time.Now 添加了纳秒精度。

排序。另一个例子发生在 Go 1.6 中,当时我们更改了排序的实现,使其运行速度提高了约 10%。这是一个根据颜色名称长度对颜色列表进行排序的示例程序

colors := strings.Fields(
    `black white red orange yellow green blue indigo violet`)
sort.Sort(ByLen(colors))
fmt.Println(colors)

Go 1.5:  [red blue green white black yellow orange indigo violet]
Go 1.6:  [red blue white green black orange yellow indigo violet]

更改排序算法通常会改变相等元素的顺序,这里就发生了这种情况。Go 1.5 返回的顺序是 green, white, black。Go 1.6 返回的顺序是 white, green, black。

排序显然可以按其喜欢的任何顺序返回相等的结果,而这次更改使其速度提高了 10%,这很好。但期望特定输出的程序将会崩溃。这是一个很好的例子,说明为什么兼容性如此困难。我们不想破坏程序,但我们也不想被束缚于未文档化的实现细节。

压缩/flate。另一个例子是,在 Go 1.8 中,我们改进了 compress/flate,使其生成更小的输出,同时 CPU 和内存开销大致相同。这听起来像是一个双赢,但它破坏了 Google 内部一个需要可重现归档构建的项目:现在他们无法重现旧的归档文件了。他们分叉了 compress/flatecompress/gzip 来保留旧算法的副本。

我们对 Go 编译器也做了类似的事情,使用了 sort 包的一个分叉(以及其他包),这样即使使用早期版本的 Go 构建编译器,它也能生成相同的结果。

对于这类输出更改的不兼容性,最好的解决方案是编写接受任何有效输出的程序和测试,并将这类破坏作为机会来改变您的测试策略,而不仅仅是更新预期的答案。如果您确实需要可重现的输出,次优的解决方案是分叉代码以隔离开变化,但请记住,您同时也隔离了错误修复。

输入更改

输入更改发生在函数改变其接受的输入或处理输入的方式时。

ParseInt。例如,Go 1.13 添加了对大数字中使用下划线以提高可读性的支持。随着语言的改变,我们让 strconv.ParseInt 接受新语法。这个改变没有破坏 Google 内部的任何东西,但很久以后我们听到了一位外部用户,他们的代码确实被破坏了。他们的程序使用下划线分隔的数字作为数据格式。它首先尝试 ParseInt,只有在 ParseInt 失败时才回退检查下划线。当 ParseInt 不再失败时,处理下划线的代码就不再运行了。

ParseIP。再举一个例子,Go 的 net.ParseIP 遵循了早期 IP RFC 中的示例,这些示例经常显示带有前导零的十进制 IP 地址。它将 IP 地址 18.032.4.011 读取为 18.32.4.11,只是多了几个前导零。很久以后我们才发现,派生自 BSD 的 C 库将 IP 地址中的前导零解释为八进制数的开始:在那些库中,18.032.4.011 表示 18.26.4.9!

这是 Go 与世界其他地方之间的一个严重不匹配,但从一个 Go 版本到下一个版本改变前导零的含义也会是一个严重不匹配。这将是一个巨大的不兼容性。最终,我们决定在 Go 1.17 中修改 net.ParseIP,使其完全拒绝前导零。这种更严格的解析确保了当 Go 和 C 都能成功解析 IP 地址时,或者当 Go 的旧版本和新版本解析时,它们对它的含义都达成一致。

这个改变没有破坏 Google 内部的任何东西,但 Kubernetes 团队担心一些之前可以解析但在 Go 1.17 中将停止解析的已保存配置。带有前导零的地址可能应该从这些配置中移除,因为 Go 对它们的解释与几乎所有其他语言都不同,但这应该在 Kubernetes 的时间表上进行,而不是 Go 的。为了避免语义变化,Kubernetes 开始使用自己分叉的原始 net.ParseIP 副本。

应对输入更改的最佳方法是在解析值之前,首先验证您希望接受的语法来处理用户输入,但有时您需要分叉代码。

协议更改

最后一类常见的不兼容性是协议更改。协议更改是对包进行的更改,最终在程序用于与外部世界通信的协议中对外可见。正如我们在 ParseIntParseIP 中看到的,几乎任何更改都可能在某些程序中对外可见,但协议更改基本上在所有程序中都是对外可见的。

HTTP/2。协议更改的一个明显例子是 Go 1.6 添加了对 HTTP/2 的自动支持。假设一个 Go 1.5 客户端正在通过一个中间盒破坏 HTTP/2 的网络连接到支持 HTTP/2 的服务器。由于 Go 1.5 只使用 HTTP/1.1,程序运行正常。但随后更新到 Go 1.6 就会破坏程序,因为 Go 1.6 开始使用 HTTP/2,而在此上下文中,HTTP/2 无法工作。

Go 旨在默认支持现代协议,但这个例子表明,启用 HTTP/2 可能会在程序本身(以及 Go 本身)没有错误的情况下破坏程序。在这种情况下,开发者可以回到使用 Go 1.5,但这并不令人满意。相反,Go 1.6 在发布说明中记录了此更改,并使得禁用 HTTP/2 变得简单。

实际上,Go 1.6 记录了两种禁用 HTTP/2 的方法:使用包 API 显式配置 TLSNextProto 字段,或者设置 GODEBUG 环境变量

GODEBUG=http2client=0 ./myprog
GODEBUG=http2server=0 ./myprog
GODEBUG=http2client=0,http2server=0 ./myprog

正如我们稍后会看到的,Go 1.21 将此 GODEBUG 机制推广,使其成为所有潜在破坏性更改的标准。

SHA1。这里有一个更微妙的协议更改示例。现在没有人应该再使用基于 SHA1 的 HTTPS 证书了。证书颁发机构在 2015 年停止颁发,所有主流浏览器在 2017 年停止接受它们。在 2020 年初,Go 1.18 默认禁用了对它们的支持,并提供了一个 GODEBUG 设置来覆盖此更改。我们还宣布打算在 Go 1.19 中移除 GODEBUG 设置。

Kubernetes 团队让我们知道,一些安装仍在使用私有 SHA1 证书。撇开安全问题不谈,强迫这些企业升级其证书基础设施不是 Kubernetes 的职责,而且分叉 crypto/tlsnet/http 以保留 SHA1 支持将会非常痛苦。因此,我们同意将覆盖设置保留比原计划更长的时间,以便为有序过渡创造更多时间。毕竟,我们希望破坏尽可能少的程序。

Go 1.21 中扩展的 GODEBUG 支持

为了即使在我们一直在研究的这些微妙情况下也能改善向后兼容性,Go 1.21 扩展并规范化了 GODEBUG 的使用。

首先,对于任何 Go 1 兼容性允许但仍可能破坏现有程序的更改,我们都会做我们刚刚看到的所有工作来理解潜在的兼容性问题,并设计更改以尽可能保持现有程序的正常工作。对于剩余的程序,新的方法是

  1. 我们将定义一个新的 GODEBUG 设置,允许单个程序选择退出新行为。如果不可行,则可能不会添加 GODEBUG 设置,但这应该极为罕见。

  2. 为兼容性而添加的 GODEBUG 设置将至少维护两年(四个 Go 版本)。一些设置,例如 http2clienthttp2server,将维护更长时间,甚至无限期。

  3. 在可能的情况下,每个 GODEBUG 设置都有一个关联的 runtime/metrics 计数器,命名为 /godebug/non-default-behavior/<name>:events,它统计了基于该设置的非默认值,特定程序的行为改变的次数。例如,当设置 GODEBUG=http2client=0 时,/godebug/non-default-behavior/http2client:events 统计程序配置了多少个不带 HTTP/2 支持的 HTTP 传输。

  4. 程序的 GODEBUG 设置会与主包的 go.mod 文件中列出的 Go 版本相匹配。如果您的程序的 go.mod 文件中写着 go 1.20,并且您更新到 Go 1.21 工具链,Go 1.21 中任何受 GODEBUG 控制的已更改行为将保留其旧的 Go 1.20 行为,直到您将 go.mod 更改为 go 1.21

  5. 程序可以通过在 main 包中使用 //go:debug 行来更改单个 GODEBUG 设置。

  6. 所有 GODEBUG 设置都集中记录在一个中心列表中,以便于参考。

这种方法意味着每个新的 Go 版本应该是旧 Go 版本的最佳实现,即使在编译旧代码时,也能保留在后续版本中以兼容但会破坏的方式更改的行为。

例如,在 Go 1.21 中,panic(nil) 现在会导致(非 nil)运行时 panic,这样 recover 的结果现在可以可靠地报告当前 goroutine 是否正在 panic。这个新行为由 GODEBUG 设置控制,因此取决于主包的 go.mod 文件中的 go 行:如果它写着 go 1.20 或更早版本,panic(nil) 仍然被允许。如果它写着 go 1.21 或更晚版本,panic(nil) 将变成一个带有 runtime.PanicNilError 的 panic。并且可以通过在主包中添加如下一行代码来显式覆盖基于版本的默认设置

//go:debug panicnil=1

这些功能的组合意味着程序可以在更新到新的工具链时,保留它们之前使用的工具链的行为,可以根据需要对特定设置应用更精细的控制,并且可以使用生产环境监控来了解哪些作业在实践中使用了这些非默认行为。总而言之,这些应该使新工具链的推出比过去更加顺畅。

更多详情请参阅“Go、向后兼容性和 GODEBUG”。

关于 Go 2 的最新动态

在本文顶部引用的“Go 1 和 Go 程序的未来”文本中,省略号隐藏了以下限定词

在某个不确定的时间点,可能会出现一个 Go 2 规范,但在那之前,[… 所有兼容性细节 …]。

这引出了一个显而易见的问题:我们何时应该期待会破坏旧 Go 1 程序的 Go 2 规范?

答案是永远不会。Go 2,就打破过去、不再编译旧程序而言,永远不会发生。Go 2,就作为我们从 2017 年开始努力实现的 Go 1 的重大修订而言,已经发生了。

不会有一个破坏 Go 1 程序的 Go 2。相反,我们将加倍强调兼容性,这比任何可能的与过去的决裂都更有价值。事实上,我们认为优先考虑兼容性是我们在 Go 1 中做出的最重要的设计决策。

因此,您将在未来几年看到大量令人兴奋的新工作,但这些工作会以谨慎、兼容的方式完成,以便我们能让您从一个工具链升级到下一个工具链的过程尽可能无聊。

下一篇文章:Go 1.21 中的向前兼容性和工具链管理
上一篇文章:Go 1.21 发布了!
博客索引