第4课:Go语言内存模型

多线程/并发程序共享数据既是一件幸事,却又是一件麻烦的事。对于共享数据的“读”是没有问题的, 问题就出现在“写”上,比如两个线程写同一个内存中的值该如何是好 ? 高级语言的写一个内存变量比如 a = a+1; 往往不会是一个原子操作(不可分割),也就是该操作会拆分成多步(典型的就是从内存 load 到寄存器、 在寄存器中 +1 、 从寄存器 store 到内存), 试着想想如果两个线程的写的多步交叉起来, 结果会如何 ? 比如 线程 f 和 g 分别将 a = a+1; 按理说, f 和 g 结束之后 a 会增大 2 , 不过可能不会这样。这就是多线程的麻烦的地方之一。 多线程麻烦的地方之二, 试想着两个线程写、一个线程读同一个 a, 那么读的结果该是最初的结果,还是 +1 的结果,还是 +2 的结果? 

对于上面的第一个问题的思路就是加锁 lock (或是互斥量 mutex)来保证一致性,保证写的操作是“不可分割”的。对于第二个问题,也就是线程之间执行顺序的控制,一般使用信号量/条件变量等限制它们的先后执行。多线程的同步问题也就是访问的原子性和顺序性问题,简而言之,一般多线程对于共享不加处理的话, 数据的值完全是听天由命,但是共享就是共享, 一个线程在 t 时刻将值 v 写进去了, 后面的所有线程的操作都是在 v 的基础之上进行的

Go有些特殊。Go的多线程,或是说goroutine,虽然数据是共享的,然而如果不加同步进制的话,这种共享却是互相之间可能看不到的,举个例子说,共享的 a (int) 变量,go f(); go g(); 如果 main 中创建 f 和 g 线程,都把 a 的值 +1,但是没有任何同步机制,这时候,在 main 中可能看到这种变化 (+1 或是 +2),也可能看不到这种变化 (+0),甚至实现可以是:f 和 g 都没有创建,因为创建它们没有实在的意义,没有同步就可以认为其他线程不关心这种变化

goroutine 的销毁
一个 goroutine 的退出不保证在程序中任何事件的之前发生。比如下面这个程序:

    var a string
     
    func hello() {
            go func() { a = "Godeye" }()
            print(a)
    }

对 a 的赋值后面没有跟任何的同步事件,所以不保证在其他的 goroutine 中能看到 a 的变化。实际上,一个疯狂的编译器可以删掉整个 go 语句。[ 介也不违反前面的规定。]
如果一个 goroutine 的执行效果想其他的 goroutine 能察觉到,那么请使用同步机制,比如锁或是管道通信,来建立一个相对顺序。
管道通信
管道通信是 goroutine 中进行同步的主要方法。每一个管道的一个发送对应一个对管道的接收,不过通常发送和接收操作是在不同的 goroutine 中。
对于管道的发送操作在对应的接收操作完成的之前发生。
程序:

    var c = make(chan int, 10)
    var a string
     
    func f() {
            a = "www.godeye.org"
            c <- 0
    }
     
    func main() {
            go f()
            <-c
            print(a)
    }

结果是肯定会输出 "hello,world" 的,因为对 a 的写操作发生在对管道 c 的发送数据之前,对管道 c 的发送又发生在对 c 的接收之前,更在 print a 之前。
对于一个管道的关闭操作发生在管道获取 0 值 之前;管道关闭之后,管道接收到一个 0 值。
对于上面的例子,使用 close(c) 更换掉 c <- 0 ,程序的结果是一样的。
对于一个无缓冲的管道来说,接收操作在发送操作完成的之前发生。

并发的 goroutine 之间默认是互不相识的, 甚至可以认为对方不存在
并发的代码段之间,对于顺序的体会可能不一样,也就是Go在语言层次允许代码顺序调整;(上面说了,代码段之间的关系有"之前"、 "之后" 和 "并发"三种,不要想当然的不是 "之前" 就是 "之后" )