我正在尝试理解来自 Go 内存模型的同步代码不正确的示例。
双重检查锁定是为了避免同步开销。例如,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
的写入。此版本可以(错误地)打印空字符串而不是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 看到
done=true
的事实并不意味着它会看到 a
设置。只有在 goroutine 之间存在显式同步时才能保证这一点。
sync.Once
可能提供此类同步,因此这就是您没有观察到此行为的原因。仍然存在内存竞争,并且在具有不同实现的 sync.Once
的不同平台上,情况可能会发生变化。
有关 Go 内存模型的参考页告诉您以下内容:
仅当重新排序不会改变语言规范定义的 Goroutine 内的行为时,编译器和处理器才可以对单个 Goroutine 内执行的读取和写入进行重新排序。
因此,编译器可能会重新排序
setup
函数体内的两次写入,从
a = "hello, world"
done = true
到
done = true
a = "hello, world"
可能会出现以下情况:
doprint
goroutine 不会观察到对 done
的写入,因此会启动 setup
函数的单次执行;doPrint
goroutine 观察对 done
的写入,但在观察对 a
的写入之前完成执行;因此它会打印 a
类型的零值,即空字符串。我运行这段代码大约五次,每次都会打印“hello world”。
您需要了解同步错误(代码的属性)和竞争条件(特定执行的属性)之间的区别; Valentin Deleplace 的这篇文章 很好地阐明了这种区别。简而言之,同步错误可能会也可能不会引起竞争条件。然而,仅仅因为竞争条件没有在程序的多次执行中显现出来,并不意味着您的程序没有错误。
在这里,您可以简单地通过重新排序
setup
中的两个写入并在两者之间添加一个微小的睡眠来“强制”发生竞争条件。
func setup() {
done = true
time.Sleep(1 * time.Millisecond)
a = "hello, world"
}
(游乐场)
这可能足以让您相信该程序确实包含同步错误。
该程序内存不安全,因为:
done
和 a
)。试图推断程序对于这些变量将如何或不会如何表现可能只是不必要的混乱,因为它实际上是未定义的行为。没有“正确”的答案。仅是间接观察,无法保证它们是否或何时成立。
在这个片段中,整个程序可能不正确的结果是:它只打印了 1 行“Hello World”,而不是 2 行。
如果 Go 编译重新排序
setup()
函数的两条语句,可能会发生这种情况。
即使编译没有进行重新排序或其他优化,由于内存模型较弱,在某些平台(例如 ARM)上仍然可能会出现错误的结果。但在 x86 / 64 平台上不会发生这种情况,因为它是 x86-TSO 内存模型。
有关详细信息,请阅读 Go 团队负责人 Russ Cox 撰写的一系列精彩文章,内存模型。