Unique's Blog

Go 内存模型

2023-04-04 · 2834字 · 11 min read
🏷️  Go

https://go.dev/ref/mem The Go Memory Model 整理学习。

Go 内存模型规定了,在一个 goroutine 中读取一个变量时,可以保证观察到不同 goroutine 中对同一变量的写入所产生的值的条件。

数据竞争:对一个内存位置的写与对同一位置的另一个读或写同时发生,除非所有涉及的访问都是同步/原子包提供的原子数据访问。

内存模型

内存操作由四个细节描述:

  • 它的类型,指示它是普通数据读取、普通数据写入还是同步操作,例如原子数据访问、互斥操作或通道操作。
  • 它在程序中的位置。
  • 正在访问的内存位置或变量。
  • 操作读取或写入的值。

要求 1每个 goroutine 中的内存操作必须对应于该 goroutine 的正确顺序执行,考虑从内存中读取和写入的值。该执行必须与 sequenced before 关系一致,该关系定义为 Go 语言规范对于 Go 控制流构造的部分顺序要求以及表达式的求值顺序。

【Go 程序执行被建模为一组 goroutine 执行,以及一个映射 W,指定每个类似读取的操作从哪个类似写入的操作中读取。】
要求 2:对于给定的程序执行,当限制为同步操作时,映射 W 必须可以通过一些隐含的同步操作的总序来解释,该总序与这些操作读取和写入的值以及顺序一致。

synchronized before 关系是从 W 派生的同步内存操作的偏序关系。如果同步读取类内存操作 r 观察到同步写入类内存操作 w(即,如果 W(r) = w),则 w 在 r 之前同步。

happens before 关系被定义为 sequenced before 关系和 synchronized before 的并集的传递闭包。

要求 3:对于内存位置 x 上的普通(非同步)数据读取 r,W(r) 必须是对 r 可见的写入 w,其中“可见”意味着以下两个条件都成立:

  • w 在 r 之前发生。
  • w 不在任何其他写入 w'(到 x)之前发生,而这些写入 w' 在 r 之前发生。
    【简单来说,对于一个内存位置 x,如果一个普通数据读取 r 读取了该位置的值,那么它必须读取到最近的写入 w 的值,该写入 w 满足 w 在 r 之前发生,并且没有其他写入 w' 在 w 和 r 之间发生。这个要求确保了内存读取操作读取到的是最近的写入操作的值,而不是过时的值。】

对含数据竞争的限制

任何实现都可以在检测到数据竞争时报告该竞争并停止程序的执行。使用 ThreadSanitizer(通过 go build -race 访问)的实现正是这样做的。

同步

初始化

  • 程序的初始化是在一个单独的 goroutine 中运行;
  • 如果 p 包导入 q 包,那么 q 包的 init函数happen before p 包任何函数;
  • 所有的 init 函数 的完成是 synchronized before 主包中的 main.main 函数执行。
    【Go 程序中,程序的初始化过程是由一个单独的 goroutine 负责,该 goroutine 会按照包的导入顺序依次执行每个包的 init 函数,直到所有包的 init 函数都被执行完毕包括主包的 init 函数。最后启动新的主 goroutine 运行 main 函数。】

Goroutine 创建

创建新 goroutine 的 go 语句 synchronized before 这个 goroutine 的执行开始。

Goroutine 销毁

不能保证 goroutine 的退出 synchronized before 于程序中的任意事件。

Channel 通信

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

  • 一个 channel 的一次 send 是 synchronized before 于该 channel 上其对应 receive 的完成。
var c = make(chan int, 10)
var a string

func f() {
	a = "hello, world"
	c <- 0 // close(c) 
}

func main() {
	go f()
	<-c
	print(a)
}

例子保证会打印 "hello world"。对 a 的写是 sequence beforec 的发送,c 的发送是 synchronize before 于对应(int) c 的接收,而 c 的接收 sequence before 于打印。

  • channel 的关闭是  synchronized before 其对应的 receive,并且由于通道关闭,接收返回的是通道元素类型的零值。
  • 无缓冲 channel 的 receive 是 synchronized before 于该通道其对应 send 的完成。
    使用无缓冲 channel 改写:
var c = make(chan int)
var a string

func f() {
	a = "hello, world"
	<-c
}

func main() {
	go f()
	c <- 0
	print(a)
}
  • 容量为 C 的 channel 的第 k 次 receive 是 synchronized before 于该通道的第 k+C 次 send 的完成。
    【容量为 C 的 channel 使用信号量来实现】
    使用缓冲的 channel 来限制并发执行任务的数量:
var limit = make(chan int, 3)

func main() {
	for _, w := range work {
		go func(w func()) {
			limit <- 1
			w()
			<-limit
		}(w)
	}
	select{} // 一直阻塞
}

Locks

sync package 实现了两种锁 lock 数据类型:sync.Mutex 以及 sync.RWMutex

  • 对于任何 sync.Mutexsync.RWMutex 变量 l,并且 n<mn<m ,前 n 次调用 l.Unlock() 完成是 synchronized before 于第 m 次 l.Lock() 返回。
  • 对于任何对 sync.RWMutex 变量 ll.RLock() 调用,都存在一个 n,使得第 n 次调用 l.Unlock()synchronized beforel.RLock() 返回,并且对应的 l.RUnlock() 调用完成 synchronized before 于第 n+1 次对 l.Lock() 的调用返回。
    【就内存模型而言,l.TryLock(或 l.TryRLock)可以被认为是能够返回 false 的,即使是在 mutex l 是解锁状态。】

