Go的内存模型

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

这篇文章主要是为了阅读一篇 go 的文档,解释了什么情况下一个 goroutine 对变量写的值能被另一个 goroutine 可靠观察到,主要以意译为主,括号内为个人理解。

无论是用单个通道来守护并发数据的实现还是使用 sync 和 sync/atomic 中的同步原语的实现,程序中多个 goroutine 并发操作相同数据时一定是串行的。(两种常见的并发模型: 使用专门 channel 来操作并发访问的数据,其它 goroutine 把自己的操作请求发给这个 channel;多个 goroutine 抢锁来操作数据)

在单个 goroutine 中,对一个变量读写操作的真正执行顺序必须要和代码中的顺序具有相同的执行效果,就是说,编译器和处理器可能会对单个 goroutine 内的一些读写操作进行重新排序,但调整顺序前后的执行结果要跟按代码中的顺序执行结果一致。但这种重新排序不会考虑多个 goroutine 的情况,一个 goroutine 中的代码所展示的执行顺序和其它 goroutine 实际观察 ( 比如另一个 goroutine 监听这个 goroutine 中某些变量的变化 ) 到的这个 goroutine 的执行顺序可能会不同。比如一个 goroutine 代码中是依次执行 a=1 和 b=2,另一个 goroutine 可能会观察到 b 先被赋值为 2,然后再是 a=1。

Happens Before

为了说清楚读和写的请求,先定义一下这个 happens before : 用来表达 Go 程序在内存中的一小段执行顺序,当我们说 e1 在 e2 之前发生时, 就是在说 e2 在 e1 之后发生。当 e1 没有发生在 e2 之前,而且 e2 没有发生在 e1 之后时,我们说 e1 和 e2 这时是并发的 ( 即我们无法可靠判断 e1 和 e2 的执行顺序 ) 。

在单个goroutine内, happens before 的顺序是由代码表达的顺序决定的

对于变量 v 的一个读 r ,如果不可靠观察到( 相对于可靠观察到 ) 写 w 对 v 的操作,那么 r 和 w 要满足:

  1. r 不能发生在 w 之前(即 r,w 要么并发发生,要么 r 在 w 之后发生)
  2. 在 w 之后且在 r 之前没有其他的对 v 的写 ( 即其它的写要么与 w 并发发生,要么与 r 并发发生,要么发生在 w 之前,要么发生在 r 之后 )

(可以看到,上面约束中并发就是不可靠的来源)而如果为了保证对 v 的读 r 能够观察到对 v 特定的一次写 w ,就是说要 r 仅观察到这特定的一次 w , 为了实现 r 能够可靠观察到这次 w ,那要满足:

  1. w 发生在 r 之前 ( 相比前文两条约束排除了 r,w 并发发生 )
  2. 其它任何对共享变量 v 的写,要么发生在 w 之前,要么发生在 r 之后 ( 同样相比之前排除了其它的写与 w 并发发生,以及其它写与r并发发生的两种情况 )

上面后两条约束要强于前两对,后者要求在 w 和 r 发生时没有其它的 w 并发发生。在单个 goroutine 内是不可能并发的,所以单个 goroutine 的情况下上面两对约束是一个意思:对 v 的读能够观察到最近一次的 w。但是在多个 goroutine 共享 v 的情况下, 就必须使用同步原语建立可靠的 happens-before 来保证一次读能够读到指定的一次写。

使用 v 类型的零值对v进行初始化和一次对 v 的写操作,在内存模型中是一样的

对于一个超过一个字( 即机器字 )的值来说,对它的读写操作相当于多个不确定顺序的单字操作

同步中的 happens before:

几种可靠的 happens before 发生顺序

1, 如果 p 导入 q 包, 那么 q 的 init 函数是可靠发生在 p 中任何逻辑之前的
2, 而 main 包中 main 函数是可靠发生在所有 init 函数完成之后
3, goroutine 创建时的 go 声明可靠发生在这个 goroutine 开始执行之前

