18

The Go Memory Model翻译及理解

 3 years ago
source link: https://studygolang.com/articles/31612
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

前言

最近看mit6.824,里面第五节课有推荐看The Go Memory Model这篇文章

网上翻译也有一些,但是还是觉得不合自己口味,而且本身有些地方写的很拗口,不适合人读,命名简单的描述却复杂化了。所以我用自己的理解替换了一下原文的语句。

运行这篇文章的代码的时候记得在main函数加time.Sleep(time.Second),时间由你决定

goroutine 协程

介绍

Go内存模型指定了一种条件,在这种条件下,可以保证在一个goroutine中读取变量可以得到在不同goroutine中写入同一变量所产生的值。

建议

如果程序想要修改由多个goroutine同时访问的数据,必须将这种访问串行化。

要串行化访问,请使用通道操作或其他同步原语(例如sync和sync / atomic包中的原语)保护数据

先行发生 Happens Before

在单个goroutine中,读取和写入的行为必须像它们按照程序指定的顺序执行。也就是说,仅当重新排序不会改变语言规范所定义的该goroutine中的行为时,编译器和处理器才可以对单个goroutine中执行的读取和写入进行重新排序。由于此重新排序,一个goroutine观察到的执行顺序可能与另一个goroutine察觉到的执行顺序不同。例如,如果一个goroutine执行a = 1; b = 2;另一个协程可能会先看到b更新后的值,再看到a更新后的值,(b在a的前面)。

为了指定读取和写入的要求,我们在Go程序中定义了执行内存操作之前的部分顺序。如果事件e1发生在事件e2之前,那么我们说e2发生在e1之后。同样,如果e1不在e2之前发生并且在e2之后也没有发生,那么我们说e1和e2同时发生。

在单个goroutine中,先行发生的顺序是程序表示的顺序。

如果对于一个变量 v 的读操作 r 和写操作 w 满足以下两个条件,r 就能观察到 w:

  1. r 没有发生在 w 之前
  2. w 之后 r 之前没有其他的写操作 w'

为了保证变量 v 的一个读操作 r 能够观察到一个特定的写操作 w,需要保证 w 是 r 被允许观察(observe)的唯一的写操作。那么,如果 r、w 都满足以下条件,r 就能确保观察到 w:

  1. w 发生在 r 之前 w happens before r.
  2. 对共享变量v的任何其他写操作都发生在w之前或r之后

这一组条件要比前一组更健壮,因为它要求没有其他任何的写操作与 w 和 r 同时发生。

在单个goroutine中,没有并发性,因此这两个定义是等效的:读操作 r 观察最近写操作w写入的变量 v 的值。当多个goroutine访问共享变量 v 时,它们必须通过同步事件来构建“先行发生”条件,确保读操作能观察到预期的写操作。

用v的类型的零值初始化变量v的行为在内存模型中表现为写操作。

大于单个机器字的值的读取和写入,将表现为多个机器字长大小的不具备特定顺序的读写操作。

同步

初始化

程序初始化在单个goroutine中运行,但是该goroutine可能会创建其他同时运行的goroutine。

如果包p导入了包q,则q的init函数的完成会先发生在q的任意一个init函数开始之前。

函数main.main的启动发生在所有init函数完成之后。

协程创建

启动新goroutine的go语句发生在该goroutine的执行开始之前。

例如,在此程序中:

func f() {
    print(a)
}

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

调用hello方法将会在未来某个时间点(可能是hello返回之后)打印"hello, world",也就是说执行f()的是一个go协程,和本hello中的语句不是顺序执行了。

协程销毁

不能保证goroutine的退出会先发生在程序中任何事件之前。

例如,在此程序中:

var a string

func hello() {
    go func() { a = "hello" }()
    print(a)
}

变量 a 的赋值语句后面没有任何同步事件,因此不能保证任何其他goroutine都会观察到它。实际上,一个激进(aggressive)的编译器可能会删除整个go语句。

如果必须通过另一个goroutine来观察一个goroutine的影响,请使用同步机制(例如锁定或通道通信)来建立相对顺序

信道通信 Channel communication

channel通信是goroutine之间同步的主要方法。通常在不同的goroutine中,将特定channel上的每个发送与该channel上的相应接收进行匹配。一进一出

有缓冲的channel

缓冲Channel有点像生产者消费者,信道上先有东西,才能接收,不然接收语句就一直阻塞,发送者的阻塞只有队列满的时候才会发生,我们可以通过这种方实现同步。

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

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

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

上面这个程序保证了打印"hello,world"。对a的写操作发生在发送c( c <- 0 )之前,main中的语句 <-c 会阻塞直到c中有数据,c中被写入0之后放行,执行print(a)语句。

在前面的示例中,用close(c)替换c <-0具有同样的效果。

close(c)关闭信道,<-c 这个语句会接收到0值。

原文的意思就是close操作发生在接收操作之前。符合我们的直觉,但是像句废话

无缓冲的channel

无缓冲的channel的有点不一样如果没有goroutine 读取接收者<-ch ,那么发送者ch<- 就会一直阻塞

var c = make(chan int)
var a string

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

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

上面的程序(和前面的例程相似,但是交换了send和receive语句并使用了未缓冲的channel)

也能确保打印出“hello, world”。一开始c<-0会阻塞,直到有接收者接收信道 <-c

如果信道有缓冲区(比如:c = make(chan int, 1) ),那么这个程序就不能保证可以打印出“hello, world“(可能打印空字符串、程序崩溃或者做其他事)。

模拟限制并发

先回顾原文

The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes.