Once

sync package 通过 Once 类型为存在多 goroutine 情况下的初始化提供了一种安全机制。
多个线程为函数 f 可执行 once.Do(f),只有一个 goroutine 执行 f 函数,其它 goroutine 会阻塞直到该 f 函数返回。
【使用 done 标志和互斥锁实现 】

  • once.Do(f) 中该单次 f 调用完成是 synchronized before 于任何 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()
}
// "hello, world" will be printed twice.

Atomic Values

sync/atomic 包中的 API 是“原子操作”,可用于同步不同 goroutine 的执行。如果原子操作 A 的效果被原子操作 B 观察到,则 A 在 B 之前同步。程序中执行的所有原子操作都表现为按某种顺序连续执行的顺序一致。这个定义与 C++的顺序一致原子(变量)和 Java 的 volatile 变量具有相同的语义。

Finalizers

在 Go 语言中,Finalizer 是一种机制,用于在对象被垃圾回收器回收之前执行一些清理操作。Finalizer 是通过 runtime.SetFinalizer 函数设置的,该函数接受两个参数:要设置 Finalizer 的对象和一个函数,该函数将在对象被垃圾回收器回收之前执行。

当一个对象被创建时,Go 语言的垃圾回收器会为其分配内存,并将其标记为“可达”。如果该对象不再被引用,垃圾回收器会将其标记为“不可达”,并在某个时间点回收该对象的内存。在回收对象之前,垃圾回收器会检查该对象是否有 Finalizer。如果有,垃圾回收器会将该对象添加到一个 Finalizer 队列中,并在下一次垃圾回收时执行 Finalizer 函数。

另外,由于 Finalizer 函数的执行是不确定的(甚至可能不执行),因此不建议在程序中过多地使用 Finalizer。如果需要释放资源或执行必须的清理操作,应该使用 defer 语句或显式地调用清理函数来实现。

  • 调用 SetFinalizer(x, f)synchronized before 函数 f(x) 的调用。

其它

condition variableslock-free mapsallocation pools, and wait groups.

错误同步

程序中存在竞争条件时,可能会出现非顺序一致的执行结果。特别地,一个读操作 r 可能会观察到与 r 并发执行的任何写操作 w 所写入的值。即使这种情况发生了,也不能保证在 w 之前发生的写操作会被在 r 之后的读操作所观察到。

var a, b int

func f() {
	a = 1
	b = 2
}

func g() {
	print(b)
	print(a)
}

func main() {
	go f()
	g()
}

g() 可能打印 2 和 0 (f 函数两个写语句不满足 sequenced before 关系

另一个例子,不正确的忙等待:

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
}

func main() {
	go setup()
	for !done {
	}
	print(a)
}

可能打印空字符串,main 中观察到写入 done,并不保证已经观察到写入 a。
更糟糕的是:由于两个线程之间没有同步事件,所以也无法保证写入 done 的内容会被 main 观察到,不能保证 main 中的循环能够结束。

需要注意上述问题的解决方法:使用显示的同步语句

编译优化限制

Go 语言内存模型对编译器优化的限制,一些编译器优化在单线程程序中是有效的,但不是对于所有 Go 程序都是有效的。特别是,编译器不能引入源程序中不存在的写入操作,不允许使得单个读取操作观察到多个不同的值,不允许使得单个写操作写入多个值。【主要目的,避免编译器在优化程序时引入数据竞争以及不确定的行为。】

  • 在没有竞争条件的程序中,不应该将写操作从它们所在的条件语句中移出。(避免其它 goroutine 观察可能观察到原来不可能出现的值)

  • 在没有竞争条件的程序中,编译器不应该假设循环一定会终止。

  • 在没有竞争条件的程序中,编译器不应该假设被调用的函数总是会返回或不包含同步操作。

  • 编译器不允许单个读操作观察到多个值,这意味着编译器不应该从共享内存中重新加载本地变量。

i := *p
if i<0 || i>=len(funcs){...}
funcs[i]()

// 禁止重新加载 *p
funcs[*p]()
  • 编译器不允许单个写操作写入多个值(禁止在写入之前把将要写入的局部变量作为临时存储)
*p = i + *p/2 // if i=2,*p=2, 只能观察到 *p 为 2/3

// 不能改写为 2/1/3
*p /=2 
*p += i

说明:所有这些优化在 C/C++编译器中都是允许的,但是与 C/C++编译器共享后端的 Go 编译器必须注意禁用那些对 Go 无效的优化。

请注意,禁止引入数据竞争的规则不适用于编译器可以证明这些竞争不会影响目标平台上的正确执行的情况。

总结

  1. 对于没有数据竞争的程序,可以放心地依赖于顺序一致的执行;
  2. 而对于存在数据竞争的程序,应该避免过于聪明的优化,编写易于理解和推理的代码,并使用同步原语来确保程序的正确性。

本文链接: Go 内存模型

版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。

发布日期: 2023-04-04

最新构建: 2024-12-26

本文已被阅读 0 次,该数据仅供参考

欢迎任何与文章内容相关并保持尊重的评论😊 !

共 43 篇文章 | Powered by Gridea | RSS
©2020-2024 Nuo. All rights reserved.