1 指令顺序调整
对于单goroutine程序代码,编译器和处理器有时会调整源码中的指令顺序来做一些优化。当然,此类调整在当前gorouine程序来看并不会改变其源码指令所指定的行为。但在多个线程共享内存的情形下,某个goroutine内部的指令顺序调整可能会影响到依赖其指令顺序的其他goroutine的行为。
看一段代码:
package main
import "fmt"
var s string
var done bool
func setup() {
s = "hello world"
done = true
if done {
fmt.Println(s)
}
}
func main() {
go setup()
for !done {
}
fmt.Println(s)
}
如上代码,main函数等待setup将s赋值成功,期待打印出“hello world”。但main函数打印结果可能与预期不同,有可能打印为空串。原因在于该代码受编译器版本或运行时影响,即当前程序使用不同的编译器版本或在不同体系结构系统上运行时,结果可能不同。
原因在于如上代码中的setup函数的两行赋值语句指令顺序可能会被编译器或运行时CPU更改,即变为:
func setup() {
done = true
s = "hello world"
if done {
fmt.Println(s)
}
}
更改后,执行setup的goroutine本身的行为未受影响,其打印结果总会是“hello world”。
但依赖done变量写入的main函数goroutine的行为会受影响,其打印结果不一定是“hello world”。
2 Golang内存顺序保证
从如上例子可以看出,并发场景下,为保障程序逻辑正确性,需要想办法保障不同goroutine中的代码执行先后顺序。
不同的CPU体系结构提供不同的fence指令来防止指令顺序重排。而直接在代码中使用fence来作逻辑控制,抬高了并发编程的门槛。Golang并未内置直接操作CPU fence指令的函数或方法,而是提供了诸多“happens before”(先于)机制来保障程序执行顺序。
a)当前包所有包级变量初始化先与init函数执行;
b)依赖包init函数执行先于当前包包级变量初始化;
c)所有依赖包包级变量初始化与init函数执行均先于main函数执行。
所以一个包含依赖包的程序的初始化执行顺序为:
依赖包包级变量初始化 < 依赖包init函数执行 < 当前包包级变量初始化 < 当前包init函数执行(<表示先于)。 下面用一段代码证明上述初始化顺序。 在$GOPATH/src/github.com/p下有这样一段代码p.go:
package p
import "fmt"
var a = func() int { fmt.Println("variable init in p"); return 1 }()
func init() {
fmt.Println("p init")
}
在$GOPATH/src/github.com/test下的main.go依赖了p包,代码如下:
package main
import (
"fmt"
_ "github.com/p"
)
var b = func() int { fmt.Println("variable init in main"); return 2 }()
func init() {
fmt.Println("main init")
}
func main() {
}
运行main.go,输出结果为:
variable init in p
p init
variable init in main
main init
a)一个goroutine的创建先于其执行。
例如,如下代码:
var a, b string
func f() {
a = "hello"
go func() {
fmt.Println(a)
b = "world"
go func() {
fmt.Println(b)
}()
}()
}
f函数中,a的赋值先于fmt.Println(a);b的赋值先于fmt.Println(b),其打印结果总是:
hello
world
goroutine销毁(无顺序保证):
goroutine的销毁并未有先于程序任何事件点的保障。
请看如下代码:
var a string
func f() {
go func() {
a = "hello"
}()
fmt.Println(a)
}
在未加任何同步的情况下,a在一个goroutine是否赋值成功,对任何其他需要“observe”其值的goroutine是没有保证的。
所以若一个goroutine需要“observe”另一个goroutine,请使用同步机制(如锁)或使用Channel通信来保证执行顺序。
a)一个Channel的发送操作先于发送操作完成;
b)一个Channel的接收操作先于接收操作完成;
c)不论是Buffered Channel还是Unbuffered Channel,一个Channel的第N个成功发送先于第N个成功接收完成;
d)一个容量为M的Channel的第N个成功接收先于第N+M个成功发送完成(特别当M=0时,其为Unbuffered Channel,其第N个成功接收先于第N个成功发送完成);
e)一个Channel的关闭先于接收完成(Channel关闭时返回“零值”)。
看一段代码:
package main
import "fmt"
var a string
func main() {
done := make(chan bool, 3)
go func() {
a = "hello world"
done <- true
}()
<-done
fmt.Println(a)
}
这段代码输出“hello world”是有保证的。因a的写入先于done的发送,done的发送先于done的接收完成,done的接收完成先于a的打印。
若将如上代码稍作改动,将done的发送改为done的close,其仍可以保证打印结果为“hello world”。
package main
import "fmt"
var a string
func main() {
done := make(chan bool, 3)
go func() {
a = "hello world"
close(done)
}()
<-done
fmt.Println(a)
}
原因是,Channel的关闭要先于接收完成。
再看一段代码:
package main
import "fmt"
var a string
func main() {
done := make(chan bool)
go func() {
a = "hello world"
<-done
}()
done <- true
fmt.Println(a)
}
这段代码将Channel通信中第一段代码的发送与接收互换位置,并改用Unbuffered Channel,仍可以保证打印结果为“hello world”。原因在于,a的写入先于done接收,done接收先于done发送完成,done发送完成先于a的打印。
上述规则中的规则d)可以使用Buffered Channel的容量作并发限制。
如下代码,在同一时刻至多有2个work()在执行。
package main
import (
"fmt"
"time"
)
func main() {
works := []func(){
func() { fmt.Println("working 0") },
func() { fmt.Println("working 1") },
func() { fmt.Println("working 2") },
func() { fmt.Println("working 3") },
func() { fmt.Println("working 4") },
func() { fmt.Println("working 5") },
func() { fmt.Println("working 6") },
func() { fmt.Println("working 7") },
}
limit := make(chan int, 2)
for _, work := range works {
go func(func()) {
limit <- 1
time.Sleep(time.Second)
work()
<-limit
}(work)
}
select {}
}
a)对于sync.Mutex或sync.RWMutex变量l,第N个l.Unlock()调用先于第N+1个l.Lock()调用返回;
b)对于sync.RWMutex变量l,第N个l.Unlock()调用先于第N个l.RLock(),l.RUnlock()调用先于第N+M(M>=0)个l.Lock();
请看如下代码:
package main
import (
"fmt"
"sync"
)
var a string
var l sync.Mutex
func main() {
l.Lock()
go func() {
a = "hello world"
l.Unlock()
}()
l.Lock()
fmt.Println(a)
}
可以保证其打印结果为“hello world”,因启动的goroutine中第一次l.Unlock()调用先于main中第二次l.Lock()调用返回。
接下来将Mutex改为RWMutex,代码如下:
package main
import (
"fmt"
"sync"
)
var a string
var l sync.RWMutex
func main() {
l.RLock()
go func() {
a = "hello world"
l.RUnlock()
}()
l.Lock()
fmt.Println(a)
}
同样可以保证打印结果为“hello world”,因启动的goroutine中第一次l.RUnlock()调用先于main中第一次l.Lock()。
同理,若改为如下方式,同样可以保证打印结果。
package main
import (
"fmt"
"sync"
)
var a string
var l sync.RWMutex
func main() {
l.Lock()
go func() {
a = "hello world"
l.Unlock()
}()
l.RLock()
fmt.Println(a)
}
因启动的goroutine中第一次l.Unlock()调用先于main中第一次l.RLock()。
如下代码,setup函数仅会执行一次。
package main
import (
"fmt"
"sync"
"time"
)
var a string
var once sync.Once
func setup() {
fmt.Println("setup calling")
a = "hello world"
}
func main() {
for i := 0; i < 4; i++ {
go func(i int) {
once.Do(setup)
fmt.Println(a, i)
}(i)
}
time.Sleep(time.Second)
}
输出结果为:
setup calling
hello world 2
hello world 3
hello world 0
hello world 1
额外注意:若main函数在新启goroutine时,未将i提取为函数参数,会发生多个goroutine重用最后i值的情况。
如如下代码所示:
func main() {
for i := 0; i < 4; i++ {
go func() {
once.Do(setup)
fmt.Println(a, i)
}()
}
time.Sleep(time.Second)
}
打印结果为:
setup calling
hello world 4
hello world 4
hello world 4
hello world 4
所以新启动程序时,宿主函数的参数使用要注意将其放入新的函数的参数列表或者在goroutine启动前使用原变量值的新实例。如如下代码所示。
func main() {
for i := 0; i < 4; i++ {
i := i
go func() {
fmt.Println(i)
}()
}
...
}
参考资料
[1] https://golang.org/ref/mem
[2] http://nil.csail.mit.edu/6.824/2016/notes/gomem.pdf
[3] https://go101.org/article/memory-model.html
[4] https://medium.com/@edwardpie/understanding-the-memory-model-of-golang-part-1-9814f95621b4
[5] https://medium.com/@edwardpie/understanding-the-memory-model-of-golang-part-2-972fe74372ba