var a string
func f() {
  print(a)
}
func hello() {
  a = "hello, world"   // a是被先赋值
  go f()               // go f()后执行, 所以print(a)一定会打印"hello,world"
}

4, 不使用同步机制的话, 无法可靠保证 goroutine 的退出发生时机

var a string
func hello() {
  go func() { a = "hello" }()
  print(a)  // 这可以打印空字符串, 也可以打印hello, 甚至一些激进的编译器直接删除创建goroutine的那句
}

( 到这个位置介绍的都还比较符合直觉, 但是接下来四条结论就比较违反直觉, 至少是我的直觉, 先看结论, 后面我再结合代码给出理解 )

5, 一次发送可靠发生在对应这次发送的接收完成之前
6, 通道关闭可靠发生在接收方收到通道类型的零值之前
7, 对于无缓冲通道的接收是可靠发生在发送完成之前
8, 第 k 次对容量为 C 的缓冲通道的接收是可靠发生在第 k+C 次的发送完成之前

( 注: 这个位置需要对比5, 6, 7, 8的原文理解一下,
文档原文如下:
5, A send on a channel happens before the corresponding receive from that channel completes.
6, The closing of a channel happens before a receive that returns a zero value because the channel is closed.
7, A receive from an unbuffered channel happens before the send on that channel completes.
8, The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes.

理解这些要把发送分解为两个过程:发送,发送完成,同样接收也是:接收,接收完成。前两句就是一个意思, 因为第二句中通道关闭就是发送通道类型的零值, 所以前两句就是说发送行为是发生在接收完成之前, 即如果接收完成了,那么发送一定发生了,但发送是否完成还不一定,强调的是接收完成的时候哪些是可靠发生了的。

var c = make(chan int, 10)
var a string
func f() {
  a = "hello"   // 写 a 发生在发送 0 给 c 之前
  c <- 0        // 发送 0 给 c 发生在 c 接收完成之前
}
func main() {
  go f()
  <-c       // c 接收完成发生在打印 a 之前
  print(a)  // 可靠打印 hello 
}

后面7,8两句对于非缓冲通道和缓冲通道满了情况的描述比较令人费解, 我在另一篇介绍通道的文档中找到这一段

If the channel is unbuffered, the sender blocks until the receiver has received the value. If the channel has a buffer, the sender blocks only until the value has been copied to the buffer; if the buffer is full, this means waiting until some receiver has retrieved a value.

如果是无缓冲通道,发送者会一直阻塞到接收者接收完成 ( has received ) 这个值。如果是缓冲通道,发送者会一直阻塞直到值被复制到缓冲区,如果缓冲区满了,那发送者阻塞到接收者从缓冲区中取走一个值。

这段介绍和 7,8 的结论是一致的,即对于阻塞状态下的通道,无论是无缓冲通道还是缓冲通道满了,接收完成一定是先于发送完成的,这里一直使用的是 has received 和 has retrieved, 对应 7,8 中的 completes. 所以对于无缓冲通道或者缓冲通道满了的情况,发送和接收最终完成,一定是接收先完成,然后发送才完成

var c = make(chan int)
var a string
func f() {
  a = "hello, world"
  <-c       // 这行先于 c<-0 完成之前完成, a 可靠赋值
}
func main() {
  go f()
  c <- 0
  // c的发送发生在print之前,
  // 而 c 的接收发生在 c 发送完成之前完成
  // 而 a 在 c 的接收之前完成赋值
  // 所以a赋值, 到c接收完成, 再到 main 中 c 发送完成, 最后可靠打印 a
  print(a)
}

另外,这段话还提供了缓冲通道的细节: 发送者等待的是把值复制到缓冲区,而不是接收者完成, 而接收者等待的是缓冲区的值, 所以对于缓冲未满的情况, 发送者要先完成把值复制到缓冲区,接收者才能从缓冲区读到值,就是 5 的结论,而非缓冲通道发送者等待的是接收者完成。

这细节..., 可能是为了知道从阻塞状态下通道解阻塞后,接收者先走一步吧

最后用图来综上所述吧
image

ok, 接着读这篇内存模型的文档 )

通过第 8 条结论, 可以用缓冲通道来模拟计数型的同步机制:通道缓冲容量代表最大允许活跃的原语数量,达到数量之后, 如果还想使用同步原语就要等待其它活跃的同步原语被释放,常用来限制并发,上代码:

var limit = make(chan int, 3)
func main() {
  for _, w := range work {  // 虽然for为每个work创建了一个goroutine, 但这些goroutine并不是同时活跃的   
    go func(w func()) {  
      limit <- 1            // limit满了情况下, goroutine就会阻塞在这里
      w()
      <- limit              // 直到其它goroutine执行完w(), 从limit中取一个值出来, 任何时候最大活跃 worker 只有3个
    }(w)
  }
}

锁中的happens before:

9, 对于 Mutex 或者 RWMutex 类型 l, 和整型 n, m, 其中n<m, n次对 l.Unlock( ) 可靠发生在 m 次的 l.Lock( ) 之前

var l sync.Mutex
var a string
func f() {
    a = "hello"
    l.Unlock()  // n = 1
}
func main() {
    l.lock()  // m = 1 
    go f()
    l.lock()  // m = 2 上面n=1可靠发生在m=2之前, 所以可靠打印hello
    print(a)
}

10, For any call to l.RLock on a sync.RWMutex variable l, there is an n such that the l.RLock happens (returns) after call n to l.Unlock and the matching l.RUnlock happens before call n+1 to l.Lock.
这句意思是下图
image

Once中的 happens before:

11, Once 提供了并发场景下的初始化方案, 多个 goroutine 调用 once.Do(f), 仅会有一个真正执行了 f( ), 其它的 goroutine 会阻塞等待执行的那个返回, 即其中一个真正执行的那个 goroutine 执行 f( ) 会发生在任何一 once.Do(f)返回之前

var a string
var once sync.Once
func setup() {
    a = "hello"
}
func doprint() {
    once.Do(setup)
    print(a)
}
func twoprint() {
    go doprint()    // 这两个goroutine中仅有一个真正执行了setup(),但是两个都会阻塞到setup()被执行完成
    go doprint()    // 所以a写入发生在once.Do(setup)之前,print(a)会可靠打印两遍hello
}

不正确的同步:

var a, b int
func f() {
    a = 1
    b = 2
}
func g() {
    print(b)
    print(a)
}
func main() {
    go f()
    g()    // 这个位置几乎可print任何组合, 0-0, 0-1, 2-0, 1-2, 因为f的goroutine和主goroutine没有任何同步,
}
var a string
var done bool

func setup() {
    a = "hello"
    done = true
}
func doprint() {
    if !done {          // 重点是, 这个逻辑是在暗示读到了done就能读到在done之前写的a, 实际上是,在没有同步机制下, 读到了done也不一定
        once.Do(setup)  // 能读到a      
    }
    print(a)
}
func twoprint() {
    go doprint()   // 可能两个goroutine都会阻塞在once.Do(setup)位置, 其中一个真正执行了setup, 而另一个不会执行, 这个为执行的goroutine
    go doprint()   // 就无法可靠观察到那个执行setup的goroutine对a的写, 所以会有一个空字符串
}
var a string
var done bool

func setup() {
    a = "hello"
    done = true
}
func main() {
    go setup()
    for !done {}   // 这个也是在暗示读到done就能读到a,同样这个done可能被main goroutine读到, 但不一定表示就能读到a, 还有就是这个done
    print(a)       // 也有可能永远不会被main读到,
}
type T struct {
    msg string
}
var g *T
func setup() {
    t := new(T)
    t.msg = "hello"
    g = t
}
func main() {
    go setup()
    for g == nil {}    //  main gorotine和setup gorotine共享了g, 所以main可以观察到g, 但是对g.msg的写无法可靠保证。
    print(g.msg)
}

只要显式使用同步原语就可以解决上面的问题


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

本文来自:Segmentfault

感谢作者:focus

查看原文:Go的内存模型

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

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