您的位置:首页 > 理论基础 > 计算机网络

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

2022-01-08 16:57 501 查看

[翻译] 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 string

func f() {
print(a)
}

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

调用

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

Goroutine destruction (协程的销毁)

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

var a string

func 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 string

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

func main() {
go f()
<-c
print(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 string

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

func main() {
go f()
c <- 0
print(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 <- 1
w()
<-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.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
    ) (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 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 之前完成 (happens before)。结果将会是打印两次 "hello, world"

Incorrect synchronization (错误的同步)

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

var a, b int

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

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

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

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

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

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

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

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

var a string
var done bool

func 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 *T

func 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)。

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: