The Go Memory Order
使用过Go语言或者听说过Go的都知道Go号称天生在并发编程上具有先天优势,其实会让人好奇他的先天优势来源哪里呢? 按照官方解释或者目前大多数人的认识来说呢应该是Go语言层面提供了一个并发执行体Goroutine,很多人把这个叫go协程,原因是因为它的创建执行消耗的计算资源非常轻量,和协程coroutine长得很像。但是在了解了Go的调度模型后,我个人感觉Goroutine的运行模式本质上更像是线程池的模型。当然这篇文章要写的主要是Go的内存模型,但是内存模型和Go的并发密切相关。因为讲到并发,很多人的第一反应就是并发安全问题,讲到并发安全就离不开数据同步,所以这篇文章的目的是根据Go官方提供的一篇文章 The Go Memory Order来分析一下Go的数据并发安全问题。
首先我们先说一下并发安全问题中的问题到底是什么问题? 这个问题的根源就是多线程问题,当有多个线程同时对同一块内存数据进行读写时,因为不能保证线程的执行顺序,所以可能导致其中有一部分线程读写的结果并不是编程者期望的样子。(线程同时执行是因为现代操作系统以及现代计算机硬件结构多核cpu能够实现真正的并行计算)。那么为了程序运行效率更高,我们期望能够并行计算,但是为了保证程序计算结果的正确性,我们需要保证数据在多个线程的操作下,数据一定是同步的。这个同步的意思是线程A对数据V的修改,对另一个线程B要读取时具有可见性(不同线程之间数据操作出现不可见的原因可能是因为编译器在编译期进行了指令重排、或者cpu执行时进行了指令重排,这一部分内容网上有很多文章介绍指令重排带来的影响)。下面我们来看看Go的内存模型说明的数据同步问题,数据在多个Goroutine中的可见性。
The Go Memory Order文章中主要讲述的是一个概念-Happens Before,Hanpens Before从字面上来理解就是一个说明顺序的问题,xxx在xxx之前发生。如果有这一层顺序关系,那么只要确定好读写线程(Go中是Goroutine)的读写Happens Before关系,那么就完成了数据的同步问题,能够使读取线程能够读到修改数据的写线程写入的数据了。下面来看其中几个详细的例子来说明Go中到底在什么地方保证了Happens Before关系,利用这些Happens Before关系,如何保证并发安全。
例1:初始化中的Hanppens Before Go程序的初始化是在一个单独的Goroutine中完成的,并且导入的包例如p中import了q 那么q的init方法Hanppens Before p的init方法执行。
例2:A Goroutine 中创建了B Goroutine,那么A Goroutine Hanppens Before B Goroutine. 所以被创建的Goroutine B 对于A Goroutine创建B 之前的数据修改是具有可见性的。
例3:通道chan的通信的Hanppens Before关系。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var c = make(chan int, 10) var a string func f() { a = "hello, world" c <- 0 } func main() { go f() <-c print(a) } |
对于一个有缓冲的通道c, c <- 0 对于通道的写入 Hanppens Before <-c 通道的接收的完成 就是说c <- 0 接收语句的执行顺序在( c<- 读取完成)之前。所以上述代码能够保证输出hello world 因为a的赋值 a = "hello world" 先与print读取时发生,print读取时a已经完成了赋值操作。
不带缓冲的通道的结果是不一样的,看看下面不到缓冲的通道的Hanppens Before关系。
1 2 3 4 5 6 7 |
var c = make(chan int) var a string func f() { a = "hello, world" <-c } |
1 2 3 4 5 |
func main() { go f() c <- 0 print(a) } |
对于Go语言来说上面的示例程序依然能够保证输出hello world,原因是对于无缓冲的通道,接收Hanppens Before通道的发送完成。
Go内存模型文章中还介绍了sync包中的一些其他方式保证内存序,例如lock,atomic等等,原子变量以及加锁应该都比较好理解。本文就不详细分析这部分内容了。
最后来看看一些错误的同步实例,下面的程序的运行结果并不会如同编码预期的那样运行。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
var a, b int func f() { a = 1 b = 2 } func g() { print(b) print(a) } func main() { go f() g() } |
上面这段代码没有使用任何同步操作,意味着a、b变量的值对于主线程是未可知的,go f()这个Goroutine对于a、b的修改,对于main Goroutine来说可能是不可见的(由于cpu缓存或者其他的原因)。所以程序不能保证输出a = 1 b = 2 。程序可能出的a = 0 b = 0 也可能是 b = 2 a = 0 (这个可能不好理解,这里的原因可能是因为指令重排导致的) a = 1 b = 2 生成的指令 可能是b = 2 在前 而 a = 1在后。
再看看下面这段代码,这段代码使用了double check的方式,然而double check是一个非常经典的容易出错的问题。这段代码依然不能正确的按照我们的期望执行,原因还是多线程并发可见性问题,和上面的程序类似,done不一定可见,并且有可能因为指令重排导致done = true 在后
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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() } |
同上再展示一段错误的同步案例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
var a string var done bool func setup() { a = "hello, world" done = true } func main() { go setup() for !done { } print(a) } |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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) } |
今天在youtube上看到一个视频讲解The Go Memory Model 感觉讲得非常细致,分享一下连接:The Go Memory Model: GoSF Meetup, 1/23/19