前言
在开始之前, 先来引出问题. 有这样一段go
代码:
func main() {
var wg sync.WaitGroup
wg.Add(2)
var x, y int
go func() {
defer wg.Done()
x = 1
fmt.Println(fmt.Sprintf("y=%d", y))
}()
go func() {
defer wg.Done()
y = 1
fmt.Println(fmt.Sprintf("x=%d", x))
}()
wg.Wait()
}
这段代码可能会有哪些结果呢? 无非看语句的执行顺序嘛, 排列组合一下可能的情况:
- x=1, y=1
- x=0, y=1
- x=1, y=1
但是, 如果你多跑几次, 就会发现, x=0, y=0
这种我们以为不会出现的情况, 是真的会出现的.
不过这种情况为什么发生想必也都心知肚明, 就简单聊一聊吧.
why
为什么会出现这种情况呢? 其实, 如果了解多线程及 CPU 的实现, 倒也不难理解.
首先, CPU 为了加速, 存在多级缓存. 同样的内存修改会先修改 CPU 内部缓存, 不会立即刷新到内存上.
此时, 若是多核 CPU, 多个线程跑在不同核上, CPU1对内存进行了修改, 但此时修改还在CPU缓存上, 没有刷新到内存, CPU2到内存读到的就是旧值.
同样的, 在Go
中, 多个协程也是跑在不同的 CPU 核上, 所以, 内存的更新对其他 CPU 核来说也不是立即可见的. 出现这样的问题也就不奇怪了.
不光是写, 读操作也是有缓存的呦
探究
在Java
中, 存在volatile
关键字, 来保证字段的更新立即刷新到内存. 那么在Go
中, 如何来解决这个问题呢?
一说到多线程同步, 第一个想到的必定就是锁了, 没错lock
可以保证更新立即可见, 同样的channel
atomic
都可以. 其中atomic
包做的事情 和volatile
是一样的. (也就是说, 前面的例子只要在将读写的操作改为atomic
, 就不会出现 x=0,y=0 的情况啦).
在Go
的官方文档中对内存模型进行了简单的介绍, 也说明了这种错误. 甚至于, 在文档中给出了这样的例子(感兴趣的可以去看一下文档, 还挺有趣的):
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func main() {
go setup()
for !done {
}
print(a)
}
- 结果可能打印空字符串. 也就是说,
done
的更新同步到内存了, 但是a
没有 - 甚至, 极端情况
main
的循环可能不会结束. 即CPU 缓存刷新时间很长.
同时, 在这篇官方文档中还有一些很有意思的内容, 推荐读一读. 比如:
- 说明了编译期对指令执行顺序的保证
- 多协程通信的方式(就是我们已知的几个
lock/channel/atomic
等) runtime.SetFinalizer
变量的析构函数(但是如果在回收前进程就结束了, 可能不会调用)- 在
build/run
命令后跟上-race
参数, 可以检测是否存在多协程变量竞争的问题. 若存在, 会在运行时报错. - 等等
over, 对此问题一个简简单单的回顾