Golang内存模型初探

开心一刻

       物理课,老师讲到:“裂变是一种物理变化,张亮,你来给大家讲讲你的理解!”
       张亮:“裂变是一种极其恐怖的反应,当它发生时,人绝对不要靠近!”
       老师点点头。
       张亮继续道:“否则,就会沾一身的恶臭。对了,裂便就是大便炸裂开来的意思,以前我经常把鞭炮插在.....”
       老师咆哮:“你滚出去!”

Go内存模型定义

The Go memory model specifies the conditions under which reads of a variable in one goroutine can be guaranteed to observe values produced by writes to the same variable in a different goroutine.
       翻译如下:
       Go 内存模型指定了在何种条件下可以保证在一个 goroutine 中读取变量时观察到在不同 goroutine 中写入相同变量所产生的值。

       我找到的比较好的解释: 当对象和变量被存放在计算机中各种不同的内存区域中时,就可能会出现一些具体的问题。Go内存模型用于解决这样的问题:在多线程并发过程中,如何处理多线程读同步问题与可见性(多线程缓存与指令重排序)、多线程写同步问题与原子性(多线程竞争race condition)

记录几个概念:

  • 指令重排:重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分为两类:编译期重排序和运行期重排序,分别对应编译时和运行时环境。
  • 同步:多个进程因为合作产生的直接制约关系,使得进程有一定的先后执行关系。同步方法调用一旦开始,调用者必须等到方法调用返回后,才能继续后续的行为。
    异步:异步方法调用更像一个消息传递,一旦开始,方法调用就会立即返回,调用者就可以继续后续的操作。
  • 互斥:多个进程在同一时刻只有一个进程能进入临界区。互斥是一种特殊的同步。

       同步类似于你直接去商城买东西,拿到东西付了钱,然后带着东西回家;异步类似于在网上买东西,付了钱,就等快递打电话然后取货就可以了。

Happens Before

       Go语言也使用了Happens Before这样的一个概念(其他语言也有使用这个概念)来描述Go语言中某一小段内存命令的执行顺序。

       通常定义如下:
       假设 A 和 B 表示一个多线程程序执行的两个操作,如果 A Happens-Before B,那么就意味着 A 操作对内存的影响,将对执行 B 的线程 (且在执行 B 之前) 可见。

       这里有个约束,如果 A 和 B 在相同线程中执行,且 A 操作声明在 B 之前,那么 A Happens-Before B 。

       注意,Happens-Before 并不意味着时间上的顺序。

       A happens-before B 并不意味着 A 在 B 之前发生。
       A 在 B 之前发生并不意味着 A happens-before B。

       另外,需要注意,这里所谓的 A B 操作,对于代码或者 CPU 来说,可能不止一条命令。

       例如这样一段代码:

var a, b int
func example() {
    a = 1 // A
    b = 2 // B
}

       代码中A happens before B, 但是由于指令重排的存在,变量b可能会在变量a之前赋值,因此说Happens-Before 并不意味着时间上的顺序。

       当 Go协程 不仅仅局限在一个的时候,存在下面两个规则:

       规则一:如果存在一个变量 v,如果下面的两个条件都满足,则读操作 r 允许观察到(可能观察到,也可能观察不到)写操作 w 写入的值:

       r 不在 w 之前发生;
       不存在其他的 w’w 之后发生,也不存在 w’r 之前发生。

       规则二:为了保证读操作 r 读取到的是写操作 w 写入的值,需要确保 w 是唯一允许被 r 观察到的写操作。如果下面的两个条件都满足,则 r 保证能够观察到 w 写入的值:

       w 发生在 r 之前;
       其他对共享变量 v 的写操作要么发生在 w 之前,要么发生在 r 之后。
       规则二的条件比规则一的条件更为严格,它要求没有其他的写操作和 w、r 并发地发生。

       在一个 Go协程 里是不存在并发的,因此规则一和规则二是等效的:读操作 r 可以观察到最近一次写操作 w 写入的值。但是,当多个协程访问一个共享变量的时候,就必须使用同步事件来构建“发生在...之前”的条件,从而保证读操作观察到的一定是想要的写操作。

       简单来说,所谓的 Happens-Before 以及 Happens-After 实际上对应了变量何时可见。

同步机制

       Go内存模型中涉及到的Happens Before有以下6种:程序初始化、Go协程的创建、信道通信、锁、一次执行(sync.Once)

程序初始化

       程序的初始化是在一个单独的 Go协程 中进行的,但是这个协程可以创建其他的 Go协程 并且二者并发执行。

  • 如果一个包 p 导入了包 q, 那么 q 的 init 函数的执行发生在 p的所有 init 函数的执行之前。

  • 函数 main.main 的执行发生在所有的 init 函数执行完成之后。

Go协程的创建

       在 Go 语言中通过 go 语句创建新的 Go协程 发生在这个 Go协程 的执行之前。比如下面的例子:

package main

var a string

func f() {
    println(a)
}

func hello() {
    a = "hello, world"
    go f()
}

func main() {
    hello()
    //println(a)
}

       调用函数 hello 会在调用后的某个时间点打印 “hello, world” ,这个时间点可能在 hello 函数返回之前,也可能在 hello 函数返回之后。如果没有后续执行代码,很大概率上“hello, world”打印不出来。变量a的赋值Happens before协程创建,协程创建happens before协程执行,因此在println(a)执行时是能观察到a的值变为“hello, world”的,但是由于main函数直接就退出了,导致子协程打印的结果观察不到。

       下面的代码能够打印出a的值

package main

import (
        "fmt"
        "sync"
)

var a string
var wg sync.WaitGroup

func foobar() {
        fmt.Print(a)
        wg.Done()
}

func hello() {
        a = "Hello World
"
        go foobar()
}

func main() {
        wg.Add(1)
        hello()
        wg.Wait()
}

       上述代码,会保证 a 的赋值发生在 foobar() 之前,所以最终会输出 Hello World 字符串。

       注意,因为协程调度的原因,字符串的输出可能会发生在 foobar() 执行完成之后。

       既然有Go协程的创建,那么就有Go协程的销毁,但是Go协程的退出无法确保发生在程序的某个事件之前。比如下面的例子:

package main

import (
    "sync"
)

var a string
var wg sync.WaitGroup

func hello() {
    a = "Hello World
"
    wg.Done()
}

func main() {
    wg.Add(1)
    go hello()
    print(a)
    wg.Wait()
}

       在赋值给 a 变量之后,没有带任何的同步机制,所以该变量的赋值并不能保证对其它协程可见。子协程的退出无法保证happens-before print(a)语句之前,也就是说print(a)语句不能保证观察到子协程退出时的结果,因此“Hello World ”打印不出来。

       如果需要保证逻辑顺序,那么就应该通过锁或者管道机制建立相对的执行顺序。

       如果某个 Go协程 里发生的事件必然要被另一个 Go协程 观察到,需要使用同步机制进行保证,比如使用锁或者信道(channel)通信来构建一个相对的事件发生顺序。

信道通信

       信道通信是 Go协程 间事件同步的主要方式。在某个特定的信道上发送一个数据,则对应地可以在这个信道上接收一个数据,一般情况下是在不同的 Go协程 间发送与接收。

       规则一:在某个信道上发送数据的事件发生在相应的接收事件之前。

       看下面的代码:

package main

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 的打印。因为信道的写入事件 c <- 0 发生在读取事件 <-c 之前,而 <-c 发生在 print(a)之前。

       规则二:信道的关闭事件发生在从信道接收到零值(由信道关闭触发)之前。

       在前面的例子中,可以使用 close(c) 来替代 c <- 0 语句来保证同样的效果。

       规则三:对于没有缓存的信道,数据的接收事件发生在数据发送完成之前。

       比如下面的代码(类似上面给出的代码,但是使用了没有缓存的信道,且发送和接收的语句交换了一下):

