5.5 内存模型与happens-before关系¶
学习目标¶
- 深入理解Go内存模型的设计
- 掌握happens-before关系的概念
- 理解内存重排序问题及其影响
- 学习同步原语的内存语义
学习内容¶
Go内存模型详解¶
内存模型是并发编程中的核心概念,它定义了程序中变量的读写操作在多线程(goroutine)环境下的可见性规则。简单来说,内存模型回答了一个问题:"当一个goroutine修改了某个变量的值,其他goroutine何时能看到这个修改?"
内存模型的定义与作用¶
Go内存模型规范了多个goroutine访问共享内存时的行为,确保在正确使用同步机制的情况下,程序的行为是可预测的。它为开发者提供了一套规则,用于判断一个goroutine的写操作是否会被另一个goroutine的读操作观察到。
Go内存模型的特点¶
- 简洁性:相比C++或Java的内存模型,Go的内存模型更为简洁
- 基于happens-before关系:核心是定义操作之间的happens-before关系
- 显式同步:通过特定的同步原语(如channel、mutex等)建立内存可见性
- 不保证未同步操作的可见性:对于未使用同步机制的共享变量,Go不保证其读写可见性
与其他语言内存模型的对比¶
- C/C++:内存模型复杂,提供多种内存序(memory order)选项
- Java:基于volatile、synchronized和final关键字构建的内存模型
- Go:更简洁,主要通过channel通信和同步原语来保证内存可见性,隐藏了底层复杂的内存序细节
实际编程中的应用¶
在实际Go编程中,内存模型帮助我们理解:
- 为什么未同步的共享变量读取可能得到过期值
- 为什么某些同步操作能保证数据可见性
- 如何安全地在多个goroutine之间共享数据
下面是一个不遵循内存模型导致问题的例子:
package main
import (
"fmt"
"time"
)
var done bool = false
func worker() {
for !done {
// 循环执行一些工作
}
fmt.Println("Worker exited")
}
func main() {
go worker()
time.Sleep(1 * time.Second)
done = true
fmt.Println("Main set done to true")
time.Sleep(1 * time.Second) // 等待worker退出
}
这个程序可能不会按预期工作,worker goroutine可能永远不会退出,因为main goroutine对done的修改可能永远不会被worker goroutine看到。这是因为没有使用任何同步机制来建立两者之间的happens-before关系。
修复这个问题的方法是使用同步原语,比如channel:
package main
import (
"fmt"
"time"
)
func worker(done chan bool) {
<-done // 等待信号
fmt.Println("Worker exited")
}
func main() {
done := make(chan bool)
go worker(done)
time.Sleep(1 * time.Second)
done <- true // 发送信号
fmt.Println("Main sent done signal")
time.Sleep(1 * time.Second) // 等待worker退出
}
happens-before关系¶
happens-before是Go内存模型的核心概念,它是一种用于描述操作之间内存可见性的关系。如果操作A happens-before操作B,那么A的执行结果(包括对内存的修改)对于B来说是可见的。
happens-before的定义¶
在Go中,如果事件A happens-before事件B,那么A对内存的修改在B执行时是可见的。需要注意的是,happens-before关系并不一定意味着A在时间上先于B发生,它描述的是内存可见性而非时间顺序。
程序顺序规则¶
在同一个goroutine中,程序语句的执行顺序遵循happens-before关系。也就是说,按照代码顺序,前面的操作happens-before后面的操作。
package main
import "fmt"
func main() {
var a, b int
a = 1 // 操作1
b = a // 操作2
// 操作1 happens-before操作2
// 因此b的结果一定是1
fmt.Println(b) // 必然输出1
}
在这个例子中,由于操作1在同一个goroutine中位于操作2之前,所以操作1 happens-before操作2,操作2一定能看到a的赋值。
同步规则¶
Go提供了多种同步机制来在不同goroutine之间建立happens-before关系:
- Channel通信:向channel发送数据happens-before从该channel接收数据完成
- 互斥锁:对一个mutex的Unlock操作happens-before后续对该mutex的Lock操作
- WaitGroup:所有对WaitGroup的Done调用happens-beforeWait返回
- 原子操作:某些原子操作可以建立happens-before关系
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var data int
wg.Add(1)
go func() {
defer wg.Done()
data = 42 // 操作1
}()
wg.Wait() // 操作2
fmt.Println(data) // 操作3
// 操作1 happens-before操作2(Wait返回)
// 操作2 happens-before操作3
// 因此操作3一定能看到data=42
}
传递性规则¶
happens-before关系具有传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var a, b, c int
// 第一个goroutine
wg.Add(1)
go func() {
defer wg.Done()
a = 1 // A
b = 2 // B
}()
// 第二个goroutine
wg.Add(1)
go func() {
defer wg.Done()
for b != 2 {} // 等待B完成
c = a // C
}()
wg.Wait()
fmt.Println(c) // 必然输出1
// A happens-before B(程序顺序规则)
// B happens-before C(同步规则,通过b变量的循环检测)
// 因此A happens-before C(传递性)
// 所以c的值一定是1
}
内存重排序问题¶
内存重排序是编译器或CPU为了优化性能而对指令执行顺序进行的调整。在单线程环境下,重排序是透明的,不会影响程序的正确性;但在多线程环境下,重排序可能导致意外的结果。
编译器重排序¶
编译器在不改变程序单线程语义的前提下,可能会调整指令的执行顺序。
例如,以下代码:
可能被编译器重排序为:
对于单线程程序,这两种顺序的结果是一样的,但在多线程环境下可能导致问题。
CPU重排序¶
现代CPU通常采用流水线、乱序执行等技术来提高性能,这也可能导致指令的实际执行顺序与代码顺序不一致。
即使编译器不进行重排序,CPU也可能改变指令的执行顺序,导致不同goroutine看到的内存操作顺序不一致。
重排序对程序的影响¶
重排序可能导致多goroutine程序出现违反直觉的行为。考虑以下示例:
package main
import (
"fmt"
"sync"
)
var x, y int
var a, b int
func f() {
x = 1 // 1
a = y // 2
}
func g() {
y = 1 // 3
b = x // 4
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
x, y, a, b = 0, 0, 0, 0
wg.Add(2)
go func() {
defer wg.Done()
f()
}()
go func() {
defer wg.Done()
g()
}()
wg.Wait()
if a == 0 && b == 0 {
fmt.Printf("Iteration %d: a=%d, b=%d\n", i, a, b)
}
}
}
直觉上,我们可能认为a和b不可能同时为0,但由于重排序,f()中的操作1和2可能被重排序,g()中的操作3和4也可能被重排序,导致a和b同时为0的情况出现。
如何避免重排序问题¶
Go提供了多种机制来避免重排序问题:
- 使用同步原语:如mutex、channel等,它们会隐式地阻止重排序
- 使用原子操作:sync/atomic包中的操作提供了内存屏障功能
- 避免共享内存:采用CSP(通信顺序进程)模型,通过channel传递数据而非共享内存
以下是使用mutex修复上述问题的示例:
package main
import (
"fmt"
"sync"
)
var x, y int
var a, b int
var mu sync.Mutex
func f() {
mu.Lock()
defer mu.Unlock()
x = 1 // 1
a = y // 2
}
func g() {
mu.Lock()
defer mu.Unlock()
y = 1 // 3
b = x // 4
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 10000; i++ {
x, y, a, b = 0, 0, 0, 0
wg.Add(2)
go func() {
defer wg.Done()
f()
}()
go func() {
defer wg.Done()
g()
}()
wg.Wait()
if a == 0 && b == 0 {
fmt.Printf("Iteration %d: a=%d, b=%d\n", i, a, b)
}
}
fmt.Println("Done")
}
使用mutex后,重排序被阻止,a和b不可能同时为0。
同步原语的内存语义¶
Go提供了多种同步原语,每种原语都有特定的内存语义,即它们如何影响内存可见性和重排序。
互斥锁的内存语义¶
sync.Mutex和sync.RWMutex提供了严格的内存语义:
- 对一个mutex的Unlock操作happens-before后续对该mutex的Lock操作
- 在Unlock之前的所有操作,对于Lock之后的所有操作都是可见的
- Lock和Unlock操作会阻止编译器和CPU的重排序
package main
import (
"fmt"
"sync"
)
var data int
var mu sync.Mutex
func writer() {
data = 42 // 1. 写入数据
mu.Unlock() // 2. 解锁,建立happens-before关系
}
func reader() {
mu.Lock() // 3. 加锁,与之前的Unlock建立关系
fmt.Println(data) // 4. 一定能看到data=42
}
func main() {
mu.Lock() // 初始加锁
go writer()
go reader()
// 等待goroutine完成
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
writer()
}()
go func() {
defer wg.Done()
reader()
}()
wg.Wait()
}
原子操作的内存语义¶
sync/atomic包提供的原子操作具有明确的内存语义:
- 原子存储(Store)和原子加载(Load)操作形成happens-before关系
- 原子交换(Swap)和比较交换(CompareAndSwap)操作也提供内存可见性保证
- 不同的原子操作可能有不同的内存序保证(如sequentially consistent, acquire, release等)
package main
import (
"fmt"
"sync"
"sync/atomic"
)
var data int32
var flag int32
func writer() {
atomic.StoreInt32(&data, 42) // 1. 存储数据
atomic.StoreInt32(&flag, 1) // 2. 设置标志
}
func reader() {
for atomic.LoadInt32(&flag) == 0 {
// 等待标志被设置
}
// 一定能看到data=42
fmt.Println(atomic.LoadInt32(&data)) // 3. 读取数据
}
func main() {
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
writer()
}()
go func() {
defer wg.Done()
reader()
}()
wg.Wait()
}
Channel的内存语义¶
Channel是Go中最常用的同步机制之一,具有明确的内存语义:
- 向channel发送数据happens-before从该channel接收数据完成
- 关闭channel happens-before从该channel接收到零值(因关闭而产生)
- 对于带缓冲的channel,第k个发送happens-before第k个接收完成
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int, 1)
var wg sync.WaitGroup
var data int
wg.Add(2)
// 发送goroutine
go func() {
defer wg.Done()
data = 42 // 1. 修改数据
ch <- 1 // 2. 发送信号
}()
// 接收goroutine
go func() {
defer wg.Done()
<-ch // 3. 接收信号
fmt.Println(data) // 4. 一定能看到data=42
}()
wg.Wait()
}
WaitGroup的内存语义¶
sync.WaitGroup的内存语义如下:
- 所有对WaitGroup的Add和Done调用happens-beforeWait方法返回
- Wait返回后,所有被等待的goroutine的操作对于后续操作都是可见的
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
var data int
// 启动工作goroutine
wg.Add(1)
go func() {
defer wg.Done()
data = 42 // 1. 修改数据
}()
// 等待工作完成
wg.Wait() // 2. 等待完成
// 一定能看到data=42
fmt.Println(data) // 3. 读取数据
}
通过理解这些同步原语的内存语义,我们可以编写出正确的并发程序,确保在多goroutine环境下数据的可见性和一致性。
下一节:5.6 实战练习与面试重点 - 通过实战练习巩固同步原语知识