问题提出

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

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 会显示代码中存在的竟态情况

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位机器):

;;...
    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 专门用来处理指针的原子操作,用法如下:

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

参考