go语言happens-before原则及应用

了解go中happens-before规则,寻找并发程序不肯定性中的肯定性。程序员

引言

先抛开你所熟知的信号量、锁、同步原语等技术,思考这个问题:如何保证并发读写的准确性?一个没有任何并发编程经验的程序员可能会以为很简单:这有什么问题呢,同时读写能有什么问题,最多就是读到过时的数据而已。一个理想的世界固然是这样,只惋惜实际上的机器世界每每隐藏了不少不容易被察觉的事情。至少有两个行为会影响这个结论:golang

  • 编译器每每有指令重排序的优化;例如程序员看到的源代码是a=3; b=4;,而实际上执行的顺序多是b=4; a=3;,这是由于编译器为了优化执行效率可能对指令进行重排序;
  • 高级编程语言所支持的运算每每不是原子化的;例如a += 3实际上包含了读变量、加运算和写变量三次原子操做。既然整个过程并非原子化的,就意味着随时有其它“入侵者”侵入修改数据。更为隐藏的例子:对于变量的读写甚至可能都不是原子化的。不一样机器读写变量的过程多是不一样的,有些机器多是64位数据一次性读写,而有些机器是32位数据一次读写。这就意味着一个64位的数据在后者的读写上其实是分红两次完成的!试想,若是你试图读取一个64位数据的值,先读取了低32的数据,这时另外一个线程切进来修改了整个数据的值,最后你再读取高32的值,将高32和低32的数据拼成完整的值,很明显会获得一个预期之外的数据。

看起来,整个并发编程的世界里一切都是不肯定的,咱们不知道每次读取的变量究竟是不是及时、准确的数据。幸运的是,不少语言都有一个happens-before的规则,能帮助咱们在不肯定的并发世界里寻找一丝肯定性。编程

happens-before

你能够把happens-before看做一种特殊的比较运算,就好像><同样。对应的,还有happens-after,它们之间的关系也好像><同样:segmentfault

若是a happens-before b,那么b happens-after a

那是否存在既不知足a happens-before b,也不知足b happens-before a的状况呢,就好像既不知足a>b,也不知足b>a(意味着b==a)?固然是确定的,这种状况称为:a和b happen concurrently,也就是同时发生,这就回到咱们以前所熟知的世界里了。数据结构

happens-before有什么用呢?它能够用来帮助咱们厘清两个并发读写之间的关系。对于并发读写问题,咱们最关心的常常是reader是否能准确观察到writer写入的值。happens-before正是为这个问题设计的,具体来讲,要想让某次读取r准确观察到某次写入w,只需知足:并发

  1. w happens-before r;
  2. 对变量的其它写入w1,要么w1 happens-before w,要么r happens-before w1;简单理解就是没有其它写入覆盖此次写入;

只要知足这两个条件,那咱们就能够自信地确定咱们必定能读取到正确的值。app

一个新的问题随之诞生:那如何判断a happens-before b是否成立呢?你能够类比思考数学里如何判断a > b是否成立的过程,咱们的作法很简单:编程语言

  1. 基于一些简单的公理;例如天然数的天然大小:3>2>1
  2. 基于比较运算符的传递性,也就是若是a>b且b>c,则a>c

判断a happens-before b的过程也是相似的:根据一些简单的明确的happens-before关系,再结合happens-before的传递性,推导出咱们所关心的w和r之间的happens-before关系。函数

happens-before传递性:若是a happens-before b,且b happens-before c,则a happens-before c

所以咱们只须要了解这些明确的happens-before关系,就能在并发世界里寻找到宝贵的肯定性了。工具

go语言中的happens-before关系

具体的happens-before关系是因语言而异的,这里只介绍go语言相关的规则,感兴趣能够直接阅读官方文档,有更完整、准确的说明。

天然执行

首先,最简单也是最直观的happens-before规则:

同一个goroutine里,书写在前的代码 happens-before书写在后的代码。

例如:

a = 3; // (1)
b = 4; // (2)

则(1) happens-before (2)。咱们上面提到指令重排序,也就是实际执行的顺序与书写的顺序可能不一致,但happens-before与指令重排序并不矛盾,即便可能发生指令重排序,咱们依然能够说(1) happens-before (2)。

初始化

