6

Golang编译器优化迷之操作

 2 years ago
source link: https://ttys3.dev/post/confusing-golang-compiler-optimization/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

简单的代码, 问题不简单

今天有人发了段代码给我, 然后问输出结果是什么?

这段代码看上去非常简单, 但是确是很有迷惑性.

// 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 ? 老灯觉得这里应该是 JNCJAE ? 关于这个, 后面老灯再说.

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 指令总结的.

Gox86

JCCJAE JCSJB JCXZLJECXZ JEQJE,JZ JGEJGE JGTJG JHIJA JLEJLE JLSJBE JLTJL JMIJS JNEJNE, JNZ JOCJNO JOSJO JPCJNP, JPO JPLJNS JPSJP, 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://godbolt.org/

https://github.com/teh-cmc/go-internals/blob/master/chapter1_assembly_primer/README.md

https://go.dev/doc/asm


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK