Golang 内存模型

Larry · · 527 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

Golang 内存模型

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

  • goroutine创建与销毁
  • goroutine创建顺序保证:
    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通信来保证执行顺序。

  • Channel通信
  • 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()。

  • Once
  • sync.Once用来对多个goroutine同时调用某个函数时(once.Do(f)),保证仅有一个goroutine可以调用f(),其余goroutine的调用会阻塞直至f()返回。
    如下代码,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

      

    有疑问加站长微信联系(非本文作者)

    本文来自:磊磊落落的博客

    感谢作者:Larry

    查看原文:Golang 内存模型

    入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

    527 次点击  
    加入收藏 微博
    暂无回复
    添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
    • 请尽量让自己的回复能够对别人有帮助
    • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
    • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
    • 图片支持拖拽、截图粘贴等方式上传