简单的代码, 问题不简单

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

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

// 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