package main

var c = make(chan int)
var a string

func f() {
    a = "hello, world"
    <-c
}
func main() {
    go f()
    c <- 0
    print(a)
}

       上面这段代码依然可以保证可以打印 hello, world。因为信道的写入事件 c <- 0 发生在读取事件 <-c 之前,而 <-c 发生在写入事件 c <- 0 完成之前,同时写入事件 c <- 0 的完成发生在 print 之前。对于这个无缓存的信道,c <- 0 和 <-c 可以交换位置,不影响输出结果。

       上面的代码,如果信道是带缓存的(比如 c = make(chan int, 1)),程序将不能保证会打印出 hello, world,它可能会打印出空字符串,也可能崩溃退出,或者表现出一些其他的症状。

       规则四:对于容量为 C 的信道,接收第 k 个元素的事件发生在第 k+C 个元素的发送之前。

       规则四是规则三在带缓存的信道上的推广。它使得带缓存的信道可以模拟出计数信号量:信道中元素的个数表示活跃数,信道的容量表示最大的可并发数;发送一个元素意味着获取一个信号量,接收一个元素意味着释放这个信号量。这是一种常见的限制并发的用法。
       最常应用的场景通过缓冲管道实现一个信号量:当前管道大小对应了已经消耗信号,容量代表了最大信号量个数,向管道写入表示获取一个信号量,读取则表示释放信号量。

       因此,也可以通过带有缓冲区的管道作为并发的限制。
       下面的代码给工作列表中的每个入口都开启一个 Go协程,但是通过配合一个固定长度的信道保证了同时最多有 3 个运行的工作(最多 3 个并发)。

package main

import (
    "fmt"
    "math/rand"
    "sync"
    "time"
)

var limit = make(chan int, 3)
var wg sync.WaitGroup

func Hello(index int) {
    r := rand.Intn(5)
    fmt.Printf("#%d sleep %d seconds.
", index, r)
    time.Sleep(time.Duration(r) * time.Second)
    wg.Done()
}

var work []func(int)

func main() {
    work := append(work, Hello, Hello, Hello, Hello, Hello, Hello)
    wg.Add(len(work))

    for i, w := range work {
        go func(w func(int), index int) {
            limit <- 1
            w(index)
            <-limit
        }(w, i)
    }
    wg.Wait()
}

       包 sync 实现了两类锁数据类型,分别是 sync.Mutex 和 sync.RWMutex。

       规则一:对于类型为 sync.Mutex 和 sync.RWMutex 的变量 l,如果存在 n 和 m 且满足 n < m,则 l.Unlock() 的第 n 次调用返回发生在l.Lock()的第 m 次调用返回之前。

       比如下面的代码:

package main

import "sync"

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()的第 1 次调用返回(在函数 f 内部)发生在 l.Lock() 的第 2 次调用返回之前,后者发生在 print 之前。

       规则二:存在类型 sync.RWMutex 的变量 l,如果 l.RLock 的调用返回发生在 l.Unlock 的第 n 次调用返回之后,那么其对应的 l.RUnlock 发生在 l.Lock 的第 n+1 次调用返回之前。

一次运行

       包 sync 还提供了 Once 类型用来保证多协程的初始化的安全。多个 Go协程 可以并发执行 once.Do(f) 来执行函数 f, 且只会有一个 Go协程 会运行 f(),其他的 Go 协程会阻塞到那单次执行的 f() 的返回。

       规则一:函数 f() 在 once.Do(f) 的单次调用返回发生在其他所有的 once.Do(f) 调用返回之前。

       比如下面的代码:

package main

import "sync"

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 函数只会调用一次 setup 函数。函数 setup 函数的执行返回发生在所有的 print 调用之前,同时会打印出两次 hello, world。

参考

原文地址:https://www.cnblogs.com/xingyu666/p/15046805.html