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

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
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,001评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,210评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,874评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,001评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,022评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,005评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,929评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,742评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,193评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,427评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,583评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,305评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,911评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,564评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,731评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,581评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,478评论 2 352

推荐阅读更多精彩内容

  • 介绍 如何保证在一个goroutine中看到在另一个goroutine修改的变量的值,这篇文章进行了详细说明。 建...
    51reboot阅读 19,653评论 11 41
  • Go的内存模型 看完这篇文章你会明白 一个Go程序在启动时的执行顺序 并发的执行顺序 并发环境下如何保证数据的同步...
    初级赛亚人阅读 2,848评论 0 2
  • 并发(并行),一直以来都是一个编程语言里的核心主题之一,也是被开发者关注最多的话题;Go语言作为一个出道以来就自带...
    驻马听雪阅读 2,999评论 3 27
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,650评论 18 139
  • Chapter 8 Goroutines and Channels Go enable two styles of...
    SongLiang阅读 1,581评论 0 3