Golang 语言的内存模型

简介: Golang 语言的内存模型

01

介绍


Go 内存模型可以保证一个 goroutine 可以读取在不同 goroutine 中修改同一指定变量的值。


02

建议


程序中的一个 goroutine 修改的数据,如果同时有其它 goroutine 读取该数据,则需要保证程序串行化执行。


为了保证程序串行化执行,我们需要使用 channel 通道操作或其他同步原语(例如 sync 和 sync/atomic 包中的原语)来保护数据。


03

先行发生(Happens Before


在单个 goroutine 中,读取和写入的行为必须按照程序指定的顺序执行。也就是说,仅当重新排序不会改变语言规范所定义的该 goroutine 中的运行结果时,编译器和处理器才可以对单个 goroutine 中执行的读取和写入进行重新排序。因为此重新排序,一个 goroutine 查看到的执行顺序可能与另一个 goroutine 查看到的执行顺序不同。


例如,如果一个 goroutine 执行 a = 1;b = 2;另一个可能会在 b 的更新值之前查看 b 的更新值。


为了说明读取和写入的要求,Go team 定义了「先行发生Happens Before」原则,在 Go 程序中执行内存操作的偏序。如果事件 e1 发生在事件 e2 之前,那么我们说 e2 发生在 e1 之后。同样,如果 e1 不在 e2 之前发生并且在 e2 之后也没有发生,那么我们说 e1 和 e2 并发。


在单个 goroutine 中,先行发生顺序是程序表示的顺序。


如果同时满足以下两个条件,则允许对变量 v 的读操作 r 查看对变量 v 的写操作 w:

  1. r 在 w 之前不会发生。
  2. 在 w 之后且在 r 之前没有发生对 v 的其他写操作。


为了保证变量 v 的读取操作 r 查看到对 v 的特定写入操作 w,请确保 w 是唯一允许 r 查看的写入操作。也就是说,如果同时满足以下两个条件,则保证 r 查看到 w:

  1. w 发生在 r 之前。
  2. 对共享变量 v 的任何其他写操作都发生在 w 之前或 r 之后。


这对条件比第一对要更加严格。它要求没有其他写入操作与 w 或 r 并发。


在单个 goroutine 中,没有并发性,因此这两个定义是等效的:读取操作 r 查看最近写入操作 w 写入到 v 的值。当多个 goroutine 访问共享变量 v 时,它们必须使用同步事件来建立先行发生条件,确保读取操作可以看到所需的写入操作。


用 v 的类型的零值初始化变量 v 的行为与在内存模型中的写操作相同。


对大于单个机器字的变量的读取和写入,将如同以未指定顺序的多个机器字大小的变量的操作。


04

同步


初始化:

程序初始化在单个 goroutine 中运行,但是该 goroutine 可能会创建其他并发执行的 goroutine。


如果包 p 导入了包 q,则 q 的 init 函数执行完成,先行发生在任何 p 的 init 函数开始执行之前。


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


创建 goroutine:

go 关键字启动新 goroutine 先行发生在该 goroutine 的执行开始之前。


例如,在此程序中:


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

调用 hello 函数,会在之后的某个时间(也许在 hello 返回之后)打印 “ hello,world”。


销毁 goroutine:

不能保证 goroutine 的退出会先行发生在程序中的任何事件发生。例如,在此程序中:


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

未使用任何同步事件限制对变量 a 的赋值操作,因此不能保证任何其他 goroutine 都会看到变量 a 的赋值。实际上,激进的编译器可能会删除整个 go 语句。


如果需要保证一个 goroutine 的执行结果,可以通过另一个 goroutine 来查看到,请使用同步机制(例如锁或 channel 通道通信)来建立程序执行的相对顺序。


channel 通信:

channel 通道通信是 goroutine 之间同步的主要方法。通常在不同的 goroutine 中,将特定 channel 通道上的每个发送与该 channel 通道上的相应接收进行匹配。


channel 通道上的发送操作先行发生在该 channel 通道上的相应接收操作完成。


该程序:

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 的写操作先行发生在对 channel 通道 c 的发送,先行发生在相应的 channel 通道 c 接收完成,先行发生在 print 操作。


channel 通道关闭先行发生在由于 channel 通道关闭而返回零值的接收。


在前面的示例中,用 close(c) 替换 c <- 0 将产生具有相同运行结果的程序。


来自未缓冲通道的接收先行发生在该通道上的发送完成。


该程序(如上所述,但是交换了 send 和 receive 语句并使用了未缓冲的通道):


var c = make(chan int)
var a string
func f() {
  a = "hello, world"
  <-c
}
func main() {
  go f()
  c <- 0
  print(a)
}

也保证打印 “hello, world”。对 a 的写操作先行发生在 c 的接收,先行发生在相应的 c 的发送完成,先行发生在 print 操作。


如果通道有缓冲(例如,c = make(chan int,1)),则不能保证程序会打印 “ hello,world”。(它可能会打印空字符串,崩溃或执行其他操作。)


在容量为 C 的通道上的第 k 个接收先行发生在该通道的第 k + C 个发送完成。


该规则将前一个规则推广到缓冲通道。它允许通过缓冲的 channel 通道对计数信号量进行建模:channel 通道中的元素数量对应于活动使用的数量,channel 通道的容量对应于同时使用的最大数量,发送一个元素获取信号量,以及接收元素会释放信号量。这是限制并发性的常见用法。


该程序为 work 列表中的每个条目启动一个 goroutine,但是 goroutine 使用限制通道进行协调,以确保一次最多运行三个 work 函数。


var limit = make(chan int, 3)
func main() {
  for _, w := range work {
    go func(w func()) {
      limit <- 1
      w()
      <-limit
    }(w)
  }
  select{}
}


锁:

sync 包实现了两种锁定数据类型,即 sync.Mutex 和 sync.RWMutex。


对任何的 sync.Mutex 或 sync.RWMutex 变量 l 和 n < m,n 次调用 l.Unlock()先行发生在 m 次 l.Lock() 返回。


该程序:


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()(在 f 中)先行发生在第二次调用 l.Lock()(在 main 中),先行发生在 print 操作。


对于 sync.RWMutex 变量 l,任意的函数调用 l.RLock 满足第 n 次 l.RLock 后发生于第 n 次调用 l.Unlock,对应的 l.RUnlock 先行发生于第 n+1 次调用 l.Lock。


Once:

sync 包为 Once 类型的多个 goroutine 提供了一种安全的初始化机制。多个线程可以对于一个特定的 f 执行 Do(f),但是只有一个线程将运行 f(),而其它线程调用将阻塞直到 f() 返回。


一个调用 Once.Do(f) 的返回先行发生在其它调用 Once.Do(f) 的返回。


在此程序中:


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 将只调用一次 step。setup 先行发生在两次调用 print 操作。结果是 “ hello,world” 将被打印两次。


05

同步的错误使用示例


注意,读取操作 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。


这个事实证明一些常见的习惯用法是不正确的。


双重检查锁定是为了避免同步的资源开销。例如,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 的写入。此版本的程序可以(不正确)打印一个空字符串,而不是 “ hello,world”。


另一个不正确的习惯用法是忙等待,如:


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

与之前的程序一样,不能保证在 main 上查看到 done 的写入意味着查看到对 a 的写入,因此该程序也可以打印一个空字符串。更糟糕的是,由于两个线程之间没有同步事件,因此无法保证 main 会始终执行写入操作。不能保证 main 中的循环完成。


此示例有一些微妙的改变,例如该程序。

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 的初始化值。


在所有这些示例中,解决方案都是相同的:显式使用同步。


06

总结


本文介绍了Golang 语言的内存模型,介绍了 Happens Before 原则,并给出了一些关于同步的最佳实践和错误示例。文章内容较长,可能会有部分内容翻译不准确,建议读者再阅读一遍英文原文。如果您对 Go 的并发编程还不是很了解,建议阅读文末「推荐阅读」列表的相关文章。





目录
相关文章
|
3月前
|
Go
Golang语言之管道channel快速入门篇
这篇文章是关于Go语言中管道(channel)的快速入门教程,涵盖了管道的基本使用、有缓冲和无缓冲管道的区别、管道的关闭、遍历、协程和管道的协同工作、单向通道的使用以及select多路复用的详细案例和解释。
140 4
Golang语言之管道channel快速入门篇
|
3月前
|
Go
Golang语言文件操作快速入门篇
这篇文章是关于Go语言文件操作快速入门的教程,涵盖了文件的读取、写入、复制操作以及使用标准库中的ioutil、bufio、os等包进行文件操作的详细案例。
71 4
Golang语言文件操作快速入门篇
|
3月前
|
Go
Golang语言之gRPC程序设计示例
这篇文章是关于Golang语言使用gRPC进行程序设计的详细教程,涵盖了RPC协议的介绍、gRPC环境的搭建、Protocol Buffers的使用、gRPC服务的编写和通信示例。
112 3
Golang语言之gRPC程序设计示例
|
3月前
|
安全 Go
Golang语言goroutine协程并发安全及锁机制
这篇文章是关于Go语言中多协程操作同一数据问题、互斥锁Mutex和读写互斥锁RWMutex的详细介绍及使用案例,涵盖了如何使用这些同步原语来解决并发访问共享资源时的数据安全问题。
100 4
|
3月前
|
Go
Golang语言错误处理机制
这篇文章是关于Golang语言错误处理机制的教程,介绍了使用defer结合recover捕获错误、基于errors.New自定义错误以及使用panic抛出自定义错误的方法。
55 3
|
1月前
|
编译器 Go
探索 Go 语言中的内存对齐:为什么结构体大小会有所不同?
在 Go 语言中,内存对齐是优化内存访问速度的重要概念。通过调整数据在内存中的位置,编译器确保不同类型的数据能够高效访问。本文通过示例代码展示了两个结构体 `A` 和 `B`,尽管字段相同但排列不同,导致内存占用分别为 40 字节和 48 字节。通过分析内存布局,解释了内存对齐的原因,并提供了优化结构体字段顺序的方法,以减少内存填充,提高性能。
41 3
|
1月前
|
Java 编译器 测试技术
go语言避免不必要的内存分配
【10月更文挑战第18天】
48 1
|
1月前
|
存储 算法 Java
Go语言的内存管理机制
【10月更文挑战第25天】Go语言的内存管理机制
32 2
|
3月前
|
Go 调度
Golang语言goroutine协程篇
这篇文章是关于Go语言goroutine协程的详细教程,涵盖了并发编程的常见术语、goroutine的创建和调度、使用sync.WaitGroup控制协程退出以及如何通过GOMAXPROCS设置程序并发时占用的CPU逻辑核心数。
72 4
Golang语言goroutine协程篇
|
3月前
|
Prometheus Cloud Native Go
Golang语言之Prometheus的日志模块使用案例
这篇文章是关于如何在Golang语言项目中使用Prometheus的日志模块的案例,包括源代码编写、编译和测试步骤。
77 3
Golang语言之Prometheus的日志模块使用案例
下一篇
DataWorks