逑识

吾生也有涯,而知也无涯,以无涯奉有涯,其易欤?

0%

Go 语言内存模型

导语」Go 语言内存模型阐明了一种执行条件,在这种条件下,可以保证一个 goroutine 对变量的读操作能够观察到不同 goroutine 对同一变量的写操作所产生的值。了解 Go 语言的内存模型是十分必要的,它可以辅助我们更好地进行并发编程的实现。

Happens Before

在单个 goroutine 中,读取和写入操作必须按照程序中指定的顺序执行,但在多 goroutine 的运行环境下,编译器和处理器可能会对某个 goroutine 中执行的读写操作进行重排序以提升运行效率(前提是重排序操作不会影响到该 goroutine 中的具体读写行为)。

正因为有了重排序操作,多个 goroutine 观察到的同一段代码的执行顺序可能并不相同。例如,一个 goroutine 执行了代码 a = 1; b = 2;,另一个 goroutine 可能会在 a 的值更新之前就观察到 b 已更新的值,也就是说 b 的赋值操作发生在 a 之前。

为了详细论述读取和写入操作的必要条件,我们定义了事件先行发生(happens before)的顺序,它代表了 Go 程序中执行内存操作的一种偏序关系。如果事件 e1 发生在事件 e2 之前,那么我们就说 e2 发生在 e1 之后。如果 e1 既未发生在 e2 之前,又未发生在 e2 之后,那么我们就说 e1e2 是同时发生的。

在单个 goroutine 中,程序中代码执行的顺序即为事件先行发生的顺序。

在满足下述条件时,对变量 v 的读操作 r 是被允许观察到写操作 wv 的修改的:

  1. r 不发生在 w 之前。

  2. w 之后和 r 之前,不存在对变量 v 的其它写操作 w'

为了确保读操作 r 一定能观察到特定写操作 wv 的修改,需要满足下述条件:

  1. w 发生在 r 之前。

  2. 对变量 v 的其它任何写操作要么发生在 w 之前,要么发生在 r 之后。

这对条件的要求要比前一对条件更为严格,它需要确保没有其它写操作与 wr 同时发生。

在单个 goroutine 中,并不存在并发,因此上述两对条件是等价的:读操作 r 可以观察到最近的写操作 w 对变量 v 的修改。当多个 goroutine 访问一个共享的变量 v 时,它们必须使用同步机制来满足先行发生这一条件,以确保读操作能够观察到写操作对变量的修改。

使用零值对变量 v 进行默认初始化时,其效果与在内存中进行写操作是没有区别的。

对大于单个机器字的值进行读取和写入操作,和我们以不确定的顺序对多个机器字大小的值进行读写操作的结果是基本一致的。

Synchronization

Initialization

程序的初始化操作运行在单个 goroutine 中,但是该 goroutine 可能会创建其它并发运行的 goroutines

在程序中,如果包 p 导入了包 q,那么 q 中的 init 函数会先于 p 中的 init 函数执行。

程序中的 main 函数会在所有 init 函数全部完成后才会启动。

Goroutine 创建

使用 go 语句可以直接创建一个 goroutine,该操作发生在 goroutine 正式执行之前。请看以下代码:

var a string

func f() {
print(a)
}

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

调用 hello 函数将会在之后的某个时刻打印 hello, world(可能发生在 hello 函数返回之后)。

Goroutine 销毁

Goroutine 无法保证其退出操作发生在程序中的任何事件之前。请看以下代码:

var a string

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

上述代码中对变量 a 的写操作并没有使用任何同步机制,因此 print 操作可能会在 go 语句启动的 goroutine 退出之前执行,那么它也就无法观察到该 goroutine 对变量 a 所进行的修改。在现实情况中,一个激进点的编译器可能会将整条 go 语句删除以避免出现异常情况。

如果想在一个 goroutine 中观察到另一个 goroutine 的执行效果,请使用锁或 channel 这种同步机制来保证程序按照指定的顺序执行。

Channel 通信

Channel 通信是 goroutine 之间进行同步操作的主要方法。在特定通道上,每一次发送操作都有与其对应的接收操作相匹配,发送和接收操作常发生在不同的 goroutine 上,这样就可以实现不同 goroutine 间的变量共享。

通道上的发送操作会发生在相应的接收操作完成之前。请看以下代码:

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 的发送操作之前,也就发生在通道 c 相应的接收操作完成之前,也就发生在 print 操作之前,所以 print 函数打印的是被修改后的 a 值。

