The Go Memory Model
Introduction
Go的内存模型指定了一个数据共享、可见条件,这个条件保证在一个goroutine中写入一个数据,另外的goroutine对相同的数据读取时可见。
Advice
程序对多个goroutines同时访问的数据的修改,必须序列化对该数据的访问(包括读和写)。要对数据进行序列化访问,可以使用channel或sync
和sync/atomic
包中提供的同步原语。
Happens Before
在一个goroutine里面,读和写操作必须按照程序指定的顺序执行。也就是说,编译器和处理器只有在不会改变程序定义的goroutine的行为的情况下才会乱序执行一个goroutine的读和写操作。因为乱序的原因,一个goroutine看到的执行顺序可能跟其他goroutine感受到的不一样。比如一个goroutine执行a = 1; b = 2
,其他的goroutines可能先获取到b
的更新,然后才是a
的更新。
我们定义了happen before
来指定读取和写入, happen before
是Go程序中执行内存操作部分的顺序。如果事件e1
在事件e2
之前发生,我们则说e2
在e1
之后发生。同样,如果e1
不是发生在e2
之前,又不是发生在e2
之后,我们则说e1
和e2
并行发生。
在一个goroutine里面,happen before
顺序是程序表达的顺序。
在以下两个条件都成立时写操作w
写变量v
,对读操作r
对变量v
的读取可见。
r
不在w
前发生;- 在
w
之后,r
之前没有其他的w'
写v
.
为了保证r
读v
看到的就是特定的w
写的,需要确保w是r被允许观察到的唯一一个写操作。也就是说下面两个条件都满足时,保证r
能够看到w
写的值:
w
在r
之前发生;- 任何其他对共享变量
v
的写操作都发生在w
之前或r
之后。
这两个条件比前两个条件更严格。它要求没有其他的写操作与w
和r
并发。
在一个goroutine里面,因为没有并发,所以前面两种定义是相等的:一个对v
的读总是可见最近的一个对w
的写操作。当有多个goroutines访问一个共享的v
时,就需要引入同步机制来建立happen-before
条件来确保读操作见到的是它期望的写操作。
将变量v
初始化为它的类型零值在内存模型中被当成一次写操作。
对一个占用空间大于一个机器字的数据的读写表现为对多个机器字的读写,读写顺序不能指定(未知,可能被编译器和处理器乱序)。
Synchronization
Initialization
程序初始化运行在一个goroutine里面,但是这个goroutine可能会创建和并发运行其他goroutines。
如果一个包p
import了另一个包q
,q
的init
函数将在p
的任何代码之前执行。main.main
函数在所有import包的init
函数执行完后开始执行。
Goroutine Creation
go
语句启动一个新的goroutine比这个goroutine开始执行先发生。
例如,下面代码中调用hello
会在程序的某个点打印"hello, world",这个点可能是在hello
返回之后。
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
Goroutine destruction
Goroutine的退出并不能被保证是发生在程序的任何事件之前。例如下面的代码中,对a
的复制没有跟任何的同步事件,所以这个复制操作并不保证对任何其他Goroutine可见。事实上比较激进的编译器可能还会移除掉这一整行代码。
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}
如果一个goroutine的影响必须对其他goroutines可见,使用像lock
或channel
通信这样的机制来建立一个相对的顺序。
Channel Communication
通过channel
通信是goroutines之间同步的主要方法。每一个对特定channel的写都对应一个对应的对该channel的读操作,通常这发生在不同的goroutines里面。
对一个channel的写发生在对对应的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
的赋值发生在写channel前,写channel又发生在读channel完成前,所有这些都发生在打印前。
在从一个channel读取数据时channel已经被关闭,将返回一个零值
上面的示例代码中,将c <- 0
替换为close(c)
将产生一个行为相同的程序。
从一个不带缓冲的channel读取数据发生在向这个channel发送数据完成之前
下面这段代码跟前面的一样,只是互换到了channl发送和接收的位置,且使用的是不带缓冲的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
的赋值发生在接收channelc
之前,接收channel发生在写channel完成前,所有这些都发生在打印前。
如果把channel改成带Buffer的(例如:c := make(chan int, 1)
),然后这段程序就不能保证还能打印"hello, world"了。(它可能会打印空字符串,崩溃甚至做任何其他事情。)
从一个容量为C的带缓冲channel读取第k次发生在第k+C次写入完成之前(例如第2次读取发生在第2+C次写之前,这就不会造成2+C次写阻塞)
这条规则将之前的规则概括到了带缓冲的channel上。它允许计数信号量(counting Semaphore)通过带缓冲channel的方式建模:channel中实际数据的数量对应实际使用的数量,channel的容量对应可同时使用的最大数量。发送一个数据获得一个信号量,读取一个数据释放一个信号量。这是限制并发数量的一个常用做法。
下面的代码为work列表中的每个入口都起了个goroutine,但是所有这些goroutines之间使用limit
channel协调,确保同时只有3个goroutines执行。
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()
前。
如下面这段程序保证能打印“hello,world",第一次调用l.Unlock()
(在f()
中)发生在第二次调用l.Lock()
(在main()
中)之前,这些都发生在打印数据之前。
var l sync.Mutex
var a string
func f() {
a = "hello, world"
l.Unlock()
}
func main() {
l.Lock()
go f()
l.Lock()
print(a)
}
对于sync.RWMutex
变量l
上的任何l.RLock()
调用,存在一个n
,就是l.RLock()
发生在n
次调用l.Unlock()
之后,然后与之匹配的l.RUnlock
发生在n+1
次l.Lock
调用之前。
Once
sync
包通过Once
类型提供了在多个goroutines存在的情况下进行初始化的安全机制。对于一个特定的f
多个线程都可以执行once.Do(f)
,但是最终实际只有一个线程会运行f()
,并且其他的线程都将被阻塞直到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
将打印两次”hello,world“,但是setup
只会被执行一次。
Incorrect synchronization
注意一个读取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()
}
上面的代码中,g()
可能会先打印2
然后再打印0
。这个事实使一些常见的惯用语法无效。(编译器或处理器乱序执行造成的。)
双重检查锁是一种降低同步开销的尝试。例如,twopoint
程序可能被错误的写成下面的样子:
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
的写操作就意味着doprint
能够看到对a
的赋值操作。所以这个版本可能会错误的打印一个空字符串,而不是打印"hello, world"。
另一种错误的语法是一直等(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
的操作。所以这段代码也可能会打印一段空字符串。更坏的情况是,因为没有一个同步机制,这里setup
中对done
的写操作可能并不一定能被main
看到(比如有其他的goroutine在main看到前又将done设成了false),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
的赋值g=t
使得g != nil
,这也并不能保证g
就已经获得了初始化后新赋的msg
值.
结论
前面所有的示例都指向了一个相同的解决方案:使用显式的同步。