简介 Go的内存模型保证在一个goroutine中可以看到另一个goroutine中对于相同变量的修改
建议
我们需要将多个goroutine的访问串行化当它们修改同一个数据时,为了串行化多个goroutine的访问,我们可以通过channel或者其他的同步原语例如sync包,sync/atomic来保护数据
如果你要阅读这篇文档的余下部分并以此来分析你项目中的一些操作,那么你就太聪明了
不要自以为是😜
Happens Before
对于单个的goroutine来说,对于数据的读和写应该按照程序的定义来依次执行。然而编译器和处理器可能会对程序中读和写的顺序进行重排当这样的重排并不会改变原先程序的语义时,正是因为这样的重排,在另一个goroutine中观察到的执行顺序可能和程序原本的定义顺序不一致,例如,如果在一个goroutine中定义a = 1; b = 2
,另一个goroutine可能观察到的执行顺序为b = 2; a = 1
,但是这样的重排并不会影响程序原先的语义
针对数据读取和写入的需求,我们定义了happens before,一个在Go程序中执行内存操作的局部顺序。如果事件在事件之前发生,那我们说在之后发生,当然,如果既不在之前发生,也不在之后发生,那么我们就可以说和是并行的
在一个goroutine中,happens-before的顺序就是程序所表达的顺序
对于一个变量的读操作可以看到对于该变量的写操作需要满足下面两个条件:
- 不在之前发生
- 没有其它写操作发生在之后,之前
为了保证对于一个变量的读操作看到特定的写操作,需要确保是唯一可以看到的写操作。也就是说可以保证看到需要满足下面两个条件:
- 在之前发生
- 其它的对于共享变量的所有写操作要么发生在之前,要么发生在之后
下面这对限制就要比上面的限制严格一些,它要求没有其它的写操作与和同时发生
对于一个goroutine来说,没有并发操作,所以上面的两组限制是等价的: 对于变量的读操作观察到对于该变量的最近的一次写操作。当有多个goroutine访问一个共享变量时,必须使用同步事件去构建happens-before,确保读操作可以观察到想要的写操作
对于一个变量的初始化在内存模型中被定义为一种写操作
读和写大于一个machine work的操作并不是一个原子操作,不会按照特定的顺序执行
同步机制
初始化
程序的初始化在单个goroutine中,但是这个goroutine可能会创建其它的goroutine
如果package p导入了package q,那么package q中的初始化函数先于package p中的初始化函数执行
main.main的执行是在所有的init函数执行完之后
goroutine 创建
go
关键字开启一个goroutine发生在这个goroutine开始执行之前,例如,下面的这个程序:
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
调用hello
将会打印"hello, world"
在将来的某个时间点(可能是hello
函数返回之后)
goroutine销毁
一个goroutine的销毁不能保证发生在程序的任何事件之前,例如下面的这个程序:
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}
对于a
的赋值没有使用任何的同步事件,所以是不能保证可以被其它的goroutine观察到的。事实上,一个激进的编译器可能会删掉整个go
的那一行代码
如果一个goroutine的影响必须被其它的goroutine观察到,使用同步原语例如lock或者channel通信去构建一个相关的执行顺序
channel 通信
channel通信是两个goroutine间同步的主要方式,对于channel的每一次写入都会有与之相对应的一次读出,通常是在不同的goroutine中
对于channel的send操作发生在从该channel中读取数据完成之前
这个程序:
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world"
c <- 0
}
func main() {
go f()
<-c
print(a)
}
是保证可以打印出"hello, world"
的,对于a
的写操作发生在向c
中发送数据之前,同时也是发生在从c
中读取数据完成之前,也就是print函数之前
对于一个channel的关闭发生在从该channel中获取零值之前
在之前的例子中,将c <- 0
替换成close(c)
同样可以保证打印出"hello, world"
从一个没有缓冲区的channel读取数据发生在向该channel中发送数据完成之前 下面这段代码(相比于上面,发送和接收调换了顺序并使用了一个没有缓冲区的channel):
var c = make(chan int)
var a string
func f() {
a = "hello, world"
<-c
}
func main() {
go f()
c <- 0
print(a)
}
同样也是保证打印出"hello, world"
的。对于a
的赋值发生在从c
接收数据之前,同时发生在向c
发送数据完成之前,也就是print函数之前
如果channel是带缓冲区的(例如: c = make(chan int, 1)),那么这个程序将不能保证打印出"hello, world"
(它可能会打印出空字符串,或者做一些其它的事情)
第k个从一个容量为C的channel中读取数据的操作发生在第k + C个向该channel发送数据的操作完成之前
这个规则衍生出之前对于带缓冲区channel的规则。通过channel限定一个计数的信号量;channel中元素的数量决定了当前可以处于活跃态的goroutine数量,channel的容量限定了同一时刻最多活跃的goroutine数量,向channel中发送一个数据来增加信号量,从channel中接收数据后释放信号量,这是一个常见的限制并发数量的做法
下面的这段程序开启一个goroutine对于work中的每一个实体,同时使用一容量为3的channel来限制同一时刻最多执行w
函数的数量
var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func(w func()) {
limit <- 1
w()
<-limit
}(w)
}
select{}
}
Locks
sync包中实现了两种锁类型,sync.Mutex和sync.RWMutex
不管是sync.Mutex还是sync.RWMutex,对于Unlock操作是发生在Lock操作之前的
下面的这段代码:
var l sync.Mutex
var a string
func f() {
a = "hello, world"
l.Unlock()
}
func main() {
l.Lock()
go f()
l.Lock()
print(a)
}
可以保证打印出hello, world
。对于Unlock
的调用在第二次Lock
操作之前,也就是在print函数执行前
Once
sync包提供了一个安全的原语Once应对多个goroutine同时初始化的场景。多个goroutine可以同时执行once.Do(f)
,但是只会有一个执行f()
,其它的goroutine都会阻塞直到f
函数返回
对于单个goroutine执行once.Do(f)
,进而执行f()
的场景发生在其它调用once.Do(f)
的goroutine返回之前
下面这个程序:
var a string
var once sync.Once
func setup() {
a = "hello, world"
}
func doprint() {
once.Do(setup)
print(a)
}
func twoprint() {
go doprint()
go doprint()
}
调用twoprint将只会执行setup一次,setup函数将在print函数执行之前完成,所以对于每一个goroutine来说都会打印"hello, world"
不正确的同步
提醒下一个读操作可能观察到与它同时发生的写操作修改的值,即使这个事情发生了,也并不意味着发生在之后的读操作一定可以观察到在之前的写操作 在这个程序中:
var a, b int
func f() {
a = 1
b = 2
}
func g() {
print(b)
print(a)
}
func main() {
go f()
g()
}
g()
可能会打印2和0
双重检查机制是一种避免上面Once
的例子中同步操作的尝试。下面这个情况,twoprint程序写成这样可能是错误的
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func doprint() {
if !done {
once.Do(setup)
}
print(a)
}
func twoprint() {
go doprint()
go doprint()
}
这里没有任何保证在doprint中,观察到对done
的写入意味着观察到对a
的写入。这个版本的代码可能会打印空字符串而不是"hello, world"
另一个错误版本是频繁的等待一个值,像下面这样:
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func main() {
go setup()
for !done {
}
print(a)
}
和上面的程序一样,没有任何的保证观察到对于done
的写操作意味着观察到对于a
的写操作,所以这个程序也可能会打印空字符串。更糟的是,没有保证main函数可以观察到对done
的写操作,因为在两个线程间没有同步事件,main函数中的这个循环不能保证正常退出
对于上面的这个问题还有变体,例如下面这段代码:
type T struct {
msg string
}
var g *T
func setup() {
t := new(T)
t.msg = "hello, world"
g = t
}
func main() {
go setup()
for g == nil {
}
print(g.msg)
}
即使main观察到了g != nil
并且结束循环,也没有保证可以观察到对于g.msg
的初始化操作
在所有的这些例子中,解决方案是相同的: 使用明确的同步操作
小结
这篇文章阐述了Go内存模型中一个比较重要的概念: happends-before的语法,从这篇文章中我学习到了由于可能存在的编译器/处理器优化,所以在我们程序中定义的一些执行顺序可能会被重排,需要使用同步事件保证当前的读操作获得它想要的结果,当然翻译中如果有有问题的地方,欢迎大家在评论区指出