jasper的技术小窝

关注DevOps、运维监控、Python、Golang、开源、大数据、web开发、互联网

Golang内存模型

作者:jasper | 分类:Golang | 标签:   | 阅读 330 次 | 发布:2017-07-31 12:05 a.m.

根据官方文档,再加上自己的理解,一起来探讨一下Golang的内存模型,简言之,Golang的内存模型描述了"如何在一个goroutine中看到在另一个goroutine修改的变量的值"。具体的,我们慢慢来看。

在一个gouroutine中,读和写一定是按照程序中的顺序执行的。即编译器和处理器只有在不会改变这个goroutine的行为时才可能修改读和写的执行顺序,即: 指令乱序重排。重排代码以最优化CPU执行, 另外还因为有CPU缓存的存在,内存的数据不一定会及时更新,这样对内存中的同一个变量读和写也不一定和期望一样。

在Java中利用内存模型叫做JMM,Java的并发模型则基于多线程和共享内存,有较多的概念(violatie, lock, final, construct, thread, atomic等)和场景,java.util.concurrent并发工具包大大简化了Java并发编程。在Golang中并发模型基于CSP,不同的Goroutine通过一种叫Channel的数据结构来通信。

happens-before

假设a和b表示一个多线程的程序执行的两个操作。如果a happens-before b,那么a操作对内存的影响将对b的线程(且执行在b之前)可见。

例如:

package main
import (
    "fmt"
)
var a, b int
func main() {
    a = 1   // (1)
    b = a + 1  // (2)
    fmt.Println(a, b) // (3)
}

上面都在一个Goroutine里,对变量的读写和代码的书写顺序一致,即(1) happens-before (2)并且(2) happens-before (3),因为这个是可以传递的,所以(1) happens-before (3)。

但是在多个Goroutine里面就不一定能满足这个顺序了,例如:

package main
import (
    "fmt"
    "time"
)
var a, b int
func main() {
    a = 1 
    go func() {
        b = a + 2  // (1)
    }()
    fmt.Println(a, b)  // (2)
    time.Sleep(1 * time.Second)
}

结果可能是(1,0),(1,2),因为(1)中的操作对(2)不可见,即他们不满足happens-before的规则。

(1) happens-before (2)并不意味着(1)在(2)之前发生

把之前那个例子稍作调整:

package main
import (
    "fmt"
)
var a, b int
func main() {
    b = a + 1  // (1)
    a = 1   // (2)
    fmt.Println(a, b) // (3)
}

很明显,任然满足(1) happens-before (2),但是由于指令乱序重排,最后的执行顺序可能是:

  • 将a的值取到寄存器
  • 将a赋值为1
  • 将寄存器值加1后赋值给b

这样的乱序是不会影响结果的,这里违反了happens-before关系了吗?根据定义,操作(1)对内存的影响必须在操作(2)执行之前对其可见。也就是说,对b的赋值必须有机会对a的赋值有影响。但是在这个例子中,对b的赋值对b的赋值没有任何影响。即便(1)的影响真的可见,(2)的行为还是一样。所以,这并不能算是违背happens-before规则。

(1)在(2)之前发生并不意味着(1) happens-before (2)

例如:

package main
import (
    "fmt"
)
var a int


func f1(){
    a = 1 // (1)
}

func f2(){
    fmt.Println(a)  // (2)
}

假设有两个线程分别运行f1()和f2(),并且打印出了a的值为1,这我们可以知道(1)在(2)之前发生,但是我们能说(1) happens-before (2)么,当然不能,因为是多个线程之间的执行f1和f2是不定的。

同步

为了达到happens-before,就需要执行体之间有一定的同步,就像一个Goroutine里面顺序执行一样,在Golang中有一下既定的同步动作,例如:

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

如果包p导入了包q,包q的init 初始化函数将在包p的初始化之前执行。

程序的入口函数 main.main 则是在所有的 init 函数执行完成 之后启动。

在任意init函数中新创建的goroutines,将在所有的init 函数完成后执行。

具体就不举例说明了,在Golang中一般使用channel通信和加锁(这个和Java类似)来保证同步。

channel通信

用管道通信是两个goroutines之间同步的主要方法。通常的用法是不同的goroutines对同一个管道进行读写操作,一个goroutines写入到管道中,另一个goroutines从管道中读数据。

管道上的发送操作发生在管道的接收完成之前(happens before)。

例如:

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

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

func main() {
        go f();
        <-c;
        print(a);
}

可以确保会输出"hello, world"。因为,a的赋值发生在向管道 c发送数据之前,而管道的发送操作在管道接收完成之前发生。因此,在print 的时候,a已经被赋值。

从一个无缓冲管道接收数据在向管道发送数据完成之前发送。

下面的是示例程序:

var c = make(chan int)
var a string

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

同样可以确保输出“hello, world”。因为,a的赋值在从管道接收数据前发生,而从管道接收数据操作在向无缓冲管道发送完成之前发生。所以,在print 的时候,a已经被赋值。

如果用的是缓冲管道(如 c = make(chan int, 1) ),将不能保证输出 “hello, world”结果:

例如:

var c = make(chan int, 1)
var a string
func f() {
    a = "hello, world" // (1)
    <-c  // (2)
}
func main() {
    go f()
    c <- 0  // (3)
    print(a)  // (4)
}

因为这里不再有任何同步保证,使得(2) happens-before (3),所以最终保证不了(1) happens-before (4)。

加锁

sync 包实现了两种锁数据结构:

  • sync.Mutex -> java.util.concurrent.ReentrantLock
  • sync.RWMutex -> java.util.concurrent.locks.ReadWriteLock

其happens-before规则和Java的也类似:

任何sync.Mutex或sync.RWMutex 变量(l),定义 n < m, 第n次 l.Unlock() happens-before 第m次l.lock()调用返回。

var l sync.Mutex
var a string
func f() {
    a = "hello, world" // (1)
    l.Unlock() // (2)
}
func main() {
    l.Lock() // (3)
    go f()
    l.Lock() // (4)
    print(a) // (5)
}

(1) happens-before (2) happens-before (4) happens-before (5)

Once

sync 包中提供了一个安全机制用来实现多个 goroutine 只有一次的初始化,就是使用 Once 类型。多个线程都可以执行 once.Do(f),但是只有一个可以运行 f(),而其他的调用将被阻塞掉,直到 f() 返回。

只有一个调用(的返回)在所有的 once.Do(f) 返回之前发生。

例如:

var 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" 的输出,而第一次对 doprint的调用运行了一次setup,第二次对doprint的调用不会运行setup,但是阻塞知道第一次里面的setup运行结束。

总结

总结下来有以下的基本观点:

  • 多个goroutine的共享变量必须满足happens-before条件才能保证在多goroutine下看到一致内存.
  • 要使共享变量满足happens-before条件, 必须使用同步, 同步的本质是"提供了可靠且可信赖的已实现的happens-before条件", 必须利用这些内建的happens-before条件来实现自己的happens-before条件.
  • 内建的happens-before条件有:
    • 缓冲管道: 带缓冲的管道上的发送操作发生在管道的接收完成之前
  • 无缓冲管道: 无缓冲的管道上的接受操作发生在发送操作完成之前
  • Mutex: 下一次的锁定必须发生在上一次解锁操作完成之后
  • 不要依赖共享内存, 应该使用channel来显式通信.

Golang语言是更自然的“实现”语言,但不是更自然的“编程”语言,所以写goroutine,应该会多出来一些用来同步的代码,这就是Golang的代码写出来都是那个鸟样的原因吧。


转载请注明出处http://www.opscoder.info/golang_mem_model.html

其他分类: