32

Go 内存模型 (2014年5月31日版本)

 5 years ago
source link: https://studygolang.com/articles/16797?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

1 简介

Go 内存模型指定了一个条件,在该条件下,在一个 goroutine 中一个变量的读取可保证能够观测到被其他 goroutine 对该变量写入的变化值。

2 建议

修改能够被多个 goroutine 同时访问到的数据的程序必须序列化此过程。

为了序列化这个访问过程,使用通道操作或其他例如在 syncsync/atomic 包中的同步原语保护数据。

如果你一定要阅读该文档的剩余部分以理解程序的行为,那么你就太聪明了。

不要自作聪明。

3 先行发生原则(Happens Before)

在一个 goroutine 中,读写一定会以在程序中的指定顺序而执行。这意味着,编译器和处理器可能会重新排序在一个 goroutine 中的读写执行,但只有当重排序不会改变语言规范定义的单一 goroutine 内的行为表现时才会发生。由于这种重排序的发生,一个 goroutine 中观测到的执行顺序可能不同于另一个 goroutine 中的观察。例如,如果一个 goroutine 执行 a = 1; b=2; , 另一个可能会观测到 b 先更新而 a 后更新。

为了满足读写需求,我们定义了 happens before 原则, 这是一种在 Go 程序中描述执行内存操作的偏序关系。如果事件 e1 先行发生于事件 e2 , 那么我们说 e2 后发生于 e1 。同理,如果 e1 没有(一定要)先行发生于 e2 , 那么我们说 e1e2 同步发生。

在一个 goroutine 内,happens-before 顺序由程序表述

对变量 v 的读操作 r 被允许观测到对 v 的写操作 w 当以下条件同时满足时:

  1. r 没有先行发生于 w
  2. 没有有另一个对 v 的 写操作 w'w 之后, r 之前发生。

为了保证 对变量 v 的读操作 r 能够观测到某个对 v 的写操作 w ,要确保 wr 被允许观测到的唯一的写操作。这就是说,确保 r 观测到 w 当同时满足下列条件:

  1. w 先行发生于 r
  2. 任何其他对共享变量 v 的写操作要么在 w 之前发生,要么在 r 之后发生。

这对条件的要求要强于第一对条件;他约束了没有其他的写操作和 wr 同时发生。

在一个 goroutine 内,没有并发,因此两个定义是等价的:读操作 r 观测到的值是最近的对 v 的写操作 w 写入的。当多个 goroutine 同时访问一个共享变量 v 时,他们必须使用同步事件建立先行发生(happens-before)条件确保读取期望的写入值。

在内存模型中对变量 v 的初始化含类型零值的操作其表现与写操作一致。

读取和写入超过一个机器字的值其表现与以非指定顺序进行多个机器字操作一致。

4 同步

4.1 初始化

程序初始化运行在一个 goroutine 内,但是这个 goroutine 可能创建其他 goroutines,客观产生并发运行的效果。

如果包 p 引入了包 q, 对 q 的 init 函数的完成要先行发生于任何对 p 的函数的开始。

函数 main.main 的开始要在所有 init 函数的完成后发生。

4.2 Goroutine 创建

go 语句启动了一个新的 goroutine, 先行发生于 goroutine 的开始执行。

例如,对这个程序:

var a string

func f() {
    print(a)
}

func hello() {
    a = "hello, world"
    go f()
}

调用 hello 将打印 hello, world 在未来某个时点(或许在 hello 函数返回之后)

4.3 Goroutine 销毁

不保证一个 goroutine 的退出先行发生于程序的任何事件。例如,对这个程序:

var a string

func hello() {
    go func() { a = "hello" }()
    print(a)
}

a 的赋值操作并为被任何同步事件所保证,因此不保证任何其他的 goroutine 能够观测到该赋值。事实上,一个激进的编译器或许或删除整个 go 语句。

如果一个 goroutine 的影响必须被另一个 goroutine 观测到,就得使用例如一个锁或通道通信的同步机制建立相对顺序。

4.4 通道通信

通道通信是 goroutines 间同步的主要方法。每个特定通道上的发送操作要与该通道上的接收操作对应,通常用于不同的 goroutine。

一个通道上的发送操作在该通道上的接收操作完成之前发生。

这个程序:

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 上的发送在相关的接收操作完成之前发生,在 c 上的接收完成先行发生于 print 函数。

一个通道上的关闭操作在由于通道被关闭的原因接收到返回的零值之前发生。

在前面的例子里,用 close(c) 代替 c <- 0 会使程序表现同样的行为。

一个非缓冲通道上的接收操作在该通道上的发送操作完成之前发生。

以下程序(如同上面的程序,但是交换了发送和接收语句,使用了非缓冲通道):

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 的接收操作在相关的发送操作完成之前发生,对通道 c 的发送完成在 print 函数之前发生。

如果通道是缓冲的(例如, c = make(chan int, 1) ),那么程序将不能保证打印出 hello, world 。(可能会打印出空字符串,崩溃或产生其他什么效果。)

容量为 C 的通道上第 k 个接收操作在该通道第 k + C 个发送操作完成之前发生。

这个规则泛化了之前对于带缓冲通道的规则。它允许计数信号量由带缓冲通道建模: 通道中的条目数对应于活跃使用数,通道容量对应于最大的同时使用数,发送一个条目获取信号量,接收一个条目释放信号量。这是限制并发的常用习惯用法。

以下程序在工作列表中每次进入都会启动一个 goroutine, 但是 goroutines 使用 limit 通道进行协调以确保至多只有3个工作函数同时运行。

var limit = make(chan int, 3)

func main() {
    for _, w := range work {
        go func(w func()) {
            limit <- 1
            w()
            <-limit
        }(w)
    }
    select{}
}

4.5 锁

sync 包引入了两种锁类型, sync.Mutexsync.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 中)返回之前发生,而对 l.Lock() 的第二次调用在 print 函数之前发生。

对于任何对 sync.RWMutext 变量 ll.RLock() 调用,存在一个这样的调用 n , 其 l.RLockl.Unlock 的调用 n 之后发生(返回),匹配的 l.RUnlockl.Lock 的调用 n + 1 前发生。

4.6 Once

sync 包提供了一种机制,该机制允许多个 goroutines 使用 Once 类型进行安全的初始化。多个线程对一个特定的函数 f 能都执行 once.Do(f) , 但是只有一个会运行 f() , 其他调用会阻塞直到 f() 返回。

通过 once.Do(f) 对 f() 的调用在任何调用 once.Do(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 会造成 hello, world 被打印两次。第一次对 doprint 的调用会运行 setup 一次。

5 不正确的同步

注意到读操作 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 的写入。因此,这个程序也可能打印出空字符串。更糟糕的事,无法保证对 done 的写入会被 main 函数观测到,因为在两个线程之间没有同步机制。在 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 的初始化值。

在上述所有的例子中,解决方案只有一个:使用显示同步机制。

6 参考资料

  1. https://golang.org/ref/mem

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK