如果补上这三篇文章的链接,本文可以作为一篇学习Go的教程。作者通过一天从零到写一个Hello World的Go WebApplication出来,全靠了这些官方文档。
本文主要是为了理解Go的内存模型而编写的,在这里放一下原文链接
导引
Go内存模型可以确保在不同的Goroutine中,对于同一个变量的操作,Read Goroutine可以观察到Wirte Goroutine所作出的更改。
一般来说,对于被多个Goroutine访问的变量的更改,更改者必须串行化这些访问操作。
对于Go来说,除了使用管道进行多Goroutine之间的通信之外,还可以使用Go自带的同步原语,比如sync包和sync/atomic包下面的一些方法。
Happens-Before原则
这条原则在支持并发的语言中基本都能看到,如果读者之前有了解过,那么阅读Go的原则则会轻松很多。
首先,希望读者了解现代处理器的乱序执行和流水线操作(如果不熟悉可以在我的主页看我的这篇文章)。
正因为CPU的这些操作,有时我们需要对源码编译而成的指令进行重新排序,使其更好地填充流水线,而被重排的指令之间一般不会存在数据依赖。
现代编译器会确保程序指令在被重新编排后,其执行行为依旧符合编写行为。举个小小的例子:a := 1和b := 2如果被排列成了b := 2, a := 1也不会有什么问题,但是这在多线程(Go里称为多Goruntine)里可能会导致一些问题(下面会说)。
还是接着上面的例子,在多Goruntine中,GoruntineA可能观察到对于a的更新先于b,但是另一个GoruntineB可能会观察到对于b的更新先于a。
为了解决此问题,我们引入了Happens-Before原则,用来指出Go程序对于内存操作的确切顺序。对此阐述如下:
- 如果事件A先于时间B发生,我们就说时间B后于时间A发生;
- 同样,如果事件A既不先于时间B发生,也不后于时间B发生,我们就说这两个事件同时发生(并发地发生)。
在单个Goruntine中,Happens-Before顺序就是我们程序编写的顺序。
一个Read如果想要观察到一个Write对同一个变量V所作出的更改,就必须满足如下两个准则:
- Read不会先于Write发生。
- 在Read和Write之间不会有其他的Write。
为了确保Read能够观察到某个确切的Write对同一个变量V所作出的更改,我们又有如下约束:
- Write先于Read发生。
- 其他Write要么先于这个W,要么后于Read发生。
后面这对约束比前面那对约束有力得多,因为它指出了不能有其他Write和这个Write或Read并发。
在单Goruntine中,并不会涉及到并发,所以上面两队准则是等价的,但是在涉及到多Goruntine的地方,往往需要引入同步来实现Happens-Before原则。
顺带一提,创建变量时的赋零值操作属于一个Write。
同步
一个程序的初始化操作会在一个单独的Goruntine中运行,此时调用程序可能运行在另一个Goruntine中,这就会涉及到并发问题。
如果一个包A引用了包B,那么B的init操作必须先于A的init,而且main方法必须等待所有init完成才能执行。
创建一个Goruntine
使用关键词go即可创建一个Goruntine
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
Goruntine的退出
当go创建的函数执行完毕,一个Goruntine就结束了。但是无法保证一个Goruntine的退出先于当前Goruntine的任何事件。来看一个例子:
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}
此时,没有任何同步方法来确保对a的赋值先于a的读取,所以完全可能在hello执行完毕才会对a赋值,如果运气好的话,可以看到程序打印"hello"。
聪明一些的编译器会直接删除赋值函数,因为它可能因为执行顺序而不起作用。
为了保证程序输出"hello",可以使用同步机制,比如channel和sync的一些方法。
Channel通信
管道通信是Go在多Goruntine之间通信的一种方式,不同于其他语言的“通过共享内存来通信”,Go选择了“通过通信来共享内存”。
Go的管道通信是实现多Goruntine的主要方式,一般来说就是一个Sender向一个Channel发送一个数据,另一端的Receiver接收即可。
来看一个示例:
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world"
c <- 0
}
func main() {
go f()
<-c
print(a)
}
对于一个Channel,在无缓冲的情况下,发送者会一直阻塞直到接收者接收完成,相应的,接收者会一直阻塞直到发送者发送完成,或者管道关闭。而在Channel有缓冲情况下,则分别对应缓冲池满和缓冲池为空。
所以上述代码又可进行如下更改:
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world"
close(c)
}
func main() {
go f()
<-c
print(a)
}
和:
var c = make(chan int)
var a string
func f() {
a = "hello, world"
<-c
}
func main() {
go f()
c <- 0
print(a)
}
如果使用带有缓冲区的Channel,那么可以做到类似信号量的操作,当缓冲区不够时,接收者会被阻塞,当缓冲区满时,发送者会被阻塞。
Locks
sync包实现了两种锁类型:sync.Mutex和sync.RWMutex。
来看一个示例:
var l sync.Mutex
var a string
func f() {
a = "hello, world"
l.Unlock()
}
func main() {
l.Lock()
go f()
l.Lock()
print(a)
}
正如其他语言的锁一样,锁的获取必须需要锁的释放,任何一把锁的lock操作都必须有一个先于它的unlock操作,初始状态锁是unlock的。
Once
sync包提供了一种在多Goruntine情形下的安全初始化操作,那就是Once。可以使用once.Do()方法来保证只会运行一次Do()里面的方法,其他对于Do()的调用都会阻塞,直到Do()里面的方法完成,程序继续执行Do()后面的代码,注意⚠️,其他Do()里面的方法不会再执行,因为只会执行一次。
来看示例代码:
var a string
var once sync.Once
var counter int
func setup() {
counter++
a = "hello, world"
fmt.Println(counter)
}
func doprint() {
once.Do(setup)
fmt.Println(a)
}
func main() {
counter = 0
go doprint()
go doprint()
go doprint()
time.Sleep(time.Second * 1)
}