0%

记录一个Go for-range循环的BUG

9月11日更新
最近发布的Go 1.14.9版本已经修复了该BUG,详情右转👉Release History

在Go 1.13和1.14版本现存一个很玄学的BUG,在for循环中的一个常数会导致无法退出循环,一个简单的示例:

1
2
3
4
5
6
7
8
9
10
11
package main

func main() {
nums := []int64{1, 2}
for i, _ := range nums {
if i + 1 < 1 {
return
}
println(i)
}
}

很简单的一段代码,预期输出如下

1
2
0
1

但实际运行发现程序进入死循环,i无限增长,继续测试发现,把 i+1 改成 i+2,运行就正常了!

这就很玄学了,循环体内的一个常数导致for-range越界了😳

BUG与编译器优化

当然这是一篇水文,这个问题也不是我发现的,在7月份就已经有人提交了 Issue,大佬们已经对这个bug做了解答,大概就是编译器在编译优化阶段,会进行一系列的代码分析,每pass一个会完成一个优化策略,其中一个pass不小心把越界判断给删掉了😅

更详细一点,编译时生成的IR(中间代码)中会有这么一行类似的代码:

1
2
3
v1 = nums.len
...
v2 = Less(i, v1)

先拿到切片长度v1,然后判断下标i是否比v1小,再运行其他代码。问题就发生在这里,Less这一行在优化后消失了,消失原因是这一行是“死”代码。

大佬们发现,让Less变成dead的原因发生在prove pass优化过程中,而它的作用是优化不必要的越界判断。

Prove pass

prove pass优化是很重要的,因为原则上每次访问数组元素都会检查一次越界,但很多越界情况是可以在编译期发现的,这个pass可以优化掉不必要的越界判断。

比如,下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
func (bigEndian) PutUint64(b []byte, v uint64) {
_ = b[7] // early bounds check to guarantee safety of writes below
b[0] = byte(v >> 56)
b[1] = byte(v >> 48)
b[2] = byte(v >> 40)
b[3] = byte(v >> 32)
b[4] = byte(v >> 24)
b[5] = byte(v >> 16)
b[6] = byte(v >> 8)
b[7] = byte(v)
}

函数第一行就访问下标为7的元素,它保证了这个数组的长度是大于等于8的,所以后面的代码都不必再进行越界判断。

在BUG代码中,i+1恰好满足了prove pass的某种优化条件,导致越界判断的Less代码变成了dead代码,进而被其他pass优化掉了。

哪个版本会修复这个BUG?

在发本文的时候,Go 1.13和1.14版本仍然在维护,相关的patch已经提交而且合入了(居然是同事大佬提交的patch,震惊了),但是1.15版本的开发周期已经冻结,所以预计会在1.16的开发中合入。

一开始很奇怪,为什么这么严重的bug,会因为“开发周期冻结”这么扯淡的理由延期修复,后来才了解到,Go的开发周期6个月一次,前3个月开发迭代,后3个月冻结,在冻结期进行测试和优化,然后再通过发行小版本来合入patch。

哎,突然很后悔没出海入个外企,这种事要是发生在国内,不让你加班修bug就不错了!