Golang 内存模型The Go Memory Model

目录
  • 1. 简介(Introduction)
  • 2. 建议(Advice)
  • 3. 发生在…之前(Happens Before)
    • 3.1 重排序
    • 3.2 happens-before
    • 3.3 规则
  • 4. 同步(Synchronization)
    • 4.1 初始化(Initialization)
    • 4.2 Go协程的创建(Goroutine creation)
    • 4.3 Go协程的销毁(Goroutine destruction)
    • 4.4 信道通信(Channel communication)
      • 有缓存channel
      • 无缓存 channel
      • 规则抽象
  • 4. 锁
  • 5. 单次运行
  • 6. 不正确的同步方式
    • 6.1 案例一
    • 6.2 案例二
    • 6.3 案例三
  • 7. 总结

1. 简介(Introduction)

官方原文

本文翻译了原文并加入了自己的理解。

主要介绍多个 Go协程之间对同一个变量并发读写时需要注意的同步措施和执行顺序问题。并列出几个常见错误。

Go 内存模型涉及到多个 Go协程之间对同一个变量的读写。

假如有一个变量,其中一个 Go协程(a) 写这个变量,另一个 Go协程(b) 读这个变量;Go 内存模型定义了什么情况下 Go协程(b) 能够确保读取到由 Go协程(a) 写入的值。

2. 建议(Advice)

  • 如果多协程并发修改数据,必须保证各个步骤串行执行(序列化访问)。
  • 为了串行执行,可以使用 channel 或其他同步原语( 如 sync 和 sync/atomic 两个包里的那些)来保护被共享的数据。

3. 发生在…之前(Happens Before)

除了重排序需要理解,其余概念其实没那么重要,看后面的例子就懂了。

3.1 重排序

当只有一个 Go协程时,对同一个变量的读写必然是按照代码编写的顺序来执行的。对于多个变量的读写,如果重新排序不影响代码逻辑的正常执行,编译器和处理器可能会对多个变量的读写过程重新排序。

比如对于 a = 1; b = 2 这两个语句,在同一个 Go协程里先执行 哪个其实是没有区别的,只要最后执行结果正确就行。

a := 1//1
b := 2//2
c := a + b //3

但是,因为重新排列执行顺序的情况的存在,会导致**某个 Go协程所观察到的执行顺序可能与另一个 Go协程观察到的执行顺序不一样。**可能另一个 Go协程 观察到的事实是 b 的值先被更新,而 a 的值被后更新。

3.2 happens-before

为了表征读写需求,我们可以定义 happens-before,用来表示 Go 语言中某一小段内存命令的执行顺序。

  • 如果事件 e1 发生在事件 e2 之前,此时我们就认为 e2 发生在 e1 之后。
  • 如果事件 e1 既不发生在事件 e2 之前,也不发生在 e2 之后,此时我们就认为 e1 和 e2 同时发生(并发)(并发 ≠ 并行)。

3.3 规则

在只有一个 Go协程的内部,happens-before的顺序就是代码显式定义的顺序。当 Go协程 不仅仅局限在一个的时候,存在下面两个规则:

  • 如果存在一个变量 v,下面的两个条件都满足,则读操作 r 允许观察到(可能观察到,也可能观察不到)写操作 w 写入的值。
  • r 不在 w 之前发生;
  • 不存在其他的 w’ 在 w 之后发生,也不存在 w’ 在 之前发生。
  • 为了保证读操作 r 读取到的是写操作 w 写入的值,需要确保 w 是唯一允许被 r 观察到的写操作。如果下面的两个条件都满足,则 r 保证能够观察到 w 写入的值:
  • w 发生在 r 之前;
  • 其他对共享变量 v 的写操作要么发生在 w 之前,要么发生在 r 之后。

规则二的条件比规则一的条件更为严格,它要求没有其他的写操作和 w、r 并发地发生。

在一个 Go协程 里是不存在并发的,因此规则一和规则二是等效的:读操作 r 可以观察到最近一次写操作 w 写入的值。

但是,当多个协程访问一个共享变量时,就必须使用同步事件来构建 happens-before 的条件,从而保证读操作观察到的一定是想要的写操作。

在内存模型中,变量 v 的零值初始化操作等同于一个写操作。

如果变量的值大于单机器字(CPU 从内存单次读取的字节数),那么 CPU 在读和写这个变量的时候是以一种不可预知顺序的多次执行单机器字的操作,这也是 sync/atomic 包存在的价值。

4. 同步(Synchronization)

4.1 初始化(Initialization)

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

每个包都允许有一个 init 函数,当这个包被导入时,会执行该包的这个 init 函数,做一些初始化任务。

  • 如果一个包 p 导入了包 q, 那么 q 的 init 函数的执行发生在 p的所有 init 函数的执行之前。(即包的引用链)
  • 函数 main.main 的执行发生在所有的 init 函数执行完成之后。

4.2 Go协程的创建(Goroutine creation)

通过 go 语句启动新的 Go协程这个动作,发生在新的 Go协程的执行之前。比如下面的例子:

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

调用函数 hello 会在调用后的某个时间点打印 “hello, world” ,这个时间点可能在 hello 函数返回之前,也可能在 hello 函数返回之后。

4.3 Go协程的销毁(Goroutine destruction)

Go协程的退出无法确保发生在程序的某个事件之前。比如下面的例子:

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

其中 a 的赋值语句没有任何的同步措施,因此无法保证被其他任意的 Go 协程(例如 hello 函数本身)观察到这个赋值事件的存在。

一些激进的编译器可能会在编译阶段删除上面代码中的整个 go 语句。

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

4.4 信道通信(Channel communication)

这部分介绍通过 channel 实现并发顺序控制。

有缓存channel

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

  • 规则一:在某个信道上发送数据的事件发生在相应的接收事件之前。
    即一定是先发送数据,才能接收到数据这个顺序。
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)`之前。信道未被读取时协程会阻塞。

  • 规则二:信道的关闭事件发生在从信道接收到零值(由信道关闭触发)之前。
    即一定是先关闭 channel,才能接收到零值。
    在前面的例子中,可以使用 close(c) 来替代 c <- 0 语句来保证同样的效果。

无缓存 channel

  • 规则三:对于没有缓存的信道,数据的接收事件发生在数据发送完成之前。
    即信道容量为0时,只有发送的信息被读取了才算发送成功,否则阻塞。
    比如下面的代码(类似上面给出的代码,但是使用了没有缓存的信道,且发送和接收的语句交换了一下):
var c = make(chan int) //容量为0,无缓存
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 = make(chan int, 1)`),程序将不能保证会打印出 `hello, world`,它可能会打印出空字符串,也可能崩溃退出,或者表现出一些其他的症状。

规则抽象

  • 规则四:对于容量为 C 的信道,接收第 k 个元素的事件发生在第 k+C 个元素的发送之前。
    规则四是规则三在带缓存的信道上的推广。
  • 它使得带缓存的信道可以模拟出计数信号量:**信道中元素的个数表示活跃数,信道的容量表示最大的可并发数;发送一个元素意味着获取一个信号量,接收一个元素意味着释放这个信号量。**这是一种常见的限制并发的用法。
  • 下面的代码给工作列表中的每个入口都开启一个 Go协程,但是通过配合一个固定长度的信道保证了同时最多有 3 个运行的工作(最多 3 个并发)。
var limit = make(chan int, 3)
func main() {
  for _, w := range work {
    go func(w func()) {
      limit <- 1  // channel里达到3个即阻塞
      w()
      <-limit  // 取出后channel里小于3个即可继续
    }(w)
  }
  select{}
}

4. 锁

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

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

即先解开上一次锁才能上这一次锁。

比如下面的代码:

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 次调用返回之前。
    即读锁可以上多次,但是只要没有全解开就不能上写锁,写锁只能上一个,不解开读写锁都不能上。

5. 单次运行

包 sync 还提供了 Once 类型用来保证多协程的初始化的安全。

多个 Go协程 可以并发执行 once.Do(f) 来执行函数 f, 且只会有一个 Go协程会运行 f(),其他的 Go 协程会阻塞到 f() 运行结束(不再执行 f,但能得到运行结果)

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

比如下面的代码:

func setup() {
    time.Sleep(time.Second * 2) //1
    a = "hello, world"
    fmt.Println("setup over") //2
}
func doprint() {
    once.Do(setup) //3
    fmt.Println(a) //4
    wg.Done()
}
func twoprint() {
    go doprint()
    go doprint()
}
func main() {
    wg.Add(2)
    twoprint()
    wg.Wait()
}
setup over
hello, world
hello, world
  • 上面代码使用wg sync.WaitGroup等待两个goroutine运行完毕,由于 setup over只输出一次,所以setup方法只运行了一次
  • 函数 setup 函数的执行返回发生在所有的 print 调用之前,同时会打印出两次 hello, world,即当一个goroutine在执行setup方法时候,另外一个在阻塞。

