美文网首页go学习
Go:Memory Model

Go:Memory Model

作者: 初级赛亚人 | 来源:发表于2017-10-27 15:02 被阅读47次

    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()没来得及执行,而程序已经退出了,之后它会被gc回收处理。(Go会将所有的goroutine加入到一个待执行的队列中)至于是不是叫队列,这里我还有一点模糊,什么时候再看下书再来更正这里。
    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的初始化值。

    附:官方文档

    相关文章

      网友评论

        本文标题:Go:Memory Model

        本文链接:https://www.haomeiwen.com/subject/pnkkpxtx.html