1. Go内存模型基础
Go内存模型是指规定goroutine如何与共享变量进行交互的一组规则。对于Go来说,内存模型是在语言层面做的,而非编译层面。Go1.7版本之后采用了新的内存模型Happens-Before,下面我们先来了解下Go内存模型的基础知识。
在Go内存模型中,内存被抽象成由一系列的字节(byte)组成的,每个字节都有对应的内存地址。与此同时,Go内存模型还定义了共享变量与普通变量之间的区别。共享变量指的是会被若干个goroutine同时访问的变量,比如我们常用的全局变量或者是在程序中通过channel传递的变量等。普通变量则指只会被单个goroutine访问的变量。
在Go内存模型中,goroutine使用的是基于cache的本地视图(Local View)来访问内存的。这个本地视图又被称为goroutine的工作内存(Working Memory),它保存了共享变量和普通变量的副本。这些副本只会存放在该goroutine自己的工作内存中,而不会被其他goroutine所访问。
当多个goroutine之间需要对同一个共享变量进行操作时,它们会通过通信或者同步来实现对内存的访问。另外,Go内存模型还规定了对于同一个goroutine来说,在程序中的指令执行顺序必须要符合其在源代码中的顺序。
1.1 内存访问同步
由于goroutine之间的工作内存是独立的,因此一个goroutine修改了工作内存中的变量的值后,并不能保证这个变量的新值会立即被其他goroutine感知到。因此,Go提供了一些同步机制来协调多个goroutine之间对共享变量的访问。
1. 互斥锁
互斥锁(Mutex)是Go最基本的一种同步原语,它提供了两个方法Lock和Unlock来锁定和解锁某个共享资源,以保证同一时刻只有一个goroutine能够访问该资源。
var mu sync.Mutex //定义一个全局的互斥锁
func f() {
mu.Lock()
defer mu.Unlock()
//对共享变量进行读写操作
}
上面的代码中,我们首先声明了一个全局的互斥锁var mu sync.Mutex,然后在f()函数中使用mu.Lock()将该共享资源进行了锁定,这里一定要使用defer语句来保证在f()函数退出的时候解锁共享资源,以免出现死锁的情况。
2. 读写锁
读写锁(RWMutex)则是互斥锁的一种变种,它具有更好的性能。与互斥锁不同的是,读写锁允许在没有任何等待者的情况下,多个goroutine同时对共享资源进行读操作,而在进行写操作时,则只允许一个goroutine进行操作。
var mu sync.RWMutex //定义一个全局的读写锁
func f() {
mu.RLock()
defer mu.RUnlock()
//对共享变量进行读操作
}
func g() {
mu.Lock()
defer mu.Unlock()
//对共享变量进行写操作
}
上面的代码中,我们同样定义了一个全局的读写锁var mu sync.RWMutex,然后在f()函数中使用mu.RLock()对共享资源进行了锁定,这里同样要使用defer来解锁共享资源,在g()函数中使用mu.Lock()进行写操作。
3. 原子操作
除了锁机制以外,Go还提供了一些原子操作(Atomic Operation)来支持对共享变量的安全访问。原子操作指的是不能被中断的操作,从而保证了操作的完整性和正确性。在Go中,原子操作主要涉及到两种类型:原子读写操作和原子计数器操作。
原子读写操作包括原子加载(Atomic Load)和原子存储(Atomic Store)。原子计数器指的则是一种特殊的原子操作,它可以支持并发地对一个计数器进行加或减的操作。
//原子读写操作
var v int32 //定义一个共享变量
atomic.StoreInt32(&v, 123) //原子存储
val := atomic.LoadInt32(&v) //原子加载
//原子计数器操作
var counter int32 //定义一个共享计数器
atomic.AddInt32(&counter, 1) //加1操作
上面的代码中,我们首先定义了一个共享变量v和一个共享计数器counter,然后使用atomic.StoreInt32函数进行原子存储,使用atomic.LoadInt32函数进行原子加载。另外我们还使用了atomic.AddInt32函数对counter进行了加1操作。
2. Happens-Before内存模型
Happens-Before(HB)是Go语言在1.7版本之后引入的全新内存模型。相对于旧的内存模型,Happens-Before更加完善和严格,可以让我们更加准确和安全地编写并发程序。
2.1 Happens-Before规则
Happens-Before规则主要指的是,在程序执行过程中,一些事件的顺序性规则。Happens-Before规则定义了各种操作之间的执行顺序,从而避免了数据访问冲突和数据竞争的问题。
在Happens-Before模型中,除了程序中存在先后顺序的操作之间会遵从Happens-Before的规则之外,还有四种操作是特殊的:初始化、传递、关闭和接收。
其中,初始化操作指的是对某个变量进行初始化的操作,接收操作指的是从某个channel接收数据的操作,传递操作指的是通过channel发送数据的操作,关闭操作则指的是关闭某个channel的操作。
对于这四种特殊的操作,在Happens-Before模型中有如下规则:
对一个变量的初始化操作Happens-Before该变量的后续操作。
对于发送操作,它的完成Happens-Before从相应的接收操作的返回。
某个channel的关闭操作Happens-Before该channel上的后续接收操作。
在一个channel上的接收操作Happens-Before在该channel上的后续发送操作。
除了上述四个规则以外,还有如下规则:
在一个goroutine中,对于一个时间t1,如果时间t2在程序中跟在t1后面执行,那么t1 Happens-Before t2。
如果a Happens-Before b,b Happens-Before c,那么a Happens-Before c。这个规则被称为传递性规则。
如果a Happens-Before b,那么在该goroutine中的所有操作a的结果对操作b都是可见的。
2.2 Sync/Atomic包的支持
对于Go语言的Sync/Atomic包来说,它通过一系列的内置函数来保证内存访问的顺序性和正确性。在Happens-Before模型中,Sync/Atomic包支持的内置函数都遵循了各种规则,从而保证了对于共享变量的读写操作正确顺序的执行。
在Sync/Atomic包中,包含了如下几个内置函数:
atomic.Store系列:原子存储操作。
atomic.Load系列:原子加载操作。
atomic.Add系列:原子加操作。
atomic.CompareAndSwap系列:如果地址指向的值是old,则将该值和new进行交换,返回原值是否是old。
atomic.Swap系列:将地址指向的值设置为new,返回原来的值。
这些内置函数可以保证内存操作之间的正确顺序性。
2.3 示例分析
下面我们以一个示例来说明Happens-Before模型的工作流程。
假设我们的程序中有如下两个goroutine,它们之间通过channel来进行通信。
var done = make(chan bool)
var msg string
func main() {
go produce()
go consume()
<-done
}
func produce() {
msg = "hello, world!"
close(done)
}
func consume() {
fmt.Println(msg)
}
在上面的代码中,我们首先定义了一个done channel和一个msg变量,然后使用两个goroutine来生产和消费msg变量。produce()函数对msg进行了赋值,然后关闭了done channel,而consume()函数则对msg变量进行了读取操作。最后在main()函数中,我们使用<-done来阻塞程序,直到done channel被关闭。
在Happens-Before模型中,如果我们对上述代码进行HB分析的话,我们可以得到如下结果:
在main()函数执行的过程中,produce()函数的执行Happens-Before<-done操作。
在produce()函数执行的过程中,对msg变量进行的赋值操作和done channel关闭操作Happens-Before<-done操作。
在consume()函数执行的过程中,msg变量的读取操作Happens-Beforefmt.Println(msg)操作。
因此,根据上述分析,我们可以确定程序中所有操作的执行顺序,从而保证了数据的正确性和完整性。
3. Go内存模型小结
Go内存模型是一个非常重要的概念,在Go语言的并发编程中扮演着关键的角色。在Go内存模型中,我们需要了解共享变量和普通变量之间的区别,以及使用锁机制和原子操作等手段来实现对共享变量的安全访问。
同时,Go1.7版本之后引入的Happens-Before模型则更加完善和严格,它定义了各种操作之间的执行顺序,从而避免了数据访问冲突和数据竞争的问题。在实际中,我们可以通过Sync/Atomic包中提供的内置函数来实现对于共享变量的安全操作。