对通道的关闭操作会发生在接收者收到通道返回的零值之前。因此如果将上述代码中的 c <- 0 替换为 close(c) 仍可保证该程序会得到相同的运行结果。

从无缓冲的通道进行的接收操作会发生在发送操作完成之前。请看以下代码(与上面的代码类似,只是交换了发送和接收语句的位置,并且使用了无缓冲通道):

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 操作之前,所以 print 函数打印的也是修改后的 a 值。

如果在上述代码中使用带有缓冲的通道(比如 c = make(chan int, 1)),那么程序将无法保证打印 hello, world(它可能会打印空串,也可能发生程序崩溃或其它异常行为)。

在容量为 C 的通道上进行的第 k 次接收操作会发生在该通道的第 k+C 次发送操作完成之前。将这句话转换一下可能会更好理解:通道的第 k+C 次发送操作的完成应发生在该通道的第 k 次接收操作之后,因为此时通道已满,发送操作会被阻塞,必须执行一次接收操作才能使发送操作继续运行。假如 k=1C=3,那么第 k+C=4 次发送操作必须在第 1 次接收操作之后方可完成。

上述规则将前面的规则推广到了带缓冲的通道上。我们可以使用带缓冲的通道来实现计数信号量:通道中元素的数量表示正在运行的 goroutine 的数量,通道的容量表示可以同时运行的 goroutine 的最大数量。向通道发送元素可以获取一个信号量,并启动一个任务,接收元素可以释放一个信号量,表示该任务已完成,这是限制程序并发数的常见用法。

下面的程序会为 work 中每一项任务都启动一个 goroutine,但它并不是无限制的,这里使用了一个带缓冲的通道 limit 来确保最多可以有三个工作函数同时执行。

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.Mutexsync.RWMutex

对于任何 sync.Mutexsync.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()(在函数 f 中)方法发生在第二次调用 l.Lock() 方法之前,也就发生在 print 操作之前,所以 print 函数打印的是修改后的 a 值。

对于任何 sync.RWMutex 类型的变量 l 和对 l.RLock() 方法的调用,存在这样一个 n,使得对 l.RLock() 方法的调用发生在对 l.Unlock() 方法的第 n 次调用之后,且与其相匹配的 l.RUnlock() 方法发生在对 l.Lock() 方法的第 n+1 次调用之前。

Once

sync 包通过 Once 类型为多个 goroutine 的初始化操作提供了一种安全的机制。多个 goroutine 会为特定的函数 f() 执行 once.Do(f) 操作,但是只有一个 goroutine 最终会运行 f() 函数,其它的 goroutine 会一直阻塞,直到 f() 函数返回。

once.Do(f)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 函数实际只会执行一次 setup 函数,并且 setup 函数会在任意一个 print 操作执行之前返回,因此以上代码会打印两次 hello, world

错误的同步

请注意,对变量 v 的读取操作 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,也就是说 g 对变量 b 的读操作观察到了 fb 的写操作,但是 ga 的读操作并没有观察到 fa 的写操作。这一事实会使许多常见的习惯用法失效。

双重检测是一种避免同步开销的实现方式,但它在以下代码中被误用了:

var a string
var done bool
var once sync.Once

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 的情况,因为有可能因为编译器的重排序操作,变量 a 此时还未被赋值,那么其中一个 goroutine 可能会错误地打印一个空串而非 hello, world

另外一种错误的习惯用法就是忙等待。请看以下代码:

var a string
var done bool

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

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

和前面类似,我们同样也无法保证 print 操作会观察到 setup 函数对变量 a 的写入,因此它也可能会打印一个空串。更糟糕的情况是,由于两个 goroutine 间没有同步机制,main 函数可能无法观察到 setup 函数对变量 done 的写入,因此可能会陷入死循环。

上述代码的一个变体如下所示:

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 并且退出了循环,也无法保证它能观察到 setup 函数写入到 g.msg 中的初始值。

上面所有错误示例的解决方案都是一致的,那就是显示地使用同步机制。

建议

如果程序中有多个 goroutine 同时访问(读、写)某项数据时,必须串行化其读写操作。

为了实现串行化的访问,可以使用通道 (channel) 或 syncsync.atomic 包中的同步原语来对数据进行保护,从而避免发生数据竞态。

参考资料

  1. The Go Memory Model
  2. Go 内存模型中译版
  3. 面向并发的内存模型

欢迎关注我的其它发布渠道