第九章 基于共享变量的并发(四)内存同步
一、内存同步
潜在问题
问题:以下代码段的所有可能输出结果是什么
var x, y int
go func() {
x = 1 // A1
fmt.Print("y:", y, " ") // A2
}()
go func() {
y = 1 // B1
fmt.Print("x:", x, " ") // B2
}()
一般会想到的:
y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1
**注意** 以下的也会出现:
x:0 y:0
y:0 x:0
内存中数据的变化不一定是实时的
假设时间上B2在A1之后执行,B2读到的x
的值不一定是A1修改后的,因为可能还没有同步
原因
并发 != 不同goroutines中的语句交错执行
-
当编译器判断两条语句的顺序不会影响执行结果时,可能会交换顺序(为了提升性能)
-
当goroutines在不同CPU上执行,每个CPU有自己的缓存时,一个goroutine写的数据只有在缓存与主存同步后才对其他goroutine可见
在现代计算机中可能会有一堆处理器,每一个都会有其本地缓存(local cache)。为了效率,对内存的写入一般会在每一个处理器中缓冲,并在必要时一起flush到主存。这种情况下这些数据可能会以与当初goroutine写入顺序不同的顺序被提交到主存。像channel通信或者互斥量操作这样的原语会使处理器将其聚集的写入flush并commit,这样goroutine在某个时间点上的执行结果才能被其它处理器上运行的goroutine得到。[1]
解决思路
在一个独立的goroutine中,语句的效果(effect)是被确保按顺序发生的,也就是说goroutine是顺序连贯(sequentially consistent)的。但如果没有使用mutex或者channel来进行显式的同步,就无法保证这些事件在其他的goroutine看来也是按照同样的顺序(核心还是主存与缓存的同步问题)。
所有这些并发的问题都可以用简单、既定的模式来规避:尽量将变量限定在goroutine内部;如果是多个goroutines都需要访问的变量,则使用互斥条件来访问(无论是读还是写)。
二、Happens Before
为了更好地描述并发程序中事件的顺序关系,Go的文档中提到了“happens before”的概念[2]。
定义
假设A和B表示一个多线程的程序执行的两个操作。如果A happens before B,那么A操作对内存的影响将在B被执行之前对执行B的线程可见。
一些规则
文档中也提到了判断 happens before的一些规则:
- 在一个goroutine中,happens before的顺序与程序执行顺序相同
- 如果一个package p被导入到package q中,q中init函数的末尾 happens before p中任一语句的开头
- main函数的开头 happens after 所有init函数的末尾
- 一个启动一个新的goroutine的
go
语句 happens before 这个goroutine的开头 - 一个channel的发送操作 happens before 相应的接收操作完成
- 关闭channel happens before 收到表明该channel已关闭的0值
- 从无缓冲的channel接收操作 happens before 向该channel的发送操作完成
- 容量为C的channel中第k个接收 happens before 第k+C个发送完成
- 对
sync.Mutex
或sync.RWMutex
变量l
,若n < m,第n个l.Unlock()
happens before 第m个l.Lock()
返回 - 对一个
sync.RWMutex
变量l
的任意l.RLock()
,存在一个n使得l.RLock()
happens(returns) after 第n个l.Unlock()
且满足l.RUnlock()
happens before 第n + 1个l.Lock()
-
once.Do(f)
中f()
的调用 happens(returns) before 任何其他的once.Do(f)
返回(即once.Do(f)
中的f()
只执行一次且happens before其他的once.Do(f)
完成)
happens-before不是时序关系[3]
- A happens-before B并不意味着A在B之前发生
例:编译器调换同一goroutine中语句顺序 - A在B之前发生并不意味着A happens-before B
例:缓存与内存的同步问题
关注的是对内存中数据的影响
三、sync.Once
为了实现变量的懒初始化(lazy initialization),且使之可被并发访问。
var icons map[string]image.Image
func loadIcons() {
icons = map[string]image.Image{
"spades.png": loadIcon("spades.png"),
"hearts.png": loadIcon("hearts.png"),
"diamonds.png": loadIcon("diamonds.png"),
"clubs.png": loadIcon("clubs.png"),
}
}
// 不是并发安全的!!
func Icon(name string) image.Image {
if icons == nil {
loadIcons() // one-time initialization
}
return icons[name]
}
除了竞争问题之外,还有一个问题。由于编译器和CPU可以重排语句顺序,loadIcons()
可能实际变成:
func loadIcons() {
// 在这句之后在其他goroutines看来就可能不为nil了,但其实初始化并没有完成
icons = make(map[string]image.Image)
icons["spades.png"] = loadIcon("spades.png")
icons["hearts.png"] = loadIcon("hearts.png")
icons["diamonds.png"] = loadIcon("diamonds.png")
icons["clubs.png"] = loadIcon("clubs.png")
}
修改1:使用互斥锁sync.Mutex
var mu sync.Mutex // guards icons
var icons map[string]image.Image
// 并发安全的,因为Mutex会触发内存同步
func Icon(name string) image.Image {
mu.Lock()
defer mu.Unlock()
if icons == nil {
loadIcons()
}
return icons[name]
}
问题:不能并发访问
修改2:使用读写锁sync.RWMutex
var mu sync.RWMutex // guards icons
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {
mu.RLock()
if icons != nil {
icon := icons[name]
mu.RUnlock()
return icon
}
mu.RUnlock()
// acquire an exclusive lock
mu.Lock()
// NOTE: must recheck for nil 因为之前释放过锁,故可能已被其他goroutine初始化过了
if icons == nil {
loadIcons()
}
icon := icons[name]
mu.Unlock()
return icon
}
问题:太复杂,容易写错
推荐方案:使用sync.Once
(原因在“happens before”的讨论中提到过)
var loadIconsOnce sync.Once
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {
loadIconsOnce.Do(loadIcons)
return icons[name]
}
每一次对
Do(loadIcons)
的调用都会锁定mutex,并会检查boolean变量。在第一次调用时,变量的值是false
,Do
会调用loadIcons
并会将boolean设置为true
。随后的调用什么都不会做,但是mutex同步会保证loadIcons
对内存(这里其实就是指icons变量啦)产生的效果能够对所有goroutine可见。用这种方式来使用sync.Once
的话,我们能够避免在变量被构建完成之前和其它goroutine共享该变量[4]。
1/24/2018