6. 不正确的同步方式

6.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(b的值),然后打印 0(a的值)。可能大家会认为既然 b 的值已经被赋值为 2 了,那么 a 的值肯定被赋值为 1 了,但事实是两个事件的先后在这里是没有办法确定的,因为编译器会改变执行顺序。

上面的事实可以证明下面的几个常见的错误。

6.2 案例二

双重检查锁定尝试避免同步带来的开销。比如下面的例子,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 的写操作。上面的写法依然有可能打印出空字符串。

6.3 案例三

另一个常见的错误用法是对某个值的循环检查,比如下面的代码:

var a string
var done bool
func setup() {
    a = "hello, world"
    done = true
}
func main() {
    go setup()
    for !done {
    }
    print(a)
}

和上一个例子类似,main函数中观察到对 done 的写操作并不意味着可以观察到对 a 的写操作,因此上面的代码依然可能会打印出空字符串。

更糟糕的是,由于两个 Go协程之间缺少同步事件,main 函数甚至可能永远无法观察到对 done 变量的写操作,导致 main 中的 for 循环永远执行下去。

上面这个错误有一种变体,如下面的代码所示:

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并且退出了它的 for 循环,依然没有办法保证它可以观察到被初始化的 g.msg 值。

避免上面几个错误用法的方式是一样的:显式使用同步语句。

7. 总结

通过上面所有的例子,不难看出解决多goroutine下共享数据可见性问题的方法是在访问共享数据时候施加一定的同步措施。

以上就是Golang 内存模型The Go Memory Model的详细内容,更多关于Go Memory Model的资料请关注我们其它相关文章!

(0)

