并发 Go 程序中的共享变量 (四):内存同步

本系列是阅读 “The Go Programming Language” 理解和记录。

在上一小节中 并发 Go 程序中的共享变量 (三):读写锁,我们在实现 Balance 方法也需要一个排他锁,不论这个排他锁是通过 channel 实现还是互斥锁实现都是可以的,在我们的例子中是通过读写锁实现的。

1
2
3
4
5
func Balance() int {
mu.RLock()
defer mu.RUnlock()
return balance
}

但是不像 Deposit 方法那样需要读取 balance 并且加上 amount,Balance 只有一种操作,就是读取 Balance 并返回,所以即使有其它的 goroutine 在这中间有执行操作也不会造成什么问题。真的是这样么?

实际上我们还是需要锁,理由有二:

第一Balance 不能在其他操作执行期间执行,比如 Withdraw 执行中的时候,实际上已经少了 balance,但是实际读取的 balance 可能还是一个旧值。

第二,同步不仅仅和 goroutine 的执行顺序相关,同步也会影响内存。

在现代计算机中,一般都会有多个 CPU,每个有 CPU 有自己的主存缓存。为了性能,写到主存的数据一般都会在每个 CPU 内部首先缓存起来,然后在必要的时候提交到主存。这些修改的提交的顺序可能和 goroutine 的执行顺序不同。而同步原语比如说 channel 或者互斥锁的主要目的就是让 CPU 把 buffer 的数据提交到主存中,以便其它执行在其它 CPU 上的 goroutine 能够看到这些提交带来变化。

考虑以下代码的输出:

1
2
3
4
5
6
7
8
9
10
var x, y int
go func(){
x = 1 // A1
fmt.Print("y:", y, " ") // A2
}()
go func(){
y = 1 // B1
fmt.Print("x:", x, " ") // B2
}()

正如前面文章所提到的,两个 goroutine 并发执行,而且在没有使用互斥机制的情况下共享变量,存在 data race,因此在看到不确定的结果时不应该感到惊讶。我们可能会看到由于代码的执行顺序的不同而有不同的输出:

1
2
3
4
5
y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1

这四行输出可以解释为 A1,B1,A2,B2 或者 B1,A1,A2,B2。但是有一种结果可能会让你感到吃惊:

1
2
x:0 y:0
y:0 x:0

但是现实情况是:由于 CPU 或者编译器以及其它一些因素的影响,这种结果是有可能发生的。那么这 4 句语句如何交错执行才能产生这样的结果?

在单个 goroutine 中,每个语句带来的影响可以说是严格按照他们的执行顺序而产生的,goroutine 是线性一致的(sequentially consistent)。但是在多个 goroutine 中,如果没有显式的同步机制,比如 channel 或者互斥锁,没有办法保证 goroutine 彼此之间看到影响都是严格按照执行顺序的先后而产生的。虽然 goroutine A 肯定是先观测到 x = 1 执行完毕之后才去读取 y 的值,但是它没有办法确保 goroutine B 对 y 的修改一定能看的到,所以 goroutine A 可能读取的还是一个 y 的旧值:0。

理解并发执行的这种尝试常常很有意思,就好像并发的结果确实是 goroutine 之间的这些语句交错执行而产生的,事实可能并不是如此,正如上面的例子展示出来的一样结果都是 0 的输出,goroutine 的执行完全由可能是 A2,A1,B2,B1。这是由于赋值语句和 Print 语句指向都是不同的变量,编译器可能会得出一个论是:这两条语句的执行结果相互不影响从而交换了这两条语句的执行顺序。如果这两个 goroutine 是在不同的 CPU 上执行,每个 CPU 都有自己的 cache,其中一个 goroutine 写到 cache 中的数据是不能被另一个 goroutine 中的 Print 语句看到的,直到数据被同步到主从中。

所有并发的问题都能被这些已经建立的简单模式所解决:

要么保证变量只在单个 goroutine 中使用

要么在多个 goroutine 之间使用互斥锁

三月沙 wechat
扫描关注 wecatch 的公众号