Go的内存模型

Golang语言

介绍

如何保证在一个goroutine中看到在另一个goroutine修改的变量的值,这篇文章进行了详细说明。

建议

如果程序中修改数据时有其他goroutine同时读取,那么必须将读取串行化。为了串行化访问,请使用channel或其他同步原语,例如sync和sync/atomic来保护数据。

先行发生

在一个gouroutine中,读和写一定是按照程序中的顺序执行的。即编译器和处理器只有在不会改变这个goroutine的行为时才可能修改读和写的执行顺序。由于重排,不同的goroutine可能会看到不同的执行顺序。例如,一个goroutine执行a = 1;b = 2;,另一个goroutine可能看到ba之前更新。

为了说明读和写的必要条件,我们定义了先行发生(Happens Before)--Go程序中执行内存操作的偏序。如果事件e1发生在e2前,我们可以说e2发生在e1后。如果e1不发生在e2前也不发生在e2后,我们就说e1e2是并发的。

在单独的goroutine中先行发生的顺序即是程序中表达的顺序。
当下面条件满足时,对变量v的读操作r被允许看到对v的写操作w的:

1 r不先行发生于w
2 在w后r前没有对v的其他写操作

为了保证对变量v的读操作r看到对v的写操作w,要确保wr允许看到的唯一写操作。即当下面条件满足时,r 被保证看到w

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

这一对条件比前面的条件更严格,需要没有其他写操作与w或r并发发生。

单独的goroutine中没有并发,所以上面两个定义是相同的:读操作r看到最近一次的写操作w写入v的值。当多个goroutine访问共享变量v时,它们必须使用同步事件来建立先行发生这一条件来保证读操作能看到需要的写操作。 对变量v的零值初始化在内存模型中表现的与写操作相同。 对大于一个字的变量的读写操作表现的像以不确定顺序对多个一字大小的变量的操作。

同步

初始化

程序的初始化在单独的goroutine中进行,但这个goroutine可能会创建出并发执行的其他goroutine。

如果包p引入(import)包q,那么q的init函数的结束先行发生于p的所有init函数开始 main.main函数的开始发生在所有init函数结束之后

创建goroutine

go关键字开启新的goroutine,先行发生于这个goroutine开始执行,例如下面程序:

 a string

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

调用hello会在之后的某时刻打印出"hello, world"(可能在hello返回之后)

销毁goroutine

gouroutine的退出并不会保证先行发生于程序的任何事件。例如下面程序:

a string

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

没有用任何同步操作限制对a的赋值,所以并不能保证其他goroutine能看到a的变化。实际上,一个激进的编译器可能会删掉整个go语句。 如果想要在一个goroutine中看到另一个goroutine的执行效果,请使用锁或者channel这种同步机制来建立程序执行的相对顺序。

channel通信

channel通信是goroutine同步的主要方法。每一个在特定channel的发送操作都会匹配到通常在另一个goroutine执行的接收操作。

在channel的发送操作先行发生于对应的接收操作完成 例如:

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上的对应的接收完成,先行发生于print

对channel的关闭先行发生于接收到零值,因为channel已经被关闭了

在上面的例子中,将c <- 0替换为close(c)还会产生同样的结果。

无缓冲channel的接收先行发生于发送完成

如下程序(和上面类似,只交换了对channel的读写位置并使用了非缓冲channel):

 c = make(chan int)
var a string

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

此程序也能保证打印出"hello, world"。对a的写先行发生于从c接收,先行发生于向c发送完成,先行发生于print

如果是带缓冲的channel(例如c = make(chan int, 1)),程序不保证打印出"hello, world"(可能打印空字符,程序崩溃或其他行为)。

在容量为C的channel上的第k个接收先行发生于从这个channel上的第k+C次发送完成

这条规则将前面的规则推广到了带缓冲的channel上。可以通过带缓冲的channel来实现计数信号量:channel中的元素数量对应着活动的数量,channel的容量表示同时活动的最大数量,发送元素获取信号量,接收元素释放信号量,这是限制并发的通常用法。

下面程序为work中的每一项开启一个goroutine,但这些goroutine通过有限制的channel来确保最多同时执行三个工作函数(w)。

 limit = make(chan int, 3)

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

sync包实现了两个锁的数据类型sync.Mutex和sync.RWMutex。

对任意的sync.Mutex或sync.RWMutex变量l和n < m,n次调用l.Unlock()先行发生于m次l.Lock()返回
下面程序:

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()中)先行发生于main中的第二次l.Lock()返回, 先行发生于print。

对于sync.RWMutex变量l,任意的函数调用l.RLock满足第n次l.RLock后发生于第n次调用l.Unlock,对应的l.RUnlock先行发生于第n+1次调用l.Lock。

Once

sync包的Once为多个goroutine提供了安全的初始化机制。能在多个线程中执行once.Do(f),但只有一个f()会执行,其他调用会一直阻塞直到f()返回。
通过执行先行发生(指f()返回)于其他的返回。
如下程序:

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"两次。setup只在第一次doprint时执行。

错误的同步方法

注意,读操作r可能会看到并发的写操作w。即使这样也不能表明r之后的读能看到w之前的写。
如下程序:

 a, b int

func f() {
    a = 1
    b = 2
}

func g() {
    print(b)    
    print(a)
}
    
func main() {
    go f()
    g()
}

g可能先打印出2然后是0。
这个事实证明一些旧的习惯是错误的。
双重检查锁定是为了避免同步的资源消耗。例如twoprint程序可能会错误的写成:

 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"。
另一个错误的习惯是忙等待 例如:

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中的循环不保证能结束。
对程序做一个微小的改变:

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的初始化值。
在上面所有的例子中,解决办法都是相同的:明确的使用同步。

作者丨 tailnode
链接丨 http://tailnode.tk/2017/01/Go的内存模型/
原文丨https://golang.org/ref/mem

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

推荐阅读更多精彩内容

  • Go的内存模型 看完这篇文章你会明白 一个Go程序在启动时的执行顺序 并发的执行顺序 并发环境下如何保证数据的同步...
    初级赛亚人阅读 2,833评论 0 2
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,585评论 18 139
  • Goroutine是Go里的一种轻量级线程——协程。相对线程,协程的优势就在于它非常轻量级,进行上下文切换的代价非...
    witchiman阅读 4,809评论 0 9
  • 控制并发有三种种经典的方式,一种是通过channel通知实现并发控制 一种是WaitGroup,另外一种就是Con...
    wiseAaron阅读 10,640评论 4 34
  • 晚上刚躺下休息,大舅打来电话说:“明儿,你赶紧回来一趟吧,你妈她快不行了,你回来送送她吧!”“恩,我知道了,我刚下...
    光丶可以改变黑暗阅读 292评论 0 0