相关推荐

  • 详解Go内存模型

    介绍 Go 内存模型规定了一些条件,在这些条件下,在一个 goroutine 中读取变量返回的值能够确保是另一个 goroutine 中对该变量写入的值.[翻译这篇文章花费了我 3 个半小时 ] Happens Before(在-之前发生) 在一个 goroutine 中,读操作和写操作必须表现地就好像它们是按照程序中指定的顺序执行的.这是因为,在一个 goroutine 中编译器和处理器可能重新安排读和写操作的执行顺序(只要这种乱序执行不改变这个 goroutine 中在语言规范中定义的行为)

  • Go语言开发必知的一个内存模型细节

    目录 引言 内存模型定义是什么 happens-before 是什么 A 不一定 happens-before B Go 语言中的 happens-before 定义 Go Channel 实例 例子 1 例子 2 例子 3 例子 4 总结 引言 在日常工作中,如果我们能够了解 Go 语言内存模型,那会带来非常大的作用.这样在看一些极端情况,又或是变态面试题的时候,就能够明白程序运行表现下的很多根本原因了. 当然,靠一篇普通文章讲完 Go 内存模型,不可能.因此今天这篇文章,把重点划在给大家讲解

  • Golang 内存模型详解(一)

    开始之前 首先,这是一篇菜B写的文章,可能会有理解错误的地方,发现错误请斧正,谢谢. 为了治疗我的懒癌早期,我一次就不写得太多了,这个系列想写很久了,每次都是开了个头就没有再写.这次争取把写完,弄成一个系列. 此 nil 不等彼 nil 先声明,这个标题有标题党的嫌疑. Go 的类型系统是比较奇葩的,nil 的含义跟其它语言有些差别,这里举个例子(可以直接进入 http://play.golang.org/p/ezFhXX0dnB 运行查看结果): 复制代码 代码如下: package main

  • Golang 内存模型The Go Memory Model

    目录 1. 简介(Introduction) 2. 建议(Advice) 3. 发生在…之前(Happens Before) 3.1 重排序 3.2 happens-before 3.3 规则 4. 同步(Synchronization) 4.1 初始化(Initialization) 4.2 Go协程的创建(Goroutine creation) 4.3 Go协程的销毁(Goroutine destruction) 4.4 信道通信(Channel communication) 有缓存chan

  • GoLang内存模型详细讲解

    目录 栈内存-协程栈-调用栈 逃逸分析 go 堆内存 堆如何进行分配 go 语言对象的垃圾回收 如何减少GC对性能的分析 GC 优化效率 栈内存-协程栈-调用栈 为什么go的栈是在堆上? go 协程栈的位置: go的协程栈位于go的堆内存,go 的gc 也是对堆上内存进行GC, go堆内存位于操作系统虚拟内存上, 记录局部变量,传递参数和返回值 ,go 使用的参数拷贝传递,如果传递的值比较大 注意传递其指针 go 参数传递 使用 值传递, 也就是说传递结构体时候,拷贝结构体的指针,传递结构体指针

  • Golang内存模型教科书级讲解

    目录 1. 简介(Introduction) 2. 建议(Advice) 3. 发生在…之前(Happens Before) 3.1 重排序 3.2 happens-before 3.3 规则 4. 同步(Synchronization) 4.1 初始化(Initialization) 4.2 Go协程的创建(Goroutine creation) 4.3 Go协程的销毁(Goroutine destruction) 4.4 信道通信(Channel communication) 有缓存chan

  • 细谈java同步之JMM(Java Memory Model)

    简介 Java内存模型是在硬件内存模型上的更高层的抽象,它屏蔽了各种硬件和操作系统访问的差异性,保证了Java程序在各种平台下对内存的访问都能达到一致的效果. 硬件内存模型 在正式讲解Java的内存模型之前,我们有必要先了解一下硬件层面的一些东西. 在现代计算机的硬件体系中,CPU的运算速度是非常快的,远远高于它从存储介质读取数据的速度,这里的存储介质有很多,比如磁盘.光盘.网卡.内存等,这些存储介质有一个很明显的特点--距离CPU越近的存储介质往往越小越贵越快,距离CPU越远的存储介质往往越大

  • Java内存模型知识汇总

    为什么要有内存模型 在介绍Java内存模型之前,先来看一下到底什么是计算机内存模型,然后再来看Java内存模型在计算机内存模型的基础上做了哪些事情.要说计算机的内存模型,就要说一下一段古老的历史,看一下为什么要有内存模型. 内存模型,英文名Memory Model,他是一个很老的老古董了.他是与计算机硬件有关的一个概念.那么我先给你介绍下他和硬件到底有啥关系. CPU和缓存一致性 我们应该都知道,计算机在执行程序的时候,每条指令都是在CPU中执行的,而执行的时候,又免不了要和数据打交道.而计算机

  • 详细分析Java内存模型

    目录 一.为什么要学习并发编程 二.为什么需要并发编程 三.从物理机中得到启发 四.Java 内存模型 五.原子性 5.1.什么是原子性 5.2.如何保证原子性 六.可见性 6.1.什么是可见性 6.2.如何保证可见性 七.有序性 7.1.什么是有序性 7.2.如何保证有序性 一.为什么要学习并发编程 对于 "我们为什么要学习并发编程?" 这个问题,就好比 "我们为什么要学习政治?" 一样,我们(至少作为学生党是这样)平常很少接触到,然后背了一堆 "正确且

  • JAVA内存模型(JMM)详解

    目录 前言 JAVA并发三大特性 可见性 有序性 原子性 Java内存模型真面目 Happens-Before规则 1.程序的顺序性规则 2. volatile 变量规则 3.传递性 锁的规则 5.线程 start() 规则 6.线程 join() 规则 使用JMM规则 方案一: 使用volatile 方案二:使用锁 小结: volatile 关键字 synchronized 关键字 总结 前言 开篇一个例子,我看看都有谁会?如果不会的,或者不知道原理的,还是老老实实看完这篇文章吧. @Slf4

  • Java内存模型JMM详解

    Java Memory Model简称JMM, 是一系列的Java虚拟机平台对开发者提供的多线程环境下的内存可见性.是否可以重排序等问题的无关具体平台的统一的保证.(可能在术语上与Java运行时内存分布有歧义,后者指堆.方法区.线程栈等内存区域). 并发编程有多种风格,除了CSP(通信顺序进程).Actor等模型外,大家最熟悉的应该是基于线程和锁的共享内存模型了.在多线程编程中,需要注意三类并发问题: ·原子性 ·可见性 ·重排序 原子性涉及到,一个线程执行一个复合操作的时候,其他线程是否能够看

随机推荐