Go 内存模型 (Go Blog 翻译)

翻译一下 The Go Memory Model.

介绍

本文明确了不同 goroutine 间变量可见性的条件。在这里,变量可见性是指当一个 goroutine 中有对一个变量的读取操作时,能够保证观察到其他 goroutine 中对同一个变量的修改操作。

建议

当你的程序要修改一份数据,而此数据会被多个 goroutine 同时访问 (读取或修改),那么你应该串行化这些访问操作。

为了串行化访问操作,你应该使用 channel 操作,或其他同步的基本数据类型,比如 sync 和 sync/atomic 包来保护数据。

如果你必须读下面的文档来理解你的程序行为,你就实在太聪明了。

不要做聪明人。 (译者注:啥意思?理解不能)

发生前

在单个 goroutine 中,对数据的读写操作会按照程序指定的方式来执行。按照语言细则规定,在一个 goroutine 中,在重排序不影响本 goroutine 行为时,编译器和处理器可能会对读写操作进行重排序。由于这种排序操作,从 goroutine A 中观察本 goroutine 的执行顺序,和从另一个 goroutine B 中观察 goroutine A 的执行顺序,是有可能会出现差异的。比如,当一个 goroutine 执行 a=1; b=2;,在另一个 goroutine 中观察,执行顺序有可能是 b 在 a 之前被更新。

为了说明读写操作的需求,我们定义了发生前,即 Go 程序中内存操作执行的局部顺序。如果事件 e1 发生在事件 e2 之前,那么我们认为 e2 发生在 e1 之后. 如果 e1 没有发生在 e2 之前,并且 e2 没有发生在 e1 之前,那么我们认为 e1 和 e2 同时执行。

在单个 goroutine 内,发生前 的顺序即程序呈现的顺序。

对于一个变量 v,读操作 r 能获取到写操作 w 对 r 的更改的前提是:

  1. r 没有发生在 w 之前。
  2. 在 w 之后,r 之前,没有其他针对变量 v 的写操作 w`。

为了保证对 v 的读操作 r 能够获取到写操作 w 对 r 的更改,我们应该确保 w 是 r 被允许观察到的唯一写操作 (即 r 只能观察到 w 的修改)。r 被保证能观察到 w 的条件是:

  1. w 发生在 r 之前。
  2. 任何其他对共享变量 v 的写操作 w`,或发生在 w 之前,或发生在 r 之后。

这一组条件限制要强于上一组限制;它要求在 w 和 r 操作时没有其他写操作同时执行。

在单个 goroutine 中是没有并发的,所有这两组限制是等价的:写操作 r 观察到的 v 值是最近一次写操作 w 的执行结果。当多个 goroutine 同时访问一个共享变量 v 时,必须使用同步事件来确定发生前的条件,以确保读操作能获取到想要的写操作的结果。

将变量 v 初始化为 “0” (相对于其类型的零值) 的操作,在内存模型中被认为是一个写操作。

对大于单个字 (machine word) 的值的读写操作,被认为是以不确定顺序进行的字操作

同步

初始化

程序的初始化过程是在单个 goroutine 中执行的,但是这个 goroutine 可能会创建多个并发执行的 goroutine。

如果一个包 (package) p 导入 (import) 了另一个包 q,那么 q 的 init 方法执行完成后,p 程序才会开始执行。

方法 main.main 要在所有 init 方法完成后才开始执行。

Goroutine 的创建

go 声明会启动一个新的 goroutine,当此声明执行后,相应的 goroutine 才会开始执行。

比如看以下程序:

var a string

func f() {
    print(a)
}

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

调用 hello 方法后,将会在未来某个时间点打印 "hello, world" (可能在 hello 方法返回之后,也有可能在 hello 方法返回之前就打印了。)

Goroutine 的销毁

无法保证 goroutine 在任何事件之前退出。比如在以下程序中:

var a string

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

对 a 赋值操作之后,并没有任何同步事件,所以这个对 a 的写操作并不保证对其他 goroutine 可见。实际上,在主动编译器 (aggressive compiler) 中,这句 go 声明语句可能会被完全删除。(因为这个语句对整个程序没有任何用处)

如果一个 goroutine 中的操作效果需要被其他 goroutine 可见的话,我们应使用同步机制,比如锁或 channel 通信,来确定操作的顺序。

