欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Go:Memory Model

程序员文章站 2024-03-21 21:39:04
...

Go的内存模型

看完这篇文章你会明白

  • 一个Go程序在启动时的执行顺序
  • 并发的执行顺序
  • 并发环境下如何保证数据的同步性
  • 同步性的错误示范

介绍

Go内存模型指定条件,在该条件下,可以保证一个goroutine中的变量读取可以观察到不同goroutine写入同一个变量而产生的值

建议

在一个程序中,多个goroutine同时修改一个都要访问的数据必须将这种访问进行序列化(也就是说需要有一个谁先谁后的规则)。为了序列化的访问,可以使用通道操作或其他同步机制(如 syncsync/atomic包中的方法)保护数据。
如果你想详细了解一个程序的行为,请继续往下读。

Happens Before原则

在单个goroutine中,读取和写入必须按照程序指定的顺序执行。 也就是说,编译器和处理器可以重新排序在单个goroutine中执行的读取和写入。 如果有两个goroutine时,一个goroutine观察到的执行顺序可能不同于另一个goroutine定义的顺序。 例如,如果一个goroutine执行a= 1; b = 2;另一个可能会在观察到a值更新之前先观察b的更新值。
为了指定读和写的顺序,我们定义了Happens Before原则,它表示一个Go程序中执行内存操作的部分顺序。

  • 如果事件e1发生在e2之前,那么我们就可以说事件e2发生在e1之后
  • 如果e1既不发生在e2之前,也不发生在e2之后,那么我们就说e1和e2是同时发生的

在单goroutine的程序中,Happens-Before的顺序就是程序中表达的顺序。
*注:这里的单goroutine的程序是指程序中没有使用go关键字声明一个goroutine的操作。

注:任何一个Go程序中都不会只有一个goroutine的存在,即使你没有显示声明过(go关键字声明),程序在启动时除了有一个main的goroutine存在之外,至少还会隐式的创建一个goroutine用于gc,使用runtime.NumGoroutine()可以得到程序中的goroutine的数量

对一个变量v的写操作(w)会影响到对v的读操作(r),那么:

  • 这个读操作(r)发生在写操作(w)之后
  • 没有其他的写操作(w')发生在写操作(w)和读操作(r)之间

为了保证对变量v的一个特定读操作(r)读取到一个特定写操作(w)写入的特定值,确保w是唯一的一个写操作,那么:

  • w发生在r之前
  • 对共享变量v的任何其他写入都发生在w之前或之后

这个条件相对于第一个更加苛刻,它需要保证没有其他的写操作发生在w和r之间

在一个goroutine中,这两个定义是等价的,因为它没并发性可言;但是当多个goroutine同时访问变量v时,我们必须使用同步事件(synchronization events)来满足Happends-Before条件以确保读操作(r)观察到期望的写操作(w)的值。

同步(Synchronization)

初始化(Initialization)

程序初始化在单个goroutine中运行,但是goroutine可能会创建其他同时运行的goroutine
如:在package p中import package q,那么q的init()函数先于p的init()函数,起始函数main.main()发生在所有包的init()函数之后

Goroutine creation

go关键词声明一个goroutine的动作发生在goroutine(调用go的那个goroutine)执行之前

var a string
func f() {
    print(a)
}
func hello() {
    a = "hello, world"
    go f()
}
func main(){
    hello()
}

结果

  • 打印hello,world,说明f()所在的goroutine执行了
  • 什么都不会打印,说明f()所在的goroutine没执行,并不代表f()这个goroutine没被加入到goroutine的执行队列中去,只是f()没来得及执行,而程序已经退出了,(Go会将所有的goroutine加入到一个待执行的队列中),之后它会被gc回收处理。
Goroutine destruction

一个goroutine退出时,并不能保证它一定发生在程序的某个事件之前

var a string
func hello() {
    go func() { a = "hello" }()
    print(a)
}

在这个匿名goroutine退出时,并不能确保它发生在事件print(a)之前,因为没有同步事件(synchronization events)跟随变量a分配,所以并不能保证a的修改能被其他goroutine观察到。事实上,约束性强一点的编译器还可能会在你保存时删除go声明。
一个goroutine对a的修改想要其他的goroutine能观察到,可以使用同步机制(synchronization mechanism)来做相对排序,如lockchannel communication

Channel communication

Channel是引用类型,它的底层数据结构是一个循环队列

Channel communication是多个goroutine之间保持同步的主要方法。每个发送的特定Channel与该Channel的相应接收匹配,发送操作和接收操作通常在不同的goroutine中。

缓冲通道(buffered channel)Happens Before原则
  • 发送操作会使通道复制被发送的元素。若因通道的缓冲空间已满而无法立即复制,则阻塞进行发送操作的goroutine。复制的目的地址有两种。当通道已空且有接收方在等待元素值时,它会是最早等待的那个接收方持有的内存地址,否则会是通道持有的缓冲中的内存地址。
  • 接收操作会使通道给出一个已发给它的元素值的副本,若因通道的缓冲空间已空而无法立即给出,则阻塞进行接收操作的goroutine。一般情况下,接收方会从通道持有的缓冲中得到元素值。
  • 对于同一个元素值来说,把它发送给某个通道的操作,一定会在从该通道接收它的操作完成之前完成。换言之,在通道完全复制一个元素值之前,任何goroutine都不可能从它哪里接收到这个元素值的副本。

一个容量为C的channel的第k个接收操作先于第(k+C)个发送操作之前完成

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的发送操作之前,它们作为一个f()整体又发生在c的接收操作完成之前(<-c),而<-c操作发生在print(a)之前

对Channel的Close操作发生在返回零值的接收之前,因为通道已经关闭
注:所以对Channel的Close操作一般发生在发送结束的地方,如果在接收的地方进行Close操作,并不能保证发送操作不会继续send数据,而对于一个Closed的Channel进行send操作会返回一个panic: send on closed channel

所以上例中,将c<-0的操作换成close(c)也能正确输出"hello,world"。

假如一个channel中的每个元素值都启动一个goroutine来处理业务,那么缓冲通道还可以有效的限制启动的goroutine数量,它总是小于等于channel的capacity的值。

var limit = make(chan int, 3)
func main() {
    for _, w := range work {
        go func(w func()) {
            limit <- 1
            w()
            <-limit
        }(w)
    }
    select{}
}
非缓冲通道(unbuffered channel)Happens Before原则
  • 向非缓冲通道发送元素值的操作会被阻塞,直到至少有一个针对该通道的接收操作进行为止。该接收操作会先得到元素值的副本,然后在唤醒发送方所在的goroutine之后返回。也就是说,这时的接收操作会在对应的发送操作完成之前完成。
  • 向非缓冲通道接收元素值的操作会被阻塞,直到至少有一个针对该通道的发送操作进行为止。该发送操作会直接把元素值复制给接收方,然后在唤醒接收方所在的goroutine之后返回。也就是说,这时的发送操作会在对应的接收操作完成之前完成。

下例是将上例的接收和发送操作交换了一下,并将通道设置为非缓冲通道

var c = make(chan int)
var a string
func f() {
    a = "hello, world"
    <-c
}
func main() {
    go f()
    c <- 0
    print(a)
}

同样会打印出"hello,world",如果使用缓冲通道(buffered channel),那么程序不一定会打印出"hello,world"了(可能会打印一个空字符串,崩溃,或者做些其他事)。

Locks

包sync实现了两种锁数据类型:sync.Mutexsync.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)
}

它能确保打印"hello,world",第一次调用l.Unlock()发生在第二次调用l.Lock()(main函数里面)之前,它们整体发生在print之前。

对于Lock()和Unlock()都是成对出现的,对于一个Unlocked的变量再次进行Unlock()操作,会panic: sync: unlock of unlocked mutex;而对于已经Lock()的变量再次进行Lock()操作是没有任何问题的(在不同的goroutine中),无非就是谁先抢占到l变量的操作权限而已。如果在同一个goroutine中对一个Locked的变量再次进行Lock()操作将会造成deadlock

Once

包sync提供了一个安全机制,通过使用Once类型可以在存在多个goroutine的情况下进行初始化,即使多个goroutine同时并发,Once也只会执行一次。Once.Do(f),对于函数f(),只有一个goroutine能执行f(),其他goroutine对它的调用将会被阻塞直到返回值(只有f()执行完毕返回时Once.Do(f)才会返回,所以在f中调用Do将会造成deadlock)。
程序:

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

以下都是“同步”用法的不正确示范

  1. 同步发生的读操作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。

  1. 双重锁定是试图避免同步的开销。 例如,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值的写入,但是操作setup()中a和done的写入并没有同步性。

  1. 另一个错误的用法就是循环等待一个值
var a string
var done bool
func setup() {
    a = "hello, world"
    done = true
}
func main() {
    go setup()
    for !done {
    }
    print(a)
}

在main()中,通过观察done的写入来实现观察a的写入,所以a最终仍可能是空。更糟糕的是,没什么同步机制确保go setup()中done的写入值会被main()中观察到,所以可能main()永远不会退出循环。

  1. 上例的一个微妙变种:
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 != nil来退出循环,但是也不能保证它能观察到g.msg的初始化值。

附:官方文档