每一个go文件均可以有一个init方法,用于执行某些初始化逻辑。当咱们开始执行某个main方法时,go会先在一个goroutine里作初始化工做,也就是执行全部go文件的init方法,这个过程当中go可能建立多个goroutine并发地执行,所以一般状况下各个init方法是没有happens-before关系的。关于init方法有两条happens-before规则:

1.a 包导入了 b包,此时b包的 init方法 happens-before a包的全部代码;
2.全部 init方法 happens-before main方法;

goroutine

goroutine相关的规则主要是其建立和销毁的:

1.goroutine的建立 happens-before 其执行;
2.goroutine的完成 不保证 happens-before任何代码;

第一条规则举个简单的例子便可:

var a string

func f() {
    fmt.Println(a) // (1)
}

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

由于goroutine的建立 happens-before 其执行,因此(3) happens-before (1),又由于天然执行的规则(2) happens-before (3),根据传递性,因此(2) happens-before (1),这样保证了咱们每次打印出来的都是"hello world"而不是空字符串。

第二条规则是少见的否认句式,一样举个简单的例子:

var a string

func hello() {
    go func() { a = "hello" }() // (1)
    fmt.Println(a) // (2)
}

因为goroutine的完成不保证happens-before任何代码,所以(1) happens-before (2)不成立,这样咱们就不能保证每次打印的结果都是"hello"。

通道

通道channel是go语言中用于goroutine之间通讯的主要渠道,所以理解通道之间的happens-before规则也相当重要。

1.对于缓冲通道,向通道发送数据 happens-before从通道接收到数据

结合一个例子:

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

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

func main() {
    go f() // (3)
    <-c // (4)
    fmt.Println(a) // (5)
}

c是一个缓冲通道,所以向通道发送数据happens-before从通道接收到数据,也就是(2) happens-before (4),再结合天然执行规则以及传递性不难推导出(1) happens-before (5),也就是打印的结果保证是"hello world"。
有趣的是,若是咱们把c的定义改成var c = make(chan int)也就是无缓冲通道,上面的结论就不存在了(注1),打印的结果不必定为"hello world",这是由于:

2.对于无缓冲通道,从通道接收数据 happens-before向通道发送数据

咱们能够将上述例子稍微调整下:

var c = make(chan int)
var a string

func f() {
    a = "hello, world" // (1)
    <- c // (2)
}

func main() {
    go f() // (3)
    c <- 10 // (4)
    fmt.Println(a) // (5)
}

对于无缓冲通道,(2) happens-before (4),再根据传递性,(1) happens-before (5),所以依然能够保证打印的结果是"hello world"。

能够这么理解这二者的差别,缓冲通道的目的是缓冲发送方发送的数据,这就意味着发送方极可能先发送数据,过一段时间后接收方才接收,或者发送方发送的速度超过接收方接收的速度,由于缓冲通道的发送happens-before接收就天然而然了;相反,非缓冲通道是没有缓冲区的,先发起的发送方和接收方都会阻塞至另外一方准备好,若是咱们使用了非缓冲通道,则意味着咱们认为咱们的场景下接收发生在发送以前,不然咱们就会使用缓冲通道了,所以非缓冲通道的接收happens-before发送。

3.对于缓冲通道,第k次接收 happens-beforek+C次发送, C是缓冲通道的容量

这条规则是缓冲通道的通用规则(有趣的是,上面针对非缓冲通道的第2条规则也能够当作这个规则的特例:C取0)。这个规则看起来复杂,咱们看个例子就清晰了:

var limit = make(chan int, 3)

func main() {
    // work是一个worker列表,其中的元素w都是可执行函数
    for _, w := range work {
        go func(w func()) {
            limit <- 1 // (1)
            w() // (2)
            <-limit // (3)
        }(w)
    }
    select{}
}

咱们先套用一下上面的规则,则:“第1次(3)happens-before第4次(1)”、“第2次(3)happens-before第5次(1)”、“第3次(3)happens-before第6次(1)”……,再结合传递性:“第1次(2)happens-before第1次(3)happens-before第4次(1)happens-before第4次(2)”、“第2次(2)happens-before第2次(3)happens-before第5次(1)happens-before第5次(2)”……,简单地说:“第1次(2)happens-before第4次(2)”、“第2次(2)happens-before第5次(2)”、“第3次(2)happens-before第6次(2)”……这样咱们虽然没有作任何分批,却事实上将workers分红三个一批、每批并发地执行。这就是经过这条happens-before规则保证的。

