go 指针赋值是原子性的吗

问题提出

提出这个问题主要是因为日常开发中有这样的场景,比如服务配置热加载或者一些全局缓存的异步更新,通常会单独启一个协程去获取最新数据再通过赋值更新原数据。因为更新逻辑是单独的协程,变量的写和读就存在并发访问的情况,于是就有了这个问题。一个简单异步刷新例子 如下:

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或者加锁保证原子性

参考


go 指针赋值是原子性的吗
https://www.mfun.ink/2023/12/06/is-assigning-pointer-atomic-go/
作者
mengboy
发布于
2023年12月6日
许可协议