Channel 通信

Channel 通信是 goroutine 间同步的主要方法。对于一个 Channel c,每个对 c 的发送消息的事件 s,都会对应一个从 c 接收相应消息的事件 r。s 和 r 通常都在不同的 goroutine 中,且 s 要早于 r 完成的时间。

看下面一个例子:

var c = make(chan int, 10)
var a string

func f() {
    a = "hello, world"
    c <- 0
}

func main() {
    go f()
    <-c
    print(a)
}

这个例子保证会在主 goroutine (对应 main 方法) 结束前打印出 "hello, world"。对 a 的赋值操作早于对 c 的发送消息操作,而对 c 的发送消息操作要早于对 c 的接收相应消息操作的完成时间,当对 c 的接收相应消息操作完成之后,print 方法被调用,打印出 "hello, world"。

对 Channel 的关闭操作发生在对同一个 Channel 的接收消息事件之前。在这个情况下,从 Channel 接收消息操作返回 0 值。

在上面的程序中,如果将 c <- 0 替换为 close(c),那么程序呈现效果是一样的。

对于无缓冲 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 的接收消息操作早于对 c 发送消息操作的完成时间。对 c 发送消息操作完成之后,print 方法被调用,打印出 "hello, world"。

在这个程序中,如果把 Channel 类型改为有缓冲的 Channel (比如:c = make(chan int, 1)),那么就不能保证打印 "hello, world" 了。结果可能是打印空字符串,程序崩溃,或其他后果。

假设有缓冲 Channel c 的容量为 C,那么对于 c 的第 k 次接收消息操作发生时间要早于第 k+C 次发送消息操作的结束时间。

这个规则是对上个针对有缓冲 Channel 规则的概括。它允许使用一个有缓冲 Channel 来构建一个计数信号量 (counting semaphore):Channel 中的消息个数对应了活跃的使用次数,Channel 的容量对应了同时使用量的最大值,向 Channel 发送一个消息等价于获取一个信号量,从 Channel 接收一个消息等价于释放一个信号量。这是一个常用的限制并发的方式。

下面的程序从工作链表中取出各个条目,对于每个条目启动一个 goroutine。同时使用一个叫 limit 的 Channel 去控制 goroutine 量,以确保最多只有三个 goroutine 在同时运行。

var limit = make(chan int, 3)

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

sync 包实现了两种数据锁类型:sync.Mutex 和 sync.RWMutex.

对于任意 sync.Mutex 或 sync.RWMutex 类型的变量 l,l.Unlock() 操作发生在 l.Lock() 操作返回之前。

看下面一个例子:

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"。在 f 方法中第一次调用 l.Unlock() 操作执行时间早于在 main 方法中第二次调用 l.Lock() 返回的时间,而在 main 方法中第二次调用 l.Lock() 返回的时间要早于 print 方法执行时间。

对于 sync.RWMutex 类型的变量 l,l.RLock 方法发生 (方法返回) 时间在 l.Unlock 之后,对应的 l.RUnlock 发生时间要早于下次 l.Lock 发生时间。

Once

通过使用 sync.Once 数据类型,sync 包提供了在多个 goroutine 中进行初始化的安全机制。对于一个方法 f,在多个协程中执行 once.Do(f) 命令,能保证只有一个协程实际运行 f 方法,其他协程中这一命令会阻塞,知道本协程中 f 方法返回。

在 once.Do(f) 中,一次调用方法 f() 返回之后,其他对 once.Do(f) 的调用才能返回。

看下面一个例子:

var s 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 方法,第二次调用 doprint 方法不执行 setup 方法,且 once.Do(setup) 会阻塞直到第一个协程中 setup 方法返回。(译者注:感觉这块说的有问题,执行的顺序有可能是第二次调用先执行,第一次调用后执行)

错误的同步

对于同时执行的读操作 r 和写操作 w,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()
}

打印结果有可能是 2 和 0 (即 b 为 2,a 为 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 并退出循环,也不能保证主 goroutine 能观察到 g.msg 的初始化值。

对于以上的例子,解决方法是一样的:使用显式的同步。

转载于:https://my.oschina.net/dokia/blog/1835373

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值