问题描述
我试图理解来自 The Go Memory Model 的同步代码不正确的示例。
双重检查锁定是一种避免同步开销的尝试。例如,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"
。
打印空字符串代替“hello world”的详细原因是什么?我运行这段代码大约五次,每次都打印“hello world”。
编译器会交换一行 a = "hello,world"
和 done = true
以进行优化吗?只有在这种情况下,我才能理解为什么会打印空字符串。
package main
import(
"fmt"
"sync"
)
var a string
var done bool
var on sync.Once
func setup() {
a = "hello,world"
done = true
}
func doprint() {
if !done {
on.Do(setup)
}
fmt.Println(a)
}
func main() {
go doprint()
go doprint()
select{}
}
解决方法
根据 Go 内存模型:
不能保证一个 goroutine 会看到另一个 goroutine 执行的操作,除非两个 goroutine 之间使用通道、互斥锁进行显式同步。等
在您的示例中:goroutine 看到 myapp1
的事实并不意味着它会看到 done=true
集。只有在 goroutine 之间存在显式同步时才能保证这一点。
a
可能提供了这样的同步,所以这就是您没有观察到这种行为的原因。仍然存在内存竞争,并且在具有不同 sync.Once
实现的不同平台上,情况可能会发生变化。
reference page about the Go Memory Model 告诉您以下内容:
编译器和处理器只有在重新排序不会改变语言规范定义的 goroutine 中的行为时,才可以重新排序在单个 goroutine 中执行的读取和写入。
因此编译器可能会重新排序 setup
函数体内的两次写入,来自
a = "hello,world"
done = true
到
done = true
a = "hello,world"
可能会出现以下情况:
- 一个
doprint
协程不观察对done
的写入,因此会启动一次setup
函数的执行; - 另一个
doPrint
协程观察对done
的写入,但在观察对a
的写入之前完成执行;因此,它打印a
类型的零值,即空字符串。
我运行这段代码大约五次,每次都打印“hello world”。
您需要了解同步错误(代码的属性)和竞争条件(特定执行的属性)之间的区别; this post by Valentin Deleplace 在阐明这种区别方面做得很好。简而言之,同步错误可能会也可能不会引起竞争条件。然而,仅仅因为竞争条件没有在您的程序的多次执行中表现出来并不意味着您的程序没有错误。
在这里,您可以简单地通过重新排序 setup
中的两次写入并在两者之间添加一个小睡眠来“强制”发生竞争条件。
func setup() {
done = true
time.Sleep(1 * time.Millisecond)
a = "hello,world"
}
这可能足以让您相信该程序确实包含同步错误。
,该程序不是内存安全的,因为:
- 多个 goroutine 同时访问同一个内存(
done
和a
)。 - 并发访问并不总是由显式同步控制。
- 访问可能会写入/修改内存。
试图推断程序将如何或将不会在这些变量方面的行为可能只是不必要的混淆,因为它实际上是未定义的行为。没有“正确”的答案。仅是间接观察,无法保证它们是否或何时成立。