
本文来自小天同学的投稿,233作为一个不会Golang的Java渣渣都看懂一些了。看完只能感慨是时候开始学Go了!强烈推荐给正在学习Golang或者对Golang感兴趣的小伙伴,建议收藏~
前言
本文主要是对The Go Memory Model一文的翻译,也想借此机会加深对golang内存模型的理解。
介绍
go内存模型规定了某个goroutine的读操作保证能观测到来自其他goroutine对这个变量的写操作的一组条件。
建议
那些数据在修改的同时如果被其他goroutine访问到,必须串行化(serialize)这种访问才能保证数据的安全性。
为了串行化访问,通过channel或者其他同步原语比如sync
和sync/atomic
包来保护数据。
如果你必须阅读这篇文档剩下的内容以理解你的程序的行为,你将会变得太聪明。
永远不要聪明。
Happens Before
在单个goroutine里,读和写必须表现得好像它们在按照程序指定的顺序执行。
也就是,编译器和处理器可能重排序(reorder)单个goroutine内执行的读和写,当然重排序不会改变语言规范所定义的在这个goroutine内的行为。因为重排序的存在,一个goroutine观测到的执行顺序可能和其他goroutine观测到的不同。
举个栗子,如果一个goroutine执行a = 1; b = 2;另外一个goroutine可能观测到对变量b的更新先于a
为了规定读和写的依赖,我们定义了happens before,一个部分有序的执行
。
如果事件e1 happens before 事件e2,那我们说e2 happens after e1。另外,如果e1没有happens before e2同时e2没有happens before e1,那我们说e1和e2并发地发生。
在单goroutine环境,happens-before顺序就是程序所表达的顺序。
在下面两个条件满足时,对变量v的读r允许观测到对v的写w:
1. r没有happen before w
2. 没有其他对v的写w' happens after w并happen before r
要保证对变量v的读观测到对v的特定写w,w是唯一允许被r观测到的写。即是,r被保证观测到w需要同时满足下列两个条件:
1. w happens before r
2. 任何其他对变量v的写要么happens before w要么happens after r
这对条件比第一对更强,它需要没有其他写和w或者r并发地发生。
笔记:第一对条件是允许观测,第二对条件是保证观测,约束强度不一样。
在单goroutine环境,没有并发,所以这两个定义是等价的:一个读r观测到最近的对这个变量的写w。
当多个goroutine同时访问一个共享变量v,它们必须使用同步事件来建立happens-before条件以保证读观测到想要的写。
对变量v的零值初始化的行为像内存模型的写。
对大于一个机器字word的变量的读和写的行为像多机器字尺寸(multiple machine-word-sized)操作一样未定义顺序。
笔记:比如32位系统的一个字word是4byte,对int64类型的变量v的写和读,可能发生goroutine1写了4个byte,goroutine2读了v的值,goroutine1继续写v剩下的4个byte这种顺序
同步
初始化
程序开始时运行在单个goroutine,但是这个goroutine可能创建其他goroutine并发运行。
如果一个包p导入包q,q的
init
函数的完成happens before任何p的init
开始前。main.main
函数的开始happens after所有init
函数已经结束。
goroutine创建
开始一个新goroutine的 go
语句happens before这个goroutine执行开始*
比如下面这个栗子:
var a string
func f() {
print(a)
}
func hello() {
Step1 : a = "hello, world"
Step2 : go f()
}复制
调用hello
一定会打印"hello, world",后者发生在a赋值的未来的某个时间点(可能在hello
函数返回后)。
goroutine销毁
goroutine的退出不保证happens before程序中的任何事件。比如下面这个程序:
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}复制
这个赋值没有跟随任何同步事件,所以不保证被任何其他goroutine观测到。实际上,激进的编译器可能删除这整个go语句(go statemennt,即go func() { a = "hello" }()
这行)。
如果一个goroutine的作用必须被其他goroutine观测到,使用一个同步机制比如一个锁或者channel通信来建立相对关系。
channel通信
channel通信是goroutine间主要的同步方法。任何在一个特定channel的send匹配一个在这个channel上的相应receive,通常这个receive在另一个goroutine。
一个channel上的send happens before这个channel相应receive的完成
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的写happens before c的send,happens before c的相应的receive的完成,happens before print
。
笔记:这里没解释为什么往a的写happens before c的send,个人理解channel的send前添加了一个内存屏障,保证了其他线程观察到的a的写happens before c的send
channel的关闭过程happens before 这个channel上的receive返回零值
笔记:这里很好理解,channel的close操作可以看作send。
在一个unbuffered channel上的receive happens before channel的send完成
笔记:这条有点反直觉
var c = make(chan int)
var a string
func f() {
a = "hello, world"
<-c
}复制
func main() {
go f()
c <- 0
print(a)
}复制
这个程序(和上一个类似,除了send和receive语句交换了然后用了一个unbuffered channel)同样保证打印"hello, world"。往a的写happens before c的receive,happens before相应的c上的send的完成,happens before print
函数。
如果这个channel是buffered的(比如: c = make(chan int, 1)
),那这个程序不会保证打印"hello, world"。(它可能打印空字符串,宕掉,或者做其他事情)
在一个容量C的channel上的第k个receive happens before那个channel上第k+C个send的完成
这个规则推广了前一个buffered channel规则。它允许通过buffered channel建模一个计数信号量(semaphore):channel里的item个数对应活跃用户数目,channel的容量对应最大可同时使用量,send item申请(acquire)信号量,receive item释放(release)信号量。这是一个实现有限并发的常见模式。
这个程序为工作列表里的每一个条目开启一个goroutine,但是这些goroutine用了一个有限的channel来确保最多有3个工作在同时运行。
var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func(w func()) {
limit <- 1
w()
<-limit
}(w)
}
select{}
}复制
锁
sync
包实现了两种锁数据类型,sync.Mutex
和 sync.RWMutex
。
对任何sync.Mutex
或者sync.RWMutex
变量l,假设n < m,调用第n个l.Unlock()
happens before第m个l.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"。第一次调用l.Unlock()
(函数f里)happens before第二次调用l.Lock()
(函数main里)返回,happens before print
。
笔记:这里存在和channel同样的问题,没有解释为什么第二次调用
l.Lock()
happens before
,原因猜测和channel一样l.Lock()
会添加一个内存屏障保证happens before关系)
对于任何调用l.RLock
在一个sync.RWMutex
变量l,有一个这样的n使得l.RLock
的返回happens after第n个调用l.Unlock
,而且与这个l.RLock
配对的l.RUnlock
happens before 第 n+1 个l.Lock
。
笔记:定义比较晦涩,其实描述的规则很简单,读锁在写锁释放后或写锁不存在的条件下才能获取,写锁要在所有存在的读锁释放后才能获取)
Once
sync
包提供一种安全的机制应对多个goroutine的并发初始化,即Once
类型。多个线程可以执行[once.Do(f](once.Do(f))
(这里f是一个函数),但是只有一个goroutine可以执行f(),而其他goroutine的调用会阻塞直到f()返回。
来自 [once.Do(f](once.Do(f))
的单次对f()调用happens before任何[once.Do(f](once.Do(f))
调用的返回
var a string
var once sync.Once
func setup() {
a = "hello, world"
}
func doprint() {
[once.Do(setup](once.Do(setup))
print(a)
}
func twoprint() {
go doprint()
go doprint()
}复制
这个程序调用twoprint
将会只调用setup
一次。setup
函数将会在任何print
调用前完成。程序的结果是"hello, world"将会被打印两次。
不正确的同步
注意一个读r可能观测到一个和r并发写w产生的值。即使这种情况发生了,也不能说明happens after r的读将会观测到happens before w的写。
var a, b int
func f() {
a = 1
b = 2
}
func g() {
print(b)
print(a)
}
func main() {
go f()
g()
}复制
这个程序里可能发生g打印2然后打印0 。
这个事实让一些常见模式无效。
Double Check是一种避免同步开销的方式。举个栗子,下面这个twoprint
程序的行为可能不正确:
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func doprint() {
if !done {
[once.Do(setup](once.Do(setup))
}
print(a)
}
func twoprint() {
go doprint()
go doprint()
}复制
不能保证观测到done
的写入意味着可以观测到a的写入。这个版本可能错误地打印出一个空字符串而不是"hello, world"。
笔记:once只能保证once和f()的happens before关系,但是不能保证once的f()和其他非once的happens before关系)
另外一个不正确的模式是在一个值上的忙等待(busy waiting)
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func main() {
go setup()
for !done {
}
print(a)
}复制
和前一个一样,在main函数里,不能保证观测到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内存模型规范里其实缺失了很重要的一项,对原子操作的规定,在Java里原子操作是可以保证memory order,golang的当前编译器实现也是保证了memory order。不过如果想编写跨编译器,跨编译器版本的兼容代码,安全的建议是在一般go程序中不要依赖原子操作来保证memory order。
引申阅读:
[1].https://go101.org/article/memory-model.html
[2].https://github.com/golang/go/issues/5045
在看
多了,优秀的小天同学可能还会给我们出下篇~