关于并发:go语言happensbefore原则及应用

46次阅读

共计 6399 个字符,预计需要花费 16 分钟才能阅读完成。

理解 go 中 happens-before 规定,寻找并发程序不确定性中的确定性。

引言

先抛开你所熟知的信号量、锁、同步原语等技术,思考这个问题:如何保障并发读写的准确性?一个没有任何并发编程教训的程序员可能会感觉很简略:这有什么问题呢,同时读写能有什么问题,最多就是读到过期的数据而已。一个现实的世界当然是这样,只惋惜实际上的机器世界往往暗藏了很多不容易被觉察的事件。至多有两个行为会影响这个论断:

  • 编译器往往有指令重排序的优化;例如程序员看到的源代码是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,它们之间的关系也如同>< 一样:

如果 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;简略了解就是没有其它写入笼罩这次写入;

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

一个新的问题随之诞生:那如何判断 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 关系,这时候后果是不确定的,一种乏味的后果是 2、0,也就是 (1)、(2) 之间产生了指令重排序。当初让咱们批改一下下面的代码,让它按咱们预期的逻辑运行:要么打印 0、0,要么打印 1、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”和“1、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 关系,那么最好应用同步工具明确它们之间的关系,例如锁或者通道。不要给程序留下不确定的可能,毕竟确定性就是编程的魅力!

正文完
 0