我们有一个缓冲区大小为 C 的channel,对这个channel的第k个接收操作,会先发生在k+C发生操作完成之前。假设容量为10,第1个接收操作先发生在第11个写操作之前,第2个接收操作发生在第12个写操作之前。

感觉又像是一句废话,满了就写不了了!下面才的是重点

这一条同时也概括了前面有缓冲区信道的规则。这样就可以用一个有缓冲信道来模拟可计数信号量:信道中元素的个数相当于当前活跃用户,信道容量相当于最大并发用户数,发送元素给信道相当于请求一个信号量,而接收就相当于释放。这是一种限制并发的惯用手段。

以下程序对于 work 列表的每一个循环都会开启一个 Go 协程,但由于使用了 limit 信道进行调度,能够在同一时间最多只有三个 work 函数在运行。

var limit = make(chan int, 3)

func main() {
    for _, w := range work {
        go func(w func()) {
            limit <- 1
            w()
            <-limit
        }(w)
    }
    select{}
}

sync 包实现了两种锁类型,sync.Mutex 和 sync.RWMutex。

Mutex:互斥锁

RWMutex:读写锁,RWMutex 基于 Mutex 实现

Lock() 加锁,Unlock() 解锁,在RWMutex中是加写锁和解写锁

RLock() 加读锁,RUnlock() 解读锁

sync.Mutex 和 sync.RWMutex具体可以参考: https://www.jianshu.com/p/679041bdaa39

特性还是蛮多的,看了对原文的解读会更深刻。因为原文写的很拗口。

For any sync.Mutex or sync.RWMutex variable l and n 

		

对于任意的sync.Mutex 或 sync.RWMutex 变量 l 并且 n < m,对第 n 个 l.Unlock() 的的调用发生在第 m 个 l.Lock() 的返回之前。也就是说l的第一次解锁比第二次加锁要先执行。也就是说,使用 Lock() 加锁后,不能再继续对其加锁,直到利用 Unlock() 解锁后才能再加锁。

例子:

var l sync.Mutex
var a string

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

func main() {
    l.Lock()
    go f()
    l.Lock()
    print(a)
}

上面的程序能够确保打印出“hello, world”。我们先调用了l.Lock(),加了锁,第二次调用l.Lock()加第二把锁会阻塞,直到某个goroutine释放了这个锁。

For any call to l.RLock on a sync.RWMutex variable l, there is an n such that the l.RLock happens (returns) after call n to l.Unlock and the matching l.RUnlock happens before call n+1 to l.Lock.

一个 sync.RWMutex 变量 l,对于任何 l.RLock 的调用,存在一个 n,使得第 n 个 l.RLock 在第 n 个的 l.Unlock之后发生,同时该 RLock 所对应的 l.RUnlock 比于第 n+1 个 l.Lock先发生。

简单理解就是我们给加锁和解锁分别定义一个序号,假设一个极端情况是一读一写,加了写锁之后,读锁得等这个写锁释放,读锁释放完,下一个写锁才能加。

Once

sync 包通过使用 Once 类型,为多个goroutines进行初始化提供了一种安全的机制。对于一个特定的函数 f,可以用多个线程来调用 once.Do(f),但只有一个线程会真正运行 f(),其他的调用则会阻塞直到 f() 返回。

sync.Once 是 Golang package 中使方法只执行一次的对象实现,作用与 init 函数类似。但也有所不同。

init 函数是在文件包首次被加载的时候执行,且只执行一次

sync.Onc 是在代码运行中需要的时候执行,且只执行一次

当一个函数不希望程序在一开始的时候就被执行的时候,我们可以使用 sync.Once 。

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 只会实际调用一次 setup。setup 函数会在任意一个 print 调用之前完成。这个once.Do(setup)只会在某个协程中执行一次。结果就是“hello, world”会被打印两次。

我这里有时候啥都不打印,有时候只打印一次,没有出现打印两次的情况

错误的同步

注意到一个读操作 r 可能会观察到与 r 同时发生的写操作 w 写入的值。即使发生这种情况,也不意味着 在 r 之后的读操作能观察到 w 之前的写操作

var a, b int

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

func g() {
    print(b)
    print(a)
}

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

g可能会先打印2,然后打印0。

很神奇,但是我就打印了两个00,很难复现。

这个事实会让一些常见的用法失效。

双重检查加锁是一种尝试在同步中减少开销的方法。举个例子,twoprint 函数也可能会被错误地编写成下面的样子:

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()
}

但这并不能保证在 doprint 中,我们观察到 done 被修改意味着观察到 a 被修改。这个版本可能会不正确地打印出一个空字符串而不是“hello, world“。

其实也就是说goroutine中赋值的顺序是不一定的,(只要两个值顺序没影响)。

另外一种不正确的习惯用法是忙式等待(busy waiting)一个值,比如

var a string
var done bool

func setup() {
    a = "hello, world"
    done = true
}

func main() {
    go setup()
    for !done {
    }
    print(a)
}

就像前一个例子一样,不能保证在 main 中,观察到 done 被修改意味着观察到 a 被修改,所以这个程序也可能会打印出空字符串。更糟糕的是,由于两个线程间没有同步事件,因此不能保证 done 的修改会被 main 观察到。main 中的循环也不一定能够结束。

这个事情还能整一些比较微妙的变体,比如下面这个程序:

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)
}

即便 main 观察到 g != nil 然后结束了它的循环,也不能保证它能获得 g.msg 初始化得到的值。

对于所有这些例子,解决方法都是相同的:使用显式的同步方法。

有疑问加站长微信联系(非本文作者)

eUjI7rn.png!mobile

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK