AI智能
改变未来

[翻译] [The Go Memory Model](https://go.dev/ref/mem)


[翻译] The Go Memory Model

[TOC]

Introduction (简介)

Go 内存模型指定了在什么情况下,一个协程对变量的写操作可以被另一个协程读到。

Advice (建议)

当一份数据同时被多个协程访问,在对这份数据进行修改时,需要保证对这份数据的访问时按照一定顺序进行的。

为了让访问有序,需要使用 channel 或者其他同步原语, 在

sync

sync/atomic

下面就提供了很多同步原语。

如果你一定要读剩下的内容以便理解你写的程序的行为,那你真是太聪明了。

可太聪明也不是一件好事。

Happens Before

在一个协程中,读写必须按照程序指定的顺序执行。

也就是说,在一个协程中,虽然编译器和处理器可能会对读写顺序重新排序,但是重排序的结果必须不能破坏上面的规定。

因为存在这种重排序机制,一个协程观测到的执行顺序可能和另一个协程不同。例如,如果一个协程执行

a = 1; b = 2;

,另一个协程看到的顺序可能是:先更新 b 为 2,再更新 a 为 1。

我们定义了

happens before

来指定读和写的顺序。

  • 如果事件 e1 发生在 e2 之前 (happens before),则描述为 e2 发生在 e1之后 (happens after)
  • 如果 e1 既不在 e2 之前发生,也不知 e2 之后发生,则描述为 e1 和 e2 并发发生 (happen concurrenctly)

在一个协程中,happens-before 的顺序就是程序的代码的顺序。

当满足如下条件时,对变量

v

的读操作

r

被允许 (is allowed) 观测到对

r

的写操作

w

被允许观测到并不意味者一定可以观察到?

  1. r

    不发生在

    w

    之前 (not happen before)

  2. 没有其他对
    v

    的写操作

    w\'

    ,其中

    w\'

    发生在

    w

    之后 (happens after) 且发生在

    r

    之前 (happes before)

为了保证 (guarantee) 读操作

r

可以观测到写操作

w

的结果,需要确保

w

是唯一的写操作。也就是说,当满足下面的要求时,

r

保证可以观测到

w

  1. w

    发生在

    r

    之前 (happens before)

  2. 其他对共享变量
    v

    的写操作要么发生在

    w

    之前 (happens before) ,要么发生在

    r

    之后 (happens after)

这一对条件比上一对条件更严格,它要求没有其他写操作与

w

r

同时发生。

在同一个协程内,由于没有并发,所以两条定义是等价的:最近的一条对变量

v

的写操作

w

会被读操作

r

观测到。当有多个协程访问共享变量

v

时,必须使用同步原语来建立

happens-before

条件以保证读操作可以观察到期待的写操作结果。

在内存模型中,初始化一个类型为 t ,值为 0 的变量 v 时,视为一次写操作。

当读写超过一个

machine word

(机器字) 大小的变量时,将会产生多个机器字大小 (totalSize / singleMachineWordSize) 的读写操作,这些操作的顺序是未指定的。

Synchronization (同步)

Initialization (初始化)

程序的初始化操作在一个主协程中执行,这个主协程会创建其他的协程,这些协程并发执行。

如果包

p

导入了另一个包

q

q

里面的

init

方法们将会在

p

init

方法之前被执行 (happens before)。

main

方法将会在所有的

init

方法执行完之后再执行 (happens after)。

Goroutine creation (协程的诞生)

go

关键字将会开启一个新协程,发生在协程开始执行之前 (happens before) (即在创建协程之后,协程才开始执行)

例如,在这个程序中

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

调用

hello

将会在某一时刻打印 "hello, world" (有可能在

hello

return 后才打印)

Goroutine destruction (协程的销毁)

Go 内存模型没有保证协程的退出时刻会发生在程序中的某个事件之前 (happens before),例如,在下面的程序中

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

在为 a 赋值后,后面并没有接任何同步原语,所以并不能保证其他协程一定可以看到 a 更新之后的值。事实上,有些激进的编译器甚至可能会直接将

go func()

那一行给优化掉 (delete) 。

如果需要一个协程的结果被其他协程看到,则必须使用同步机制 (例如锁或者 channel 等) 来为这些事件建立一个相对的顺序。

Channel communication (Channel 通信)

Channel 通信是在多个协程间进行同步的最主要方法。同一个

Channel

上的发送和接收是一一对应的,通常发送和接收操作是在不同的协程上进行的。

channel 的发送操作发生在对应的接收操作完成之前 (happens before)
var c = make(chan int, 10)var a stringfunc f() {a = "hello, world"c <- 0}func main() {go f()<-cprint(a)}

上面这个程序保证能输出 "hello world"

  • 对 a 的写操作发生在 c 的发送操作之前 (happens before)
  • c 的发送操作发生在 c 的接收操作完成之前 (happens before)
  • c 的接收操作发生在 print 之前 (happens before)
对 Channel 的 close 操作发生在 Channel 的接收操作之前 (happens before),且由于 Channel 被关闭,接收方将会收到一个零值

在之前的例子中,如果使用

close(c)

来替换

c <- 0

, 读写行为不会发生改变

unbuffered channel 的接收操作发生在发送操作完成之前 (happens before)

下面的程序和之前的差不多,只不过交换了发送和接收语句的位置并使用了一个 unbuffered channel

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

这段代码同样能保证最终输出 "hello, world"

  • 对 a 的写操作发生在 c 的接收操作之前 (happens before)
  • c 的接收操作发生在 c 的发送操作完成之前
  • c 的发送操作发生在 print 操作之前

如果 channel 是一个 buffered channel , (例如

c = make(chan int,1)

) , 那就无法保证打印出 "hello, world" 了。(它最终将会输出一个空字符串,crash 或其他未知的事情)

一个容量为 c 的管道上的地 k 个接收操作发生在第 (k + c) 个发送操作之前 (happens before)

这条规则可以视为对上面规则的拓展,(当 c = 0 时就是一个 unbuffered channel 了),可以使用 buffered channel 封装出一个信号量 (semaphore),用 channel 里面的元素数量来代表当前正在使用的资源数量,channel 的容量表示同时可以使用的最大资源数量。当申请信号量时,就往 channel 中发送一个元素,释放信号量时就从 channel 中接收一个元素。

下面的程序为

work

列表中的每个元素都开启了一个协程,并使用名字

limit

的 channel 来协调协程,让同一时刻最多有三个方法在执行

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

Locks (锁)

sync

包内实现了两种锁,分别是

sync.Mutex

sync.RWMutex

对于类型为

sync.Mutex

sync.RWMutex

的变量 l,在 n < m 的情况下,对 l.Unlock() 的第 n 次调用发生在 l.Lock() 的第 m 次调用的返回之前 (happens before)

var l sync.Mutexvar a stringfunc f() {a = "hello, world"l.Unlock()}func main() {l.Lock()go f()l.Lock()print(a)}

上面的代码保证会输出 "hello, world"

  • l.Unlock() 的第一次调用 (在
    f()

    内) 发生在第二次调用

    l.lock()

    返回之前 (在

    main

    ) (happens before)

  • 第二次调用 l.lock() 发生在 print(a) 之前 (happens before)
类型为

sync.RWMutex

的变量 l,对任何一次 l.RLock() 的调用,都会存在一个 n,使得 l.RLock() 发生在第 n 次调用 l.Unlock() 之后,并发生在第 n + 1 次 l.Lock 之前

ps: 换句话说就是一旦拿了写锁,除非写锁释放,否则无法拿到读锁;一旦拿到读锁,除非读锁释放,否则无法拿到读锁。

Once

sync

包内

Once

类型为在多协程场景下的初始化提供了一个安全的机制,当多个线程执行 once.Do(f) 时,只有一个能成功执行 f(),其他线程对 once.Do(f) 的调用会被阻塞住,直到 f() 返回

once.Do(f) 中 f() 将会在所有的 once.Do(f) 返回之前返回 (happens before)

var a stringvar once sync.Oncefunc setup() {a = "hello, world"}func doprint() {once.Do(setup)print(a)}func twoprint() {go doprint()go doprint()}

twoprint

方法仅仅会调用一次

setup

setup

将会在 print 之前完成 (happens before)。结果将会是打印两次 "hello, world"

Incorrect synchronization (错误的同步)

注意读操作 r 可能会观察到与它并发执行的写操作 w (happens concurrently),即使这种情况发生了,也并不能表示发生在 r 之后 (happens after) 的其他读操作可以观察到发生在 w 之前 (happens before) 的其他写操作。

var a, b intfunc f() {a = 1b = 2}func g() {print(b)print(a)}func main() {go f()g()}

g() 可能会发生先输出 2 再输出 0 的情况。

这个事实意味着一些常用的技巧可能会失效。例如双重检查锁 (Double-checked locking) 以及忙等待 (busy waiting)。

双重检查锁可以避免同步时的额外开销,例如,下面的

twoprint

程序就可能导致不正确的行为

var a stringvar done boolfunc setup() {a = "hello, world"done = true}func doprint() {if !done {once.Do(setup)}print(a)}func twoprint() {go doprint()go doprint()}

doprint

内,即使观察到了

done

变量被更新为 true,也并不能保证 a 变量被更新为 "hello, world" 了。因此上面的程序可能会打印出一个空字符串。

下面是一段忙等待的代码,它的原本目的是:一直等下去,直接 a 被赋值。

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

和上面一样,观察到 done 的写操作并不能表示能观察到对 a 的写操作。所以这段代码也可能会打印出一个空白的字符串。更糟的是,由于不能保证 done 的写操作一定会被 main 观察到,main 里面的 loop 可能永远都不会退出。

还有一个类似的例子,看下面这段代码

type T struct {msg string}var g *Tfunc setup() {t := new(T)t.msg = "hello, world"g = t}func main() {go setup()for g == nil {}print(g.msg)}

即使 main 观察到 g 非空并退出了循环,也不能保证它能看到 g.msg 被初始化之后的结果

上面的这些例子的解决方案都是一样的,那就是显示地使用同步操作 (use explicit synchronization)。

赞(0) 打赏
未经允许不得转载:爱站程序员基地 » [翻译] [The Go Memory Model](https://go.dev/ref/mem)