跳转至

5.5 内存模型与happens-before关系

学习目标

  • 深入理解Go内存模型的设计
  • 掌握happens-before关系的概念
  • 理解内存重排序问题及其影响
  • 学习同步原语的内存语义

学习内容

Go内存模型详解

内存模型是并发编程中的核心概念,它定义了程序中变量的读写操作在多线程(goroutine)环境下的可见性规则。简单来说,内存模型回答了一个问题:"当一个goroutine修改了某个变量的值,其他goroutine何时能看到这个修改?"

内存模型的定义与作用

Go内存模型规范了多个goroutine访问共享内存时的行为,确保在正确使用同步机制的情况下,程序的行为是可预测的。它为开发者提供了一套规则,用于判断一个goroutine的写操作是否会被另一个goroutine的读操作观察到。

Go内存模型的特点

  1. 简洁性:相比C++或Java的内存模型,Go的内存模型更为简洁
  2. 基于happens-before关系:核心是定义操作之间的happens-before关系
  3. 显式同步:通过特定的同步原语(如channel、mutex等)建立内存可见性
  4. 不保证未同步操作的可见性:对于未使用同步机制的共享变量,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关系:

  1. Channel通信:向channel发送数据happens-before从该channel接收数据完成
  2. 互斥锁:对一个mutex的Unlock操作happens-before后续对该mutex的Lock操作
  3. WaitGroup:所有对WaitGroup的Done调用happens-beforeWait返回
  4. 原子操作:某些原子操作可以建立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为了优化性能而对指令执行顺序进行的调整。在单线程环境下,重排序是透明的,不会影响程序的正确性;但在多线程环境下,重排序可能导致意外的结果。

编译器重排序

编译器在不改变程序单线程语义的前提下,可能会调整指令的执行顺序。

例如,以下代码:

a = 1
b = 2

可能被编译器重排序为:

b = 2
a = 1

对于单线程程序,这两种顺序的结果是一样的,但在多线程环境下可能导致问题。

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)
        }
    }
}

直觉上,我们可能认为ab不可能同时为0,但由于重排序,f()中的操作1和2可能被重排序,g()中的操作3和4也可能被重排序,导致ab同时为0的情况出现。

如何避免重排序问题

Go提供了多种机制来避免重排序问题:

  1. 使用同步原语:如mutex、channel等,它们会隐式地阻止重排序
  2. 使用原子操作:sync/atomic包中的操作提供了内存屏障功能
  3. 避免共享内存:采用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后,重排序被阻止,ab不可能同时为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 实战练习与面试重点 - 通过实战练习巩固同步原语知识