原文:https://go.dev/ref/mem
怎样理解编程语言的内存模型?
编程语言的内存模型是理解编程语言如何管理和操作计算机内存的关键。 它定义了编程语言中变量、数据结构和程序的存储方式,以及它们之间的交互方式。通过理解内存模型,程序员可以更有效地利用内存资源,优化程序性能,并避免常见的内存错误。
Go语言的内存模型是怎样的?
Go语言的内存模型主要定义了如何在并发环境下安全地读写共享数据。它确保了并发执行的goroutines(Go语言的轻量级线程)之间对共享变量的访问和操作的正确性和一致性。以下是Go语言内存模型的一些关键概念:
- Happens-Before 关系: Go语言的内存模型基于"happens-before"关系来定义内存操作的顺序。如果事件A happens-before 事件B,那么A的执行结果对B是可见的。这确保了内存操作的顺序性和可见性。
- 初始化和启动: main函数的开始执行 happens-before 所有其他goroutines的启动。因此,main函数中的初始化代码是可见的,对后续的goroutines是可访问的。
- Goroutines 间的通信: 当通过通道(channel)进行发送操作时,发送 happens-before 相应的接收。这意味着发送方在通道上的写操作对接收方是可见的。通道提供了一种同步机制,用于确保数据的正确传递和顺序。
- 同步原语: Go语言提供了互斥锁(Mutex)和原子操作(atomic operations)等同步原语,它们用于在多个goroutines之间同步访问共享资源。当使用这些同步原语时,加锁和解锁操作、原子操作的执行顺序等都会根据happens-before关系来确保内存访问的正确性。
- 数据竞争: Go语言的内存模型禁止数据竞争(data race),即禁止两个或多个goroutines并发地读写同一个变量,且至少有一个是写操作。编译器和运行时系统会检测数据竞争,并在检测到时发出警告或错误。
- 顺序一致性: 虽然Go语言的内存模型提供了相对宽松的内存一致性保证,但可以通过使用互斥锁或原子操作来确保顺序一致性(sequential consistency)。顺序一致性意味着内存操作按照它们在代码中出现的顺序执行,并且对于所有观察者来说,这些操作的顺序是一致的。
总的来说,Go语言的内存模型通过定义happens-before关系、使用通道和同步原语以及禁止数据竞争等方式,确保了并发执行的goroutines之间对共享数据的正确访问和操作。这使得Go语言成为一种非常适合编写并发程序的编程语言。然而,为了充分利用Go语言的并发特性,开发者需要仔细理解和遵守其内存模型的规定。
下面我们就开始对Go官方提供的博客(原文:https://go.dev/ref/mem)进行详细的翻译及解读。
综述
数据争用的概念为对内存位置的写入与对同一位置的另一次读取或写入同时发生(即同一位置不同程序在同一时间进行读写) ,除非涉及的所有访问都是 sync/atomic
包提供的原子数据访问。Go的内存模型中建议程序员使用适当的同步机制来避免数据争用。在没有数据争用的情况下,Go 程序的行为就好像所有 goroutines都多路复用到单个处理器上一样。 此属性有时称为 DRF-SC:无数据争用程序以顺序一致的方式执行。
虽然程序员应该在没有数据竞争的情况下编写Go程序,但Go实现在应对数据竞争时所能做的事情是有限的。这些实现可能总是通过报告争用和终止程序来对数据争用做出反应。否则,每次读取单个字大小或子字大小的内存位置时,都必须观察到实际写入该位置的值(可能由并发执行的 goroutine 写入)并且尚未覆盖。这些实现约束使 Go 更像 Java 或 JavaScript,因为大多数竞争的结果数量有限,而不像 C 和 C++,其中任何具有竞赛的程序的含义都是完全不确定的,编译器可以做任何事情。Go 的方法旨在使错误的程序更可靠、更易于调试,同时仍然坚持认为竞争是错误的,工具可以诊断和报告它们。
内存模型
以下Go内存模型的正式定义严格遵循 Hans-J 提出的方法。Boehm 和 Sarita V. Adve 在“Foundations of the C++ Concurrency Memory Model”中,发表在 PLDI 2008 上。数据无竞争程序的定义和无竞争程序的顺序一致性保证与该工作中的定义相同。
内存模型描述了对程序执行的要求,程序执行由 goroutine 执行组成,而 goroutine 执行又由内存操作组成。
内存操作由四个细节建模:
- 操作类型,表示它是普通的数据读取、普通的数据写入,还是同步操作,如原子数据访问、互斥操作或通道操作
- 在程序中的位置
- 正在访问的内存位置或变量
- 操作读取或写入的值
某些内存操作是类似读取的,包括读取、原子读取、互斥加锁和通道接收。其他内存操作是类似写入的,包括写入、原子写入、互斥锁解锁、通道发送和通道关闭。除此之外如原子比较和交换,既是读式的,也是写式的。
Goroutine的执行被建模为单个goroutine执行的一系列内存操作。
- 要求一:每个goroutine中的内存操作必须对应于该goroutine的正确顺序执行,这取决于从内存读取和写入的值。这种执行必须与“在...之前”的序列关系保持一致,这种关系定义为Go语言规范为Go的控制流构造以及表达式求值顺序设定的偏序要求。
Go 程序执行被建模为一组 goroutine 执行,以及一个映射 W,该映射指定每个类似读取操作从中读取的类似写操作。(同一程序的多次执行可以具有不同的程序执行。)
- 要求二:对于给定的程序执行,当仅限于同步操作时,映射W必须可以通过同步操作的某个隐式总顺序来解释,该顺序与顺序以及这些操作的读写值一致。
同步前关系是同步内存操作的部分顺序,派生自 W。如果同步类读内存操作 r 观察到同步类写存储器操作 w(即,如果 W(r) = w),则 w 在 r 之前同步。非正式地,同步前关系是上一段中提到的隐含总顺序的子集,仅限于 W 直接观察到的信息。
在关系之前发生被定义为在关系之前排序和在关系之前同步的并集的传递闭包。
- 要求三:对于在内存位置 x 上读取的普通(非同步)数据 r,W(r) 必须是对 r 可见的写入 w,其中 visible 表示以下两个都成立:
w 发生在 r 之前。
w 不会在 r 之前发生的任何其他写入 w'(到 x)之前发生。
内存位置 x 上的读写数据争用由 x 上的类似读取的内存操作 r 和 x 上的类似写入的内存操作 w 组成,其中至少有一个是非同步的,它们不按 happen before 排序(即 r 既不在 w 之前发生,w 也不在 r 之前发生)。
内存位置 x 上的写入数据争用由 x 上的两个类似写入的内存操作 w 和 w' 组成,其中至少一个是非同步的,它们由 happen 之前无序。
请注意,如果内存位置 x 上没有读写或写入数据争用,则 x 上的任何读取 r 都只有一个可能的 W(r):在发生之前顺序中紧接在它前面的单个 w。
更一般地说,可以证明任何没有数据争用的 Go 程序,这意味着它没有具有读写或写入数据争用的程序执行,只能通过一些顺序一致的 goroutine 执行交错来解释结果。(该证据与上面引用的 Boehm 和 Adve 论文的第 7 节相同。此属性称为 DRF-SC。
正式定义的目的是匹配其他语言(包括 C、C++、Java、JavaScript、Rust 和 Swift)为无种族程序提供的 DRF-SC 保证。
某些 Go 语言操作(如 goroutine 创建和内存分配)充当同步操作。这些操作对 synchronized-before 部分顺序的影响记录在下面的“同步”部分中。各个软件包负责为自己的操作提供类似的文档。
包含数据竞争的程序的实现限制
任何实现都可以在检测到数据争用时报告争用并停止程序的执行。使用 ThreadSanitizer(通过“go build -race
”访问)的实现正是这样做的。
数组、结构体或复数的读取可以按任意顺序实现为对每个子值(数组元素、结构体字段或实/虚分量)的读取。类似地,数组、结构或复数的写入可以按任何顺序实现为每个子值的写入。
内存位置 x 的读取 r 持有不大于机器字的值,必须观察到一些写入 w,使得 r 不会在 w 之前发生,并且没有写入 w',使 w 发生在 w' 之前,w' 发生在 r 之前。也就是说,每次读取都必须观察由前一个或并发写入写入的值。
此外,不允许观察因果关系和“凭空”写入。
鼓励读取大于单个机器字的内存位置,但不要求满足与字大小内存位置相同的语义,并观察单个允许的写入 w。出于性能原因,实现可能会以未指定的顺序将较大的操作视为一组单独的机器字大小的操作。这意味着多字数据结构上的争用可能导致与单次写入不对应的值不一致。当值依赖于内部(指针、长度)或(指针、类型)对的一致性时,就像大多数 Go 实现中的接口值、映射、切片和字符串一样,这种竞争反过来又会导致任意内存损坏。
Go内存模型中的同步
初始化
程序初始化在单个 goroutine 中运行,但该 goroutine 可能会创建其他 goroutine,这些 goroutine 并发运行。
如果软件包 **p**
导入软件包 **q**
,则 **q**
的 **init**
函数的完成发生在 **p**
的任何函数开始之前。
所有 init
函数的完成在main
包下的 main
函数开始之前同步。
Goroutine 创建
启动新 goroutine 的 go
语句在 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
的赋值后不跟任何同步事件,因此不能保证任何其他goroutine都能观察到它。事实上,主动编译器可能会删除整个 **go**
语句。
如果一个 goroutine 的效果必须由另一个 goroutine 来观察,请使用同步机制(如锁或通道通信) 来建立相对排序。
goroutine同步方式一:channel通信
channel通信是 goroutine 之间同步的主要方法。特定通道上的每个发送都与来自该通道的相应接收匹配,通常在不同的 goroutine 中。
在完成从该channel的相应接收之前,将对channel上的发送进行同步。
这个程序:
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
之前进行排序。
channel的关闭在接收之前同步,该接收返回零值,因为channel已关闭。
在前面的示例中,将 c <- 0
替换为 close(c)
将生成具有相同保证行为的程序。
在完成对无缓冲channel的相应发送之前,将同步来自无缓冲channel的接收。
此程序(如上所述,但交换了发送和接收语句并使用无缓冲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
上的接收之前进行排序,在 c
上的相应发送完成之前同步,在 print
之前进行排序。
如果通道是缓冲的(例如,c = make(chan int, 1)
),则无法保证程序打印"hello, world"
。 (它可能会打印空字符串、崩溃或执行其他操作。)
容量为 C 的信道上的第 k 个接收在完成从该信道发送的 k+C 个消息之前同步。
此规则将上一条规则推广到缓冲通道。它允许通过缓冲通道对计数信号量进行建模:通道中的物品数量对应于活动使用的数量,通道的容量对应于同时使用的最大数量,发送项目获取信号量,接收项目释放信号量。这是限制并发的常用用语。
此程序为工作列表中的每个条目启动一个 goroutine,但 goroutine 使用 limit
通道进行协调,以确保一次最多有三个运行工作函数。
var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func(w func()) {
limit <- 1
w()
<-limit
}(w)
}
select{}
}
goroutine同步方式二:锁
sync
包实现了两种锁数据类型,sync.Mutex
和 sync.RWMutex
。
对于任何 sync.Mutex
或 sync.RWMutex
变量 l
和 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()
的第二次调用(在 main
中)返回之前同步,该调用在 print
之前排序。
对于 sync.RWMutex
变量 l
上对 l.RLock
的任何调用,都存在一个 n,使得对 l.Unlock
的第 n 次调用在从 l.RLock
返回之前同步,并且对 l.RUnlock
的匹配调用在从调用 n+1 返回到 l.Lock
之前同步。
成功调用 l.TryLock
(或 l.TryRLock
)等同于调用 l.Lock
(或 l.RLock
)。不成功的调用根本没有同步效果。就内存模型而言,即使互斥锁已解锁,l.TryLock
(或 l.TryRLock
)也可以认为能够返回 false。
goroutine同步方式三:Once
sync
包通过使用 Once
类型,在存在多个 goroutine 的情况下提供了一种安全的初始化机制。多个线程可以对特定 f
执行 once.Do(f)
,但只有一个线程会运行 f()
,其他调用会阻塞,直到 f()
返回。
在返回任何 once.Do(f)
的调用之前,从 once.Do(f)
完成 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"
将被打印两次。
goroutine同步方式四:Atomic
sync/atomic 包中的 API 统称为“原子操作”,可用于同步不同 goroutine 的执行。如果原子操作 B 观察到原子操作 A 的效果,则 A 在 B 之前同步。在程序中执行的所有原子操作的行为都像是按某种顺序一致的顺序执行的。
前面的定义与 C++ 的顺序一致的原子和 Java 的 volatile
变量具有相同的语义。
Finalizers
运行时包提供了一个 SetFinalizer
函数,该函数添加了一个终结器,当程序不再可访问特定对象时,该终结器将被调用。对 SetFinalizer(x, f)
的调用在最终调用 f(x)
之前同步。
其他机制
sync
软件包提供了额外的同步抽象,包括条件变量、无锁映射、分配池和等待组。其中每个文档都指定了它对同步所做的保证。
其他提供同步抽象的包也应该记录它们所做的保证。
同步不正确
带有竞争的程序是不正确的,并且可能显示出非顺序一致的执行。特别要注意的是,读取 r 可以观察到与 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
。
这一事实使一些常见的习语无效。
仔细检查锁定是为了避免同步开销。例如,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"
。
另一个不正确的成语是忙于等待一个值,如:
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func main() {
go setup()
for !done {
}
print(a)
}
如前所述,不能保证在 main
中,观察对 done
的写入意味着对 a
的写入,因此该程序也可以打印空字符串。更糟糕的是,由于两个线程之间没有同步事件,因此无法保证 main
会观察到对 done
的写入。 main
中的循环不保证完成。
这个主题有更微妙的变体,比如这个程序。
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
并退出其循环,也不能保证它会观察到 g.msg
的初始化值。
在所有这些示例中,解决方案都是相同的:使用显式同步。
编译不正确
Go 内存模型对编译器优化的限制与对 Go 程序的限制一样多。某些在单线程程序中有效的编译器优化并非在所有 Go 程序中都有效。特别是,编译器不得引入原始程序中不存在的写入,不得允许单次读取观察多个值,并且不得允许单次写入写入多个值。
以下所有示例都假定 'p' 和 ' q' 指的是多个 goroutine 可访问的内存位置。
不将数据争用引入无争用程序意味着不要将写入从它们出现的条件语句中移出。例如,编译器不得反转此程序中的条件:
*p = 1
if cond {
*p = 2
}
也就是说,编译器不得将程序重写为以下程序:
*p = 2
if !cond {
*p = 1
}
如果 cond
为 false,并且另一个 goroutine 正在读取 *p
,那么在原始程序中,另一个 goroutine 只能观察 *p
和 1
的任何先验值。在重写的程序中,另一个 goroutine 可以观察到 2
,这在以前是不可能的。
不引入数据争用也意味着不假设循环终止。例如,编译器通常不得在此程序中将访问移至 *p
或 *q
的循环之前:
n := 0
for e := list; e != nil; e = e.next {
n++
}
i := *p
*q = 1
如果 list
指向一个循环列表,那么原始程序将永远不会访问 *p
或 *q
,但重写的程序会。(如果编译器能够证明“p”不会惊慌失措,那么将“ p”向前移动是安全的;将“q”向前移动还需要编译器证明没有其他 goroutine 可以访问“ q”。
不引入数据争用还意味着不假设被调用的函数始终返回或没有同步操作。例如,编译器不得在此程序中函数调用之前移动对 *p
或 *q
的访问(至少在不直接了解 f
的精确行为的情况下不能移动):
f()
i := *p
*q = 1
如果调用从未返回,则原始程序将再次永远不会访问 *p
或 *q
,但重写的程序会。如果调用包含同步操作,则原始程序可以在访问 *p
和 *q
之前的边之前建立,但重写的程序不会。
不允许单次读取观察多个值意味着不从共享内存中重新加载局部变量。例如,编译器不得丢弃 i
并从以下程序的 *p
中再次重新加载它:
i := *p
if i < 0 || i >= len(funcs) {
panic("invalid function index")
}
... complex code ...
// compiler must NOT reload i = *p here
funcs[i]()
如果复杂代码需要许多寄存器,则单线程程序的编译器可以在不保存副本的情况下丢弃 i
,然后在 funcs[i]()
之前重新加载 i = *p
。Go 编译器不能这样做,因为 *p
的值可能已经更改。(相反,编译器可能会将 i
溢出到堆栈中。
不允许单次写入写入多个值也意味着不使用局部变量在写入之前作为临时存储的内存。例如,编译器不得在此程序中使用 *p
作为临时存储:
*p = i + *p/2
也就是说,它不能将程序重写为以下程序:
*p /= 2
*p += i
如果 i
和 *p
开始等于 2,则原始代码为 *p = 3
,因此赛车线程只能从 *p
中读取 2 或 3。重写的代码先执行 *p = 1
和 *p = 3
,允许赛车线程也读取 1。
请注意,所有这些优化在 C/C++ 编译器中都是允许的:与 C/C++ 编译器共享后端的 Go 编译器必须注意禁用对 Go 无效的优化。
请注意,如果编译器能够证明数据争用不影响目标平台上的正确执行,则禁止引入数据争用不适用。例如,在基本上所有 CPU 上,重写都是有效的
n := 0
for i := 0; i < m; i++ {
n += *shared
}
into:
n := 0
local := *shared
for i := 0; i < m; i++ {
n += local
}
前提是可以证明 *shared
在访问时不会出错,因为潜在的添加读取不会影响任何现有的并发读取或写入。另一方面,重写在源到源转换器中无效。
结论
编写无数据竞赛程序的 Go 程序员可以依赖于这些程序的顺序一致执行,就像在所有其他现代编程语言中一样。
当涉及到有竞争的程序时,程序员和编译者都应该记住这个建议:不要聪明。
我正在参与2024腾讯技术创作特训营最新征文,快来和我瓜分大奖!