Go 1.22 新特性前瞻
美国时间 2023 年 12 月 20 日,Go 官方宣布 Go 1.22rc1 发布[1],开启了为期 2 个多月的、常规的公测之旅,Go 1.22 预计将于 2024.2 月份正式发布!
除了在官网下载 Go 1.22rc1 版本[2]进行新特性体验之外,我们还可以通过在线的 Go Playground[3]选择“Go dev branch”来体验(相比下载安装,在线版本体验会有一些局限):
本文将和大家一起看看 Go 1.22 都会带来哪些新特性。不过由于目前为时尚早,下面列出的有些变化最终不一定能进入到 Go 1.22 的最终版本中,所以切记一切变更点要以最终 Go 1.22 版本发布时为准。
1. 语言变化
Go 1.22 的语言特性变化主要是围绕 for loop 的。
1.1 loopvar 试验特性转正
在 Go 1.21 版本[4]中,作为试验特性 loopvar 在 Go 1.22 中正式转正。如果你还不知道这个特性是啥,我们来看一下下面这个最能说明问题的示例:
我们分别用 Go 1.22rc1 和 Go 1.21.0 来运行上面这段代码:
之所以存在差异,是因为 Go 1.22 版本开始,for range 语句中声明的循环变量(比如这里的 i 和 v)不再是整个 loop 一份(loop var per loop),而是每次 iteration 都会有自己的变量(loop var per-iteration),这样在 Go 1.22 中,for range 中的 goroutine 启动的闭包函数[5]中捕获的变量是 loop var per-iteration,这样才会输出 5 个不同的索引值和对应的切片值。
那传统的 3-clause 的 for loop 呢?其中的循环变量的语义是否也发生变化了呢?我们看下面示例:
我们依然分别用 Go 1.22rc1 和 Go 1.21.0 版本运行这段代码,得到的结果如下:
从输出结果来看,3-clause 的 for 语句中声明的循环变量也变成了 loop var per-iteration 了。
在 Go 1.22 之前,go vet 工具在遇到像上面代码那样在闭包中引用循环变量的情况时会给出警告,但由于 Go 1.22 的这个语义修正,go vet 对于 Go 1.22 及以后版本(根据 go.mod 中的指示)的类似 Go 代码将不再报错。
不过就像 Russ Cox 在 spec: less error-prone loop variable scoping[6]这一 issue 中提及那样,该特性落地可能会带来不兼容问题,即对存量代码行为的破坏性改变。为此 Go 团队提供了一个名为 bisect 的工具[7],该工具可以检测出存量代码在 for loop 语义发生变更后是否会导致问题。不过该工具似乎只能与 go test 一起使用,也就是说你只能对那些被 Go 测试[8]覆盖到的 for loop 进行检测。
目前 spec: less error-prone loop variable scoping[9]这一 issue 还处于 open 状态,也没有放入 Go 1.22 milestone 中,不知道后续是否还会存在变数!
1.2 range 支持整型表达式
在 Go 1.22 版本中,for range 后面的 range 表达式除了支持传统的像数组、切片、map、channel 等表达式外,还支持放置整型表达式,比如下面这个例子:
我们知道:for range 会在执行伊始对 range 表达式做一次求值,这里对 n 求值结果为 5。按照新增的 for range 后接整型表达式的语义,对于整数值 n,for range 每次迭代值会从 0 到 n-1 按递增顺序进行。上面代码中的 for range 会从 0 迭代到 4(5-1),我们执行一下上述代码就可以印证这一点:
如果 n <= 0,则循环不运行任何迭代。
这个新语法特性,可以理解为是一种“语法糖”,是下面等价代码的“语法糖”:
不过,迭代总是从 0 开始,似乎限制了该语法糖的使用范围。
1.3 试验特性:range-over-function iterators
在 for range 支持整型表达式的时候,Go 团队也考虑了增加函数迭代器(iterator)[10],不过前者语义清晰,实现简单。后者展现形式、语义和实现都非常复杂,于是在 Go 1.22 中,函数迭代器以试验特性提供,通过 GOEXPERIMENT=rangefunc 可以体验该功能特性。
在没有函数迭代器之前,我们实现一个通用的反向迭代切片的函数可能是像这样:
下面是在 Go 1.21.0 版本中使用上面 Backward 函数的方式:
我们用 Go 1.21.0 运行一下上述示例:
在以前版本中,这种对切片、数组或 map 中进行元素迭代的情况在实际开发中非常常见,也比较模式化,但基于目前语法,使用起来非常不便。于是 Go 团队提出将它们与 for range 结合在一起的提案[11]。有了 range-over-function iterator 机制后,我们就可以像下面这样使用 Backward 泛型函数了:
相比于上面的老版本代码,这也的代码更简洁清晰了,使用 Go 1.22rc1 的运行结果也与老版本别无二致:
但代价就是要理解什么样原型的函数才能与 for range 一起使用实现函数迭代,这的确有些复杂,本文就不展开说了,有兴趣的童鞋可以先看看有关 range-over-function iterator 的 wiki[12]先行了解一下。
2. 编译器、运行时与工具链
2.1 继续增强 PGO 优化[13]
自 Go 1.20 版本引入 PGO[14](profile-guided optimization)后,PGO 这种优化技术带来的优化效果就得到了持续的提升:Go 1.20 实测性能提升仅为 1.05%;Go 1.21 版本发布[15]时,官方的数据是 2%~7%,而 Go 1.21 编译器自身在 PGO 优化过后编译速度提升约 6%。
在 Go 1.22 中,官方给出的数字则是 2%~14%,这 14%的提升想必是来自 Google 内部的某个实际案例。
2.2 inline 和 devirtualize
在 Go 1.22 中,Go 编译器可以更灵活的运用 devirtualize 和 inline 对代码进行优化了。
在面向对象的编程中,虚拟函数是一种在运行时动态确定调用的函数。当调用虚拟函数时,编译器通常会为其生成一段额外的代码,用于在运行时确定要调用的具体函数。这种动态调度的机制使得程序可以根据实际对象类型来执行相应的函数,但也带来了一定的性能开销。通过 devirtualize 优化技术,编译器会尝试在编译时确定调用的具体函数,而不是在运行时进行动态调度。这样可以避免运行时的开销,并允许编译器进行更多的优化。
对应到 Go 来说,就是在编译阶段将使用接口进行的方法调用转换为通过接口的实际类型的实例直接调用该方法。
关于内联优化,今年 Austin Clements 发起了 inline 大修项目[16],对 Go 编译器中的内联优化过程进行全面调整,目标是在 Go 1.22 中拥有更有效的、具有启发能力的内联,为后续内联的进一步增强奠定基础。该大修的成果目前以 GOEXPERIMENT=newinliner 试验特性的形式在 Go 1.22 中提供。
2.3 运行时
运行时的变化主要还是来自 GC[17]。
Go 1.22 中,运行时会将基于类型的垃圾回收的元数据放在每个堆对象附近,从而可以将 Go 程序的 CPU 性能提高 1-3%。同时,通过减少重复的元数据的优化,内存开销也将降低约 1%。不确定减少重复元数据(metadata)这一优化是否来自对 unique 包的讨论[18]。
2.4 工具链
在 Go 工具链改善方面,首当其冲的要数 go module 相关工具了。
在 Go 1.22 中,go work 增加了一个与 go mod 一致的特性:支持 vendor。通过 go work vendor,可以将 workspace 中的依赖放到 vendor 目录下,同时在构建时,如果 module root 下有 vendor 目录,那么默认的构建是 go build -mod=vendor,即基于 vendor 的构建。
go mod init 在 Go 1.22 中将不再考虑 GOPATH 时代的包依赖工具的配置文件了,比如 Gopkg.lock。在 Go 1.22 版本之前,如果 go module 之前使用的是类似 dep 这样的工具来管理包依赖[19],go mod init 会尝试读取 dep 配置文件来生成 go.mod。
go vet 工具取消了对 loop 变量引用的警告,增加了对空 append 的行为的警告(比如:slice = append(slice))、增加了 deferring time.Since 的警告以及在 log/slog 包的方法调用时 key-value pair 不匹配的警告。
3. 标准库
最后,我们来看看标准库的变化。每次 Go 发布新版本,标准库都是占更新的大头儿,这里无法将所有变更点一一讲解,仅说说几个重要的变更点。
3.1 增强 http.ServerMux 表达能力
Go 内置电池,从诞生伊始就内置了强大的 http 库,不过长期以来 http 原生的 ServeMux 表达能力比较单一,不支持通配符等,这也是 Go 社区长期以来一直使用像 gorilla/mux[20]、httprouter[21]等第三方路由库的原因。
今年 log/slog 的作者 Jonathan Amsterdam 又创建了新的提案:net/http: enhanced ServeMux routing[22],提高 http.ServeMux 的表达能力。在新提案中,新的 ServeMux 将支持如下路由策略[23](来自 http.ServeMux 的官方文档):
"/index.html"路由将匹配任何主机和方法的路径"/index.html";
"GET /static/"将匹配路径以"/static/"开头的 GET 请求;
"example.com/"可以与任何指向主机为"example.com"的请求匹配;
"example.com/{$}"会匹配主机为"example.com"、路径为"/"的请求,即"example.com/";
"/b/{bucket}/o/{objectname...}"匹配第一段为"b"、第三段为"o"的路径。名称"bucket"表示第二段,"objectname"表示路径的其余部分。
下面就是基于上面的规则编写的示例代码:
我们使用 curl 对上述示例进行一个测试(前提是在/etc/hosts 中设置 example.com 为 127.0.0.1):
从测试情况来看,不同路由设置之间存在交集,这就需要路由匹配优先级规则。新版 Go ServeMux 规定:如果一个请求有两个或两个以上的模式匹配,则更具体(specific)的模式优先。如果 P1 符合 P2 请求的严格子集,也就是说,如果 P2 符合 P1 及更多的所有请求,那么 P1 就比 P2 更具体。
举个例子:"/images/thumbnails/"比"/images/"更具体,因此两者都可以注册。前者匹配以"/images/thumbnails/"开头的路径,后者则匹配"/images/"子树中的任何其他路径。
如果两者都不更具体,那么模式就会发生冲突。为了向后兼容,这一规则有一个例外:如果两个模式发生冲突,而其中一个模式有主机(host),另一个没有,那么有主机的模式优先(比如上面测试中的第二次 curl 执行)。如果通过 ServeMux.Handle 或 ServeMux.HandleFunc 设置的模式与另一个已注册的模式发生冲突,这些函数就会 panic。
增强后的 ServeMux 可能会影响向后兼容性,使用 GODEBUG=httpmuxgo121=1 可以保留原先的 ServeMux 行为。
3.2 增加 math/rand/v2 包
在日常开发中,我们多会在生成随机数的场景下使用 math/rand 包,其他时候使用的较少。但 Go 1.22 中新增了 math/rand/v2 包,我之所以将这个列为 Go 1.22 版本标准库的一次重要变化,是因为这是标准库第一次为某个包建立 v2 版本包,按照 Russ Cox 的说法[24],这次 v2 包的创建,为标准库中的其他可能的 v2 包树立了榜样。创建 math/rand/v2 可以使 Go 团队能够在一个相对不常用且风险较低的包中解决工具问题(如 gopls、goimports 等对 v2 包的支持),然后再转向更常用、风险更高的包,如 sync/v2 或 encoding/json/v2 等。
新增 rand/v2 包的直接原因[25]是清理 math/rand 并修复其中许多悬而未决的问题,特别是使用过时的生成器、慢速算法以及与 crypto/rand 冲突的问题,这里就不针对 v2 包举具体的示例了,对该包感兴趣的同学可以自行阅读该包的在线文档,并探索如何使用 v2 包。
同时,该提案也为标准库中的 v2 包的创建建立了一种模式,即 v2 包是原始包的子目录,并且以原始包的 API 为起点,每个偏离点都要有明确的理由。
想当初,go module 刚落地到 Go 中时,Go module 支持两种识别 major 的两种方式,一种是通过 branch 或 tag 号来识别,另外一种就是利用 vN 目录来定义新包。当时还不是很理解为什么要有 vN 目录这种方式,现在从 math/rand/v2 包的增加来看,足以体现出当初 module 设计时的前瞻性考量了。
3.3 大修 Go execution tracer[26]
Go Execution Tracer 是解决 Go 应用性能方面“疑难杂症”的杀手锏级工具,它可以提供 Go 程序在一段时间内发生的情况的即时视图。这些信息对于了解程序随时间推移的行为非常宝贵,可辅助开发人员对应用进行性能改进。我曾在《通过实例理解 Go Execution Tracer[27]》中对其做过系统的说明。
不过当前版本的 Go Execution Tracer 在原理和使用方面还存在诸多问题,Google 的 Michael Knyszek 在年初发起了 Execution tracer overhaul 的提案[28],旨在对 Go Execution Tracer 进行改进,使 Go Execution Tracer 可扩展到大型 Go 部署的 Go 执行跟踪。具体目标如下:
使跟踪解析所需的内存占用量仅为当前的一小部分。
支持可流式传输的跟踪,以便在无需存储的情况下进行分析。
实现部分自描述的跟踪,以减少跟踪消费者的升级负担。
修复长期存在的错误,并提供一条清理实现的路径。
在近一年的时间里,Knyszek 与 Felix Geisendorfer、Nick Ripley、Michael Pratt 等一起实现了该提案的目标。
鉴于篇幅,这里就不对新版 Tracer 的使用做展开说明,有兴趣的童鞋可结合《通过实例理解 Go Execution Tracer[29]》中的使用方法自行体验新版 Tracer。
注:新版 Tracer 的设计文档[30] - https://go.googlesource.com/proposal/+/ac09a140c3d26f8bb62cbad8969c8b154f93ead6/design/60773-execution-tracer-overhaul.md
3.4 其他
“出尔反尔” - syscall 包:取消弃用(undeprecate)[31]
自 Go 1.4 版本[32]以来,syscall 包新特性就已经被冻结,并在 Go 1.11 版本[33]中被标记为不推荐使用(deprecate)。Go 团队推荐 gopher 使用 golang.org/x/sys/unix 或 golang.org/x/sys/windows。syscall 包的大多数功能都能被 golang.org/x/sys 包替代,除了下面这几个:
由于 syscall 包已经弃用,IDE 等工具在开发人员使用上述内容时总是得到警告!这引发了众多开发人员的抱怨。为此,在 Go 1.22 版本中,syscall 取消了弃用状态,但其功能特性依旧保持冻结,不再添加新特性。
TCPConn to UnixConn:支持 zerocopy
gnet[34]作者 Andy Pan 的提案:TCPConn to UnixConn:支持 zerocopy[35]在 Go 1.22 落地,具体内容可以看一下原始提案 issue[36]。
新增 go/version 包
在 Go 1.21 版本发布后,Go 团队对 Go 语言的版本规则做了调整,并明确了 Go 语言的向前兼容性和 toolchain 规则[37],Go 1.22 中增加 go/version 包实现了按照上述版本规则的 Go version 判断,这个包既用于 go 工具链,也可以用于 Gopher 自行开发的工具中。
4. 小结
Go 1.22 版本具有至少两点重要的里程碑意义:
通过对 loopvar 语义的修正,开启了 Go 已有“语法坑”的 fix 之路
通过 math/rand/v2 包树立了 Go 标准库建立 vN 版本的模式
“语法坑”fix 是否能得到社区正向反馈还是一个未知数,其导致的兼容性问题势必会成为 Go 社区在升级到 Go 1.22 版本的重要考虑因素,即便决定升级到 Go 1.22,严格的代码审查和测试也是必不可少的。
最后,感谢 Go 团队以及所有 Go 1.22 贡献者做出的伟大工作!
文本涉及的源码可以在这里[38]下载。
5. 参考资料
-Go 1.22 Milestone[39] - https://github.com/golang/go/milestone/298
版权声明: 本文为 InfoQ 作者【Tony Bai】的原创文章。
原文链接:【http://xie.infoq.cn/article/669c1a65f2787f833c6580aee】。文章转载请联系作者。
评论