问题提出 提出这个问题主要是因为日常开发中有这样的场景,比如服务配置热加载或者一些全局缓存的异步更新,通常会单独启一个协程去获取最新数据再通过赋值更新原数据。因为更新逻辑是单独的协程,变量的写和读就存在并发访问的情况,于是就有了这个问题。一个简单异步刷新例子 如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 type conf struct { } var c *conf func loadConf() { c = &conf{} go func() { ticker := time.NewTicker(time.Second) for range ticker.C{ newC := &conf{} c = newC } }() }
这里的关键点在于c = newC
是否是原子性的,需不需要加锁。go 指针和int64一样都是8位在32位机器上一条指令最多只能操作4字节的数据所以一次赋值操作至少得两条指令,所以在32位机器上肯定存在并发问题。那么在64位机器上8位的数据是可以通过一条指令操作的那在64位机器上存在并发问题吗?
竞态检测 关于数据并发问题,go本身提供了竞态检测来帮助发现代码中的存在竞态情况,如下代码,通过 go run -race main.go
会显示代码中存在的竟态情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 func main() { var data map[string]string temp := make(map[string]string) data = temp go func() { v := data["d"] fmt.Println(v) }() go func() { temp := make(map[string]string) temp["a"] = "a" data = temp }() time.Sleep(time.Second) } // go run -race main.go 输出如下: ================== WARNING: DATA RACE Write at 0x00c000012028 by goroutine 7: main.main.func2() /Users/vector/go/src/demo/conf/main.go:19 +0x93 Previous read at 0x00c000012028 by goroutine 6: main.main.func1() /Users/vector/go/src/demo/conf/main.go:13 +0x34 Goroutine 7 (running) created at: main.main() /Users/vector/go/src/demo/conf/main.go:16 +0x16e Goroutine 6 (running) created at: main.main() /Users/vector/go/src/demo/conf/main.go:12 +0x104 ================== Found 1 data race(s) exit status 66
通过-race参数可以看到go给出了告警,并发赋值存在数据竞争。本着小心使得万年船的态度我们可以加个读写锁,至于这条赋值语句是不是原子的我们继续看。
汇编代码 想看到赋值是不是一条指令只能看编译之后的汇编代码了,通过go tool compile -S -N main.go > main.s
生成中间汇编代码(64位机器):
1 2 3 4 5 6 7 8 9 10 11 12 13 ;;... 0x008c 00140 (main.go:19) PCDATA ZR, $-1 0x008c 00140 (main.go:19) MOVD main.&data-8(SP), R2 ;;将栈指针(SP)减 8 后的地址处的数据(main.&data)加载到寄存器 R2 中。 0x0090 00144 (main.go:19) MOVD main.temp-24(SP), R3 ;;将栈指针(SP)减 24 后的地址处的数据(main.temp)加载到寄存器 R3 中。 0x0094 00148 (main.go:19) PCDATA ZR, $-2 0x0094 00148 (main.go:19) MOVWU runtime.writeBarrier(SB), R0 0x00a0 00160 (main.go:19) CBZW R0, 168 0x00a4 00164 (main.go:19) JMP 176 0x00a8 00168 (main.go:19) MOVD R3, (R2) ;;将寄存器 R3 中的数据存储到寄存器 R2 指向的地址。 0x00ac 00172 (main.go:19) JMP 184 0x00b0 00176 (main.go:19) CALL runtime.gcWriteBarrier(SB) 0x00b4 00180 (main.go:19) JMP 184 ;;...
一行赋值语句生成了这么多条汇编指令,真正发生值修改是这句MOVD R3, (R2)
可以看出指针的赋值确实是原子的。
网上的一些讨论 关于这个问题,也在网上搜了下,有说是原子的也有说不是原子的。结合go官方文档只有使用atomic
包才保证原子性,并没有显示指出指针赋值是原子的,所以无法保证指针赋值是原子的,虽然上面汇编值修改是只有一条指令,但谁也无法保证随着后续的升级go语言本身会不会有什么修改。所以普遍建议是使用读写锁或者使用atomic
,刚好go 1.19新出了atomic.Pointer 专门用来处理指针的原子操作,用法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 type conf struct { v1 string v2 int } var atomicC = atomic.Pointer[conf]{} func main() { tempc := &conf{ v1: "", v2: 0, } atomicC.Store(tempc) go func() { ticker := time.NewTicker(time.Second) for range ticker.C { newC := &conf{} atomicC.Store(newC) } }() var c = &conf{} c = atomicC.Load() fmt.Println(c.v2, c.v1) }
总结
go 官方没有显示保证指针赋值是原子的,变量建议使用atomic
保证原子性
不同架构的cpu比如x86、x64实现不一样,如过想保证全平台一致一定要显示做原子性处理,加锁或使用atomic
在性能损失可接受的情况下使用atomic
或者加锁保证原子性
参考