从 Go 1.23 版本开始,Go 语言开始允许对迭代器函数使用 range
循环:
变更日志[1] 博客文章[2]
如果你已经了解了这个特性,那很好。否则,我建议你在继续阅读本文之前先阅读上面两个链接。
range-over-func
特性是在 Go 编译器内部实现的,它将:
for range f() {
...
}
大致重写为:
f(func(x T) bool {
...
})
具体细节对本文来说并不重要,你只需要理解编译器前端将 for-range
循环重写为一个函数调用,该调用将一个函数字面量(也称为闭包)作为参数。
问题
Go 1.23 发布后,出现了一个问题[3],用户报告 range-over-func
循环无法终止。经过一些分析和排查后,Keith Randall 发表了他的意见[4],认为这似乎是一个编译器错误。
几天后,又出现了另一个问题[5],这次是运行时出现了神秘的崩溃,同样也与 range-over-func
相关。这个问题很快被标记为发布阻塞问题,因为这听起来像是一个严重的编译器错误。
我决定先看看问题 69434[6],因为我在问题报告的那天已经做了一些分析。
Go 编译器中的逃逸分析有一条规则:如果变量 x
在声明 x
的函数的子闭包中被重新赋值,那么 x
必须在堆上分配。
用代码来表示:
func f() {
var x *int
func() {
x = new(int) // 如果闭包没有内联,则必须在堆上分配
}()
}
让我们看看这个程序:
package main
import (
"iter"
)
func All() iter.Seq[int] {
return func(yield func(int) bool) {
for i := 0; i < 10; i++ {
growStack(1)
if !yield(i) {
return
}
}
}
}
type S struct {
round int
}
func NewS(round int) *S {
s := &S{round: round}
return s
}
func (s *S) check(round int) {
if s.round != round {
panic("bad round")
}
}
func f() {
rounds := 0
s := NewS(rounds)
s.check(rounds)
for range All() {
s.check(rounds)
rounds++
s = NewS(rounds)
s.check(rounds)
}
}
func growStack(i int) {
if i == 0 {
return
}
growStack(i - 1)
}
func main() {
f()
}
使用 go tool compile -m
检查:
$ go1.23.1 build -gcflags=-m -trimpath main.go
# command-line-arguments
./main.go:7:6: can inline All
./main.go:22:6: can inline NewS
./main.go:27:6: can inline (*S).check
./main.go:53:6: can inline main
./main.go:35:11: inlining call to NewS
./main.go:36:9: inlining call to (*S).check
./main.go:38:15: inlining call to All
./main.go:39:10: inlining call to (*S).check
./main.go:41:11: inlining call to NewS
./main.go:42:10: inlining call to (*S).check
./main.go:8:14: yield does not escape
./main.go:8:9: func literal escapes to heap
./main.go:23:7: &S{...} escapes to heap
./main.go:27:7: s does not escape
./main.go:29:9: "bad round" escapes to heap
./main.go:8:14: yield does not escape
./main.go:35:11: &S{...} does not escape
./main.go:36:9: "bad round" escapes to heap
./main.go:38:2: func literal does not escape
./main.go:39:10: "bad round" escapes to heap
./main.go:41:11: &S{...} does not escape
./main.go:42:10: "bad round" escapes to heap
./main.go:38:15: func literal does not escape
你可以看到 ./main.go:41:11: &S{...} does not escape
,而它应该是逃逸的。
现在运行它:
$ go1.23.1 run -trimpath main.go
panic: bad round
goroutine 1 [running]:
main.(*S).check(...)
./main.go:29
main.f-range1(0xc0000546e8?)
./main.go:39 +0xb8
main.f.All.func1(0xc000054710)
./main.go:11 +0x48
main.f()
./main.go:38 +0x88
main.main()
./main.go:54 +0xf
exit status 2
啊哈,问题找到了。
对于一个函数 f
,它内部的闭包将被命名为 f.funcN
,其中 N
从 1 开始,每次遇到一个新的闭包就递增。这种命名方案对于逃逸分析很重要,因为它可以帮助检查闭包是否在函数内部。
查看上面的堆栈跟踪,你可以看到闭包 main.f-range1
,它没有遵循这种命名模式,因此逃逸分析不知道它是一个函数 f
中的闭包,导致了错误的分配决策。
如何立即发现这个错误是另一个故事,因为它需要你熟悉 Go 编译器的开发。
解決方案
修复很简单,只需扩展逃逸分析以识别 range-over-func
的新命名方案。这在CL 614096[7] 中完成,并将包含在 Go 1.23 的下一个次要版本(可能是 Go 1.23.2)中。
$ go1.23.1 build -trimpath -gcflags=-m main.go &> bad
$ go build -trimpath -gcflags=-m main.go &> good
$ grep -v -Ff bad good
./main.go:41:11: &S{...} escapes to heap
还没有评论,来说两句吧...