- Published on
Golang编译器优化迷之操作
- Authors
- Name
- ttyS3
简单的代码, 问题不简单
今天有人发了段代码给我, 然后问输出结果是什么?
这段代码看上去非常简单, 但是确是很有迷惑性.
// a.go
package main
import (
"fmt"
"time"
)
var x int64 = 0
func storeFunc() {
for i := 0; ; i++ {
if i%2 == 0 {
x = 2
} else {
x = 1
}
}
}
func main() {
go storeFunc()
for {
fmt.Printf("x=%v\n", x)
// x=0
time.Sleep(time.Millisecond * 10)
}
}
答案是: 不断地输出 x=0
tested under Go 1.17 / Go 1.18
为什么是 0 ? 刚开始老灯也有疑问. 查看了汇编之后, 才确定是编译器优化在搞鬼.
假设文件名为 a.go, 我们直接执行 go run a.go
结果就是输出0
我们稍微改一下, 就在 for 里面加个小 sleep (sleep 多久不重要):
// b.go
package main
import (
"fmt"
"time"
)
var x int64 = 0
func storeFunc() {
for i := 0; ; i++ {
time.Sleep(time.Millisecond * 16)
if i%2 == 0 {
x = 2
} else {
x = 1
}
}
}
func main() {
go storeFunc()
for {
fmt.Printf("x=%v\n", x)
// x=0
// x=2
// x=1
time.Sleep(time.Millisecond * 10)
}
}
这个新加的 time.Sleep(time.Millisecond * 16)
会导致编译器不再将这个函数对 x
的赋值操作优化掉.
我们继续变一下, 这将主要是让这个函数有条件返回, 同样的, 编译器不会再优化掉对 x
的赋值操作:
// c.go
package main
import (
"fmt"
"time"
)
var x int64 = 0
func storeFunc() {
for i := 0; i < 10000000001; i++ {
if i%2 == 0 {
x = 2
} else {
x = 1
}
}
}
func main() {
go storeFunc()
for {
fmt.Printf("x=%v\n", x)
// x=0
// x=1
// x=1
// x=1
// x=2
// x=1
time.Sleep(time.Millisecond * 10)
}
}
接着, 我们再变一下, 没错, 我们把 for
移到后面了, 结果会是输出 2 吗?
// d.go
package main
import (
"fmt"
"time"
)
var x int64 = 0
func storeFunc() {
var i int
if i%2 == 0 {
x = 2
} else {
x = 1
}
for i := 0; ; i++ {
}
}
func main() {
go storeFunc()
for {
fmt.Printf("x=%v\n", x)
// x=0
time.Sleep(time.Millisecond * 10)
}
}
答案是: 不会, 结果还是输出 0
目前的测试结果看来, 只要这里是个死循环, 并且里面没有加 sleep 类的操作, 照样会被优化.
我们再来一个变种:
// e.go
package main
import (
"fmt"
"time"
)
var x int64 = 0
func storeFunc() {
for i := 0; ; i++ {
// time.Sleep(time.Millisecond * 10)
if i%2 == 0 {
x = 2
} else {
x = 1
}
}
}
func main() {
go func() {
for {
fmt.Printf("x=%v\n", x)
// x=0
time.Sleep(time.Millisecond * 10)
}
}()
storeFunc()
}
这个问题和多线程访问变量有关吗?
完全无关. 有人评论说, 你这个代码, 不用多线程你两个死循环完全跑不了.
所以, 我这里增加了这段, 用于测试验证, 和多线程无关. 本质上是编译器优化, 跟多线程不多线程没有任何关系.
我完全可以去掉多线程 go storeFunc()
修改成 storeFunc()
即可 (不要说你这第二个loop没机会运行, 这里只是为了说明, 与多线程没有关系), 生成的优化代码是完全一样的:
// a.go
package main
import (
"fmt"
"time"
)
var x int64 = 0
func storeFunc() {
for i := 0; ; i++ {
if i%2 == 0 {
x = 2
} else {
x = 1
}
}
}
func main() {
storeFunc()
for {
fmt.Printf("x=%v\n", x)
// x=0
time.Sleep(time.Millisecond * 10)
}
}
生成的汇编是一模一样的:
"".storeFunc STEXT nosplit size=3 args=0x0 locals=0x0 funcid=0x0 align=0x0
0x0000 00000 (a.go:11) TEXT "".storeFunc(SB), NOSPLIT|ABIInternal, $0-0
0x0000 00000 (a.go:11) FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (a.go:11) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
0x0000 00000 (a.go:12) XCHGL AX, AX
0x0001 00001 (a.go:1) JMP 0
0x0000 90 eb fd
当然, 正常开发中没人会这样写代码.
同样, 正常开发的时候, 我们多线程读写同一个内存, 一般都会加锁, 为了避免 CPU 方面的优化, 我们还可能会采用原子操作 (go atomic
pkg).
有没有办法禁止这种优化?
当然是有的, 编译的时候加 -gcflags '-N'
即可. (还有个 -l
表示 noinline
, 由于这里我们的讨论不涉及inline优化, 因此这里不讨论这个参数). 禁止优化执行的结果是预期的:
go run -gcflags '-N' a.go
x=0
x=2
x=2
x=1
x=1
x=1
x=2
x=1
x=1
x=2
x=1
x=1
ps: 构建时用 go build -gcflags '-N'
即可.
我们看下禁止优化(-N
)后的Go 汇编代码( 我们这里只关注 func storeFunc()
这个函数的):
go tool compile -N -S a.go
TEXT "".storeFunc(SB), NOSPLIT|ABIInternal, $16-0
SUBQ $16, SP
MOVQ BP, 8(SP)
LEAQ 8(SP), BP
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
MOVQ $0, "".i(SP)
JMP storeFunc_pc24
storeFunc_pc24:
JMP storeFunc_pc26
storeFunc_pc26:
MOVQ "".i(SP), AX
BTL $0, AX
JCC storeFunc_pc38
JMP storeFunc_pc51
storeFunc_pc38:
MOVQ $2, "".x(SB)
JMP storeFunc_pc66
storeFunc_pc51:
MOVQ $1, "".x(SB)
NOP
JMP storeFunc_pc66
storeFunc_pc66:
JMP storeFunc_pc68
storeFunc_pc68:
MOVQ "".i(SP), AX
INCQ AX
MOVQ AX, "".i(SP)
JMP storeFunc_pc24
TEXT "".storeFunc(SB), NOSPLIT|ABIInternal, $16-0
表示 storeFunc
函数开始.
MOVQ $0, "".i(SP)
把 0 丢给 i
MOVQ "".i(SP), AX
把 i 丢给 AX
BTL $0, AX
, BT
是 Bit Test 的意思, 位检测指令. 结果会影响 CF (carry flag), 因此后面用 JCC 判断. 取第0个 bit 丢给 CF
关于 BT:
Selects the bit in a bit string (specified with the first operand, called the bit base) at the bit-position designated by the bit offset (specified by the second operand) and stores the value of the bit in the CF flag. The bit base operand can be a register or a memory location; the bit offset operand can be a register or an immediate value.
CF ← Bit(BitBase, BitOffset);
注意, 这段描述是针对 Intel asm的, GCC 使用 AT&T 风格的 asm, 因此参数是反的. 另外, AT&T 语法 Immediate values prefixed with a $
, registers prefixed with a %
比如 AT&T movl $5, %eax
, Intel 则是 mov eax, 5
, 我们教科书里面基本上介绍 Intel asm, 包括单片机那些. 老灯也是看不太习惯 AT&T 语法, 可能 Intel 先入为主了吧.
ref https://en.wikipedia.org/wiki/X86_assembly_language#Syntax
JCC storeFunc_pc38
, AX 最低位是 0 则跳到 storeFunc_pc38, 这里怎么直接是 JCC
? 老灯觉得这里应该是 JNC
或 JAE
? 关于这个, 后面老灯再说.
MOVQ $2, "".x(SB)
即把 2 丢给 x 啦.
否则, JCC 没跳, 则 MOVQ $1, "".x(SB)
, 把 1 丢给 x
此即:
if i%2 == 0 {
x = 2
} else {
x = 1
}
最后 JMP storeFunc_pc24
又跳回去了. 无限循环.
好了, 我们再看一下被优化过的 storeFunc
函数长啥样:
go tool compile -S a.go
storeFunc_pc0:
TEXT "".storeFunc(SB), NOSPLIT|ABIInternal, $0-0
FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
XCHGL AX, AX
JMP storeFunc_pc0
没错, 只有一个 XCHGL AX, AX
指令, 然后就是 JMP storeFunc_pc0
死循环. 整个指令完全把对 x
的赋值操作优化没了.
Go 汇编迷一样的条件跳转语句
Go 汇编 里面的条件跳转语句, 对于熟悉 Intel x86 语法的人来说, 也是很迷的. 老灯在这找到了一个对应表: 实际上应该是结合 https://github.com/golang/go/blob/ed4db861182456a63b7d837780c146d4e58e63d8/src/cmd/asm/internal/arch/arch.go#L128 和 x86 指令总结的.
Go | x86 |
---|---|
JCC | JAE |
JCS | JB |
JCXZL | JECXZ |
JEQ | JE,JZ |
JGE | JGE |
JGT | JG |
JHI | JA |
JLE | JLE |
JLS | JBE |
JLT | JL |
JMI | JS |
JNE | JNE, JNZ |
JOC | JNO |
JOS | JO |
JPC | JNP, JPO |
JPL | JNS |
JPS | JP, JPE |
开启优化, 怎么确保 x 的值被设置?
答案当然是用 atomic 操作啦. https://pkg.go.dev/sync/atomic
可以用 LoadInt64 和 StoreInt64 读写.
Refs
https://dave.cheney.net/2020/04/25/inlining-optimisations-in-go
https://dave.cheney.net/2018/01/08/gos-hidden-pragmas
https://en.wikipedia.org/wiki/X86_assembly_language#Syntax
A Quick Guide to Go's Assembler https://go.dev/doc/asm
https://pkg.go.dev/sync/atomic#LoadInt64
https://github.com/teh-cmc/go-internals/blob/master/chapter1_assembly_primer/README.md