Go内存模型
内存模型介绍
Go语言的内存模型规定了一种规则。这种规则可以保证,在一个goroutine读取某个变量的值,是其他goroutine对同一个变量写入的值。
建议
程序中多个goroutine同时修改相同数据,必须使之能够按序访问。为了按序访问以及保护数据,可以使用channel操作或者其他同步原语例如sync及sync/atomic包。
If you must read the rest of this document to understand the behavior of your program, you are being too clever.
Don’t be clever.
Happens-Before原则
在单个goroutine中,读取和写入的操作一定与程序的执行顺序一致。 也就是说,仅当重新排序语句不会改变语言规范所定义的该goroutine中的行为时,编译器和处理器才可以对单个goroutine中执行的读取和写入进行重新排序。 由于此重新排序,一个goroutine观察到的执行顺序可能不同于另一个goroutine所察觉的执行顺序。 例如,如果一个goroutine执行a = 1; b = 2;
,另一个goroutine可能会a的值更新前,就已经观察到b的更新后的值。
在Go程序中,为了限定读取和写入的要求,我们定义happens-before
原则,此原则限定了程序在读写内存时的一种偏序关系。 如果事件e1发生在事件e2之前,那么我们说e2发生在e1之后。 同样,如果e1不在e2之前发生并且在e2之后也没有发生,那么我们说e1和e2同时发生。
Happen-Before是一个术语,不仅仅是go才有的。
原始的定义是由Leslie Lamport在1978年的文章 Time, Clocks and the Ordering of Events in a Distributed System中提出来的
另见维基百科 https://en.wikipedia.org/wiki/Happened-before
在单个goroutine中,happens-before发生顺序即是程序中的顺序。
如果同时满足下述两个规则,那么对变量v的读操作r能够观察到对变量v的写操作w:
- 读操作r没有发生在写操作w之前
- 没有另外一个针对变量v的写操作w`发生在,写操作w之后且在读操作r之前
为了保证对变量v的读操作r能够观察到对变量v的特定的写操作w的值,即确保读操作r可以观察到写操作w是唯一的对变量v的写操作。 那么,如果满足下述两个原则,读操作r将能够观察到写操作w
- w发生在r之前
- 任何其他的对共享变量v的写操作w`发生在写操作w之前或者读操作r之后
这对条件的约束强于前面一对条件的约束;它需要没有其他任何的写操作为w`,与写操作w 或 读操作r同时发生。
在单一的goroutine中,由于没有并发,因此两个定义也是等价的:读操作r可以观察到最近的写操作w写入到v的值。当多个goroutine访问一个共享变量v时,必须使用同步事件建立happens-before
条件来确保读操作观察到期望的写操作的变化。
使用变量v的类型零值初始化变量v的行为与内存模型中写操作相同。
如果读取和写入的值大于一个机器字,那么多个机器字节的操作顺序不是固定的顺序。
同步
初始化
程序初始化操作是在单个goroutine中,但是该goroutine可能会创建的其他goroutine,这些goroutines是并发运行的。
如果包p导入了包q,则q包中的的init函数的完成发生在任何p包中的init函数开始之前。
函数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之间同步数据的主要方法。 通常在不同的goroutine中,将特定channel上的每个发送与该channel上的相应接收进行匹配。
channel上的发送 happens-before 该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的发送一定会先于c接受的完成,因此这些都先于print调用完成
happens before 具有传递性
关闭一个channel happens-before channel收到返回零值, 因为channel已经关闭了
在前面的例子中,如果使用close(c)
代替c <- 0
,能够保证相同的行为。
从一个无缓冲的channel接收数据 happens-before 向这个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的写操作发生在channel变量c的接收之前, c的接收发生在c的发送之前,c的发送发生在print打印之前。
如果是带缓冲的channel(例如c = make(chan int, 1)
),那么程序则不一定能保证会打印“hello,world”。(可能打印空字符串或者crash、或者其他未知情况)
在缓冲大小为C的chanel上接收第k个数据 happens-before 在第 k + C发送完成之前。
该规则将前一个规则(无缓冲的channel)推广到缓冲channel。 它允许通过缓冲的channel对计数信号量进行建模:channel中的项目数量对应于活动使用的数量,channel的容量对应于同时使用的最大数量,发送一个项目获取信号量,以及 接收项目会释放信号量。 这是限制并发性的常见用法。
该程序为工作列表中的每个条目启动一个goroutine,但是goroutine使用限制channel进行协调,以确保一次最多运行三个work。
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 类型的变量l。 如果 n < m ,那么第n次 l.Unlock() 调用在第 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中)发生在第二次调用l.Lock()
之前(在main函数中),第二次调用l.Lock()
发生在print之前。
上面的例子相当于 n=1, m=2
在一个sync.RWMutex变量l上任何调用 l.RLock,都有一个n,使得l.RLock在调用n个l.Unlock之后发生(并返回),并且匹配的l.RUnlock发生在调用第n + 1个l.Lock之前。
Once
这个同步包为多个goroutines进行初始化只执行一次的操作,提供了一种安全的机制,多个线程可以执行once.Do(f)
,那么函数会且只会有一个执行f函数,而其他调用将阻塞直到f函数的返回。
使用once.Do(f)执行f()并返回,发生在任何调用once.Do(f)返回之前。
下面程序
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函数将在两次调用打印之前完成。 结果将是“ hello,world”将被打印两次。
错误的同步
注意,读操作r可能会观察到与r同时发生的写操作w写入的值。 即使发生这种情况,也并不意味着在r之后发生的读取将观察到在w之前发生的写入。
var a, b int
func f() {
a = 1
b = 2
}
func g() {
print(b)
print(a)
}
func main() {
go f()
g()
}
有可能打印出2和0
即b已经赋值为2,但是a还未被赋值
这个事实使一些常见的习语无效。
双检锁是为了避免同步的开销,例如,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)
}
上述示例所示,在主函数main中,观察到done值为true并不能保证一定能看到对a的赋值变化, 因此本示例程序也有可能打印空值。 更糟糕的是,setup函数对done=true的赋值,并不一定能被main函数观察到,因此这两个线程之间并没有使用任何的同步事件。 因此for循环在主函数中并不能保证一定会循环结束。
下述为一些更为难以发现的变体
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)
}
即使主函数观察到g != nil
且退出了循环,也不能保证main能够观察到g.msg
的赋值内容。
对于上述的所有错误示例,都可以采用同样的解决办法:使用明确的同步方式。