这个规则理解起来其实也很简单,C是通道的容量,若是没法保证第k次接收happens-beforek+C次发送,那通道的缓冲就不够用了。

注1:以上是官方文档给的规则和例子,可是笔者在尝试将第一个例子的 c改为无缓冲通道后发现每次打印的依然稳定是"hello world",并无出现预期的空字符串,也就是看起来 happens-before规则依然成立。但既然官方文档说没法保证,那咱们开发时仍是按照 happens-before不成立比较好。

锁也是并发编程里很是经常使用的一个数据结构。go语言中支持的锁主要有两种:sync.Mutexsync.RWMutex,即普通锁和读写锁(读写锁的原理能够参见另外一篇文章)。普通锁的happens-before规则也很直观:

1.对锁实例调用 nUnlock happens-before 调用 Lock m次,只要 n < m

请看这个例子:

var l sync.Mutex
var a string

func f() {
    a = "hello, world" // (1)
    l.Unlock() // (2)
}

func main() {
    l.Lock() // (3)
    go f() // (4)
    l.Lock() // (5)
    print(a) // (6)
}

上面调用了Unlock一次,Lock两次,所以(2) happens-before (5),从而(1) happens-before (6)

而读写锁的规则为:

2.对读写锁实例的某一次 Unlock调用, happens-afterRLock调用对应的 RUnlock调用 happens-before下一次 Lock调用。

其实本质就是读写锁的原理:读写互斥,简单地理解就是写锁释放后先获取了读锁,则读锁的释放会happens-before 下一次写锁的获取。注意上面的规则是“存在”,而不是“任意”。

Once

sync中还提供了一个Once的数据结构,用于控制并发编程中只执行一次的逻辑,例如:

var a string
var once sync.Once

func setup() {
   a = "hello, world"
   fmt.Println("set up")
}

func doprint() {
   once.Do(setup)
   fmt.Println(a)
}

func twoprint() {
    go doprint()
    go doprint()
}

会打印"hello, world"两次和"set up"一次。Oncehappens-before规则也很直观:

第一次执行 Once.Do happens-before其他的 Once.Do

应用

掌握了上述的基本happens-before规则,能够结合起来分析更复杂的场景了,来看这个例子:

var a, b int

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

func g() {
    print(b) // (3)
    print(a) // (4)
}

func main() {
    go f()
    g()
}

这里(1) happens-before (2),(3) happens-before(4),可是(1)与(3)、(4)之间以及(2)与(3)、(4)之间并无happens-before关系,这时候结果是不肯定的,一种有趣的结果是二、0,也就是(1)、(2)之间发生了指令重排序。如今让咱们修改一下上面的代码,让它按咱们预期的逻辑运行:要么打印0、0,要么打印一、2。

使用锁

var a, b int
var lock sync.Mutex

func f() {
    lock.Lock() // (1)
    a = 1 // (2)
    b = 2 // (3)
    lock.Unlock() // (4)
}

func g() {
    lock.Lock() // (5)
    print(b) // (6)
    print(a) // (7)
    lock.Unlock() // (8)
}

func main() {
    go f()
    g()
}

回想下锁的规则:

1.对锁实例调用 nUnlock happens-before 调用 Lock m次,只要 n < m

这里存在两种可能:要么(4) happens-before (5),要么(8) happens-before (1),会分别推导出两种结果:(6) happens-before (7) happens-before (2) happens-before (3) ,以及(2) happens-before (3) happens-before (6) happens-before (7),也就分别对应“0、0”和“一、2”两种结果。

使用通道

var a, b int
var c = make(chan int, 1)

func f() {
   <- c
   a = 1 // (2)
   b = 2 // (3)
   c <- 1
}

func g() {
   <- c
   print(b) // (6)
   print(a) // (7)
   c <- 1
}

func test() {
   wg := sync.WaitGroup{}
   wg.Add(3)
   go func(){
      defer wg.Done()
      f()
   }()
   go func(){
      defer wg.Done()
      g()
   }()
   go func(){
      defer wg.Done()
      c <- 1
   }()
   wg.Wait()
   close(c)
}

总之,若是没法肯定并发读写之间的happens-before关系,那么最好使用同步工具明确它们之间的关系,例如锁或者通道。不要给程序留下不肯定的可能,毕竟肯定性就是编程的魅力!

相关文章
相关标签/搜索