Golang的调度模型
Posted inet_ygssoftware
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Golang的调度模型相关的知识,希望对你有一定的参考价值。
Golang中的内存模型
The Go memory model specifies the conditions under which reads of a variable in one goroutine can be guaranteed to observe values produced by writes to the same variable in a different goroutine.
- 翻译过来就是说,Go内存模型通过定义以下的条件,来保证在一个goroutine可以观察到另外的goroutine对这个相同变量的写操作。
Happens Before
在一个goroutine中,读和写的顺序一定是安装程序中的顺序执行的。编译器和处理器只有在不会在改变这个goroutine的行为的时候,才可能修改读和写的执行顺序。
但是由于重排,不同的gouroutine会看到不同的执行顺序。
例如,一个goroutine执行a = 1;b = 2;,另一个goroutine可能看到b在a之前更新。
为了说明读和写的必要性,go引入了Happens Before原语,来对内存模型中指定的条件进行描述。Happens Before翻译过来就是先行发生的意思。
Happens Before的定义:
- 如果事件e1发生在事件e2之前,那么我们说e2发生在事件e1之后。同样,如果e1不发生在e2之前,也不发生在e2之后,那么我们说e1和e2同时发生。
内存模型指定了哪些条件
go的Happens Before定义了2组条件:
第一组条件:
当上面2个条件都满足的时候,对变量v的读操作r是允许对v的写入操作w进行监测的
- r不先行发生于w
- 在w后r前没有对v的其他写操作
第二组条件:
为了确保对变量v的读取操作r能够监测到特定的对v的写操作w, 需要确保w是r允许看到的唯一写操作。即当下面条件满足时,则r能保证监测到w
- w先行发生于r
- 对共享变量v的其它任何写入操作都只能发生在w之前或r之后
在单个goroutine中,这2组条件的定义是一样的。但是,如果在多goroutine的环境下,第二组条件要求会更严格,因为它需要确保没有其他的写入操作与w或者r同时发生
我们看第一组条件,为什么说它没有第二组条件这么严格,r不先行发生于w,并不意味着,r就在w之后,因为它们可以是同时发生。
因此,需要特别注意的是,第一组条件的说法,在2个并发的goroutine来说,一个goroutine能否读到另一个goroutine中写入的数据是不确定的,可能可读到,可能读不到。
第二组条件,由于r发生在w之后,对共享变量v的其它任何写入操作都只能发生在w之前或r之后,意思就是说在r到w这一段期间之间,没有其他的写操作w’,也没有和r并行的写操作w’'发生,所以我们可以说,r读到的值必然是w写入的值。
下面这个图是从go编译语言网站扣过来的:
单Go程的情形:
-- w0 ---- r1 -- w1 ---- w2 ---- r2 ---- r3 ------>
这里不仅是个偏序关系,还是一个良序关系:所有 r/w 的先后顺序都是可比较的。
双Go程的情形:
-- w0 -- r1 -- r2 ---- w3 ---- w4 ---- r5 -------->
-- w1 ----- w2 -- r3 ---- r4 ---- w5 -------->
单Go程上的事件都有先后顺序;而对于两条Go程,情况又有所不同。即便在时间上 r1 先于 w2 发生,
但由于每条Go程的执行时长都像皮筋一样伸缩不定,因此二者在逻辑上并无先后次序。换言之,即二者并发。
对于并发的 r/w,r3 读取的结果可能是前面的 w2,也可能是上面的 w3,甚至 w4 的值;
而 r5 读取的结果,可能是 w4 的值,也能是 w1、w2、w5 的值,但不可能是 w3 的值。
双Go程交叉同步的情形:
-- r0 -- r1 ---|------ r2 ------------|-- w5 ------>
-- w1 --- w2 --|-- r3 ---- r4 -- w4 --|------->
现在上面添加了两个同步点,即 | 处。这样的话,r3 就是后于 r1 ,先于 w5 发生的。
r2 之前的写入为 w2,但与其并发的有 w4,因此 r2 的值是不确定的:可以是 w2,也可以是 w4。
而 r4 之前的写入的是 w2,与它并发的并没有写入,因此 r4 读取的值为 w2。
个人理解:
通过上面的解释,我们知道,在多个goroutine的情形下,如果我们不添加同步控制,那么,所有的goroutine的运行都是"平行"并发的,类似于在2个维度运行,对于在2个纬度里面的读或者写操作,我们没办法判定谁先谁后,所以必须要引入同步控制。引入同步控制之后,在2个goroutine里面,才有了一个"相同的节点",在这个"相同的节点"的两边,也就有了执行的先后顺序。不过两个"节点"之间的部分,同样还是可以自由伸缩,没有先后顺序的。
golang中有哪些同步的机制呢?
1. 初始化
Program initialization runs in a single goroutine, but that goroutine may create other goroutines, which run concurrently.
If a package p imports package q, the completion of q’s init functions happens before the start of any of p’s.
The start of the function main.main happens after all init functions have finished.
- 程序的初始化运行在单个goroutine中,但该goroutine可能会创建其它并发运行的goroutine
- 若包p导入了包q,则q的 init 函数会在 p 的任何函数启动前完成。
- 函数 main.main 会在所有的 init 函数结束后启动。
2. goroutine的创建
The go statement that starts a new goroutine happens before the goroutine’s execution begins.
- goroutine的创建happens before 这个goroutine的执行
3. goroutine的销毁
The exit of a goroutine is not guaranteed to happen before any event in the program.
-
Go程无法确保在程序中的任何事件发生之前退出。
var a string
func hello() {
go func() { a = “hello” }()
print(a)
}
对 a 进行赋值后并没有任何同步事件,因此它无法保证被其它任何Go程检测到。 实际上,一个积极的编译器可能会删除整条 go 语句。
若一个Go程的作用必须被另一个Go程监测到,需使用锁或信道通信之类的同步机制来建立顺序关系。
4. channel
Channel communication is the main method of synchronization between goroutines. Each send on a particular channel is matched to a corresponding receive from that channel, usually in a different goroutine.
- channel是goroutine之间进行同步的主要方法。在特定通道上的每一次发送都有与之对应的接收操作相匹配,这通常是在不同的goroutine上发生。
channel有4种不同的同步方式,确保A事件happens before B事件。
第一种:
A send on a channel happens before the corresponding receive from that channel completes.
对一个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的发送,先行发生于C的接收;对于C的接收先行发生于print。
第二种:
The closing of a channel happens before a receive that returns a zero value because the channel is closed.
channel的关闭先行发生于channel返回零值
例如上面的代码, c <- 0, 可以改成close©。 其实效果是一样的
第三种:
A receive from an unbuffered channel happens before the send on that channel completes.
在无缓冲的channel中,对其的接收操作要先行发生于对channel的发送完成
例如:
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的赋值,先行发生于对应channel C的接收;对于C的接收,先行发生于对C的发送完成;对于C的发送完成先行发生于print
第四种:
The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes.
This rule generalizes the previous rule to buffered channels. It allows a counting semaphore to be modeled by a buffered channel: the number of items in the channel corresponds to the number of active uses, the capacity of the channel corresponds to the maximum number of simultaneous uses, sending an item acquires the semaphore, and receiving an item releases the semaphore. This is a common idiom for limiting concurrency.
对第k次的接收操作,先行发生于第k+1次的发送完成
此规则将前面的规则推广到缓冲通道。它允许一个计数信号量由一个缓冲通道来建模:通道中的项目数对应于活动使用的数量,通道的容量对应于同时使用的最大数量,发送一个项目获得信号量,接收一个项目释放信号量。这是限制并发的常用习惯用法。
这种方式通常可以用于控制并发的数量。
例如下面的例子:
var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func(w func()) {
limit <- 1
w()
<-limit
}(w)
}
select{}
}
就限制了每次最多只能由3个work线程进行工作。
锁
sync 包实现了两种锁的数据类型:sync.Mutex 和 sync.RWMutex。
对于任何 sync.Mutex 或 sync.RWMutex 类型的变量 l 以及 n < m ,对 l.Unlock() 的第 n 次调用在对 l.Lock() 的第 m 次调用返回前发生。
此程序:
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”。该程序首先(在 f 中)对 l.Unlock() 进行第一次调用,然后(在 main 中)对 l.Lock() 进行第二次调用,最后执行 print 函数。
对于任何 sync.RWMutex 类型的变量 l 对 l.RLock 的调用,存在一个这样的 n,使得 l.RLock 在对 l.Unlock 的第 n 次调用之后发生(返回),且与其相匹配的 l.RUnlock 在对 l.Lock的第 n+1 次调用之前发生。
Once类型
sync 包通过 Once 类型为存在多个Go程的初始化提供了安全的机制。 多个线程可为特定的 f 执行 once.Do(f),但只有一个会运行 f(),而其它调用会一直阻塞,直到 f() 返回。
通过 once.Do(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" 。 第一次对 twoprint 的调用会运行一次 setup。
Go内存模型解决了什么问题?
前面说了那么多,那我们定义内存模型,到底有什么用呢?其实说到底,go中内存模型,我们主要还是需要解决的是可见性的问题,即在多线程的的环境下,A goroutine中对变量的写,如何才能让在B goroutine中的读操作可见。
多线程的环境下为什么会出现可见性的问题呢?
因为内存重排,内存重排有两块,一个是软件层面的重排,一个是硬件层面的重排。
- 硬件层面的重排主要说的就是编译器的优化,即编译器重排。编译器重排指的是在不改变程序原有的语义的情况下,对于编译后的指令进行了优化,以提高运行效率。
- 软件层面的重排主要说的是CPU重排。CPU的设计者为了提高CPU的执行效率和最大化的榨干它的性能,对它使用了各种手段,有分支预测、流水线等。其中也包括CPU对内存操作的重排,以提高对内存的读写效率。
重排又是如何引起可见性的问题呢?
因为为了提高CPU对内存操作的效率,CPU提出了各种策略,比如我们经常听到的三级缓存策略。
CPU的每个核心都有自己的Store Buffer, 另外还有L1-L2-L3各级缓存,CPU为了提高性能,对某个变量的操作不一定会等到扩散到内存才会进行其他操作,即读写指令的重排。那么,这就会导致,其他核在某一个时刻,读到的不一定是最新的值。
在单线程的环境下,这不会有什么问题,但是在多线程的情况下,这就会导致数据可见性的问题。因此,所有的CPU都提供了所谓的"锁"支持,称之为barrier,或者fence。
A barrier instruction forces all memory operations before it to complete before any memory operation after it can begin.
barrier 指令要求所有对内存的操作都必须要"扩散"到 memory 之后才能继续执行其他对 memory 的操作。也就是所谓的内存屏障(memory barrier)
内存屏障(Memory Barrier)分为写屏障(Store Barrier)、读屏障(Load Barrier)和全屏障(Full Barrier),其作用有两个:
- 防止指令之间的重排序
- 保证数据的可见性
关于第一点,关于指令重排,这里不考虑架构的话,Load和Store两种操作会有Load-Store、Store-Load、Load-Load、Store-Store这四种可能的乱序结果。上面提到的三种屏障则是限制这些不同乱序的机制。
关于第二点。写屏障会阻塞直到把Store Buffer中的数据刷到Cache中;读屏障会阻塞直到Invalid Queue中的消息执行完毕。以此来保证核间各级数据的一致性。
这说的各级数据,指的是L1-L2-L3-主存之间的数据一致性,但是,解决各级cache一致性的问题并不需要程序员关注,而是CPU自身实现,例如因特尔CPU的MESI协议
所以,这里要强调,内存屏障解决的只是顺序一致性的问题,不解决Cache一致性的问题(这是Cache一致性协议的责任,也不需要程序员关注)。Store Buffer和Load Buffer等组件是属于流水线的一部分,和Cache无关。这里一定要区分清楚这两点,Cache一致性协议只是保证了Cache一致性(Cache Coherence),但是不关注顺序一致性(Sequential Consistency)的问题。比如,一个处理器对某变量A的写入操作仅比另一个处理器对A的读取操作提前很短的一点时间,那就不一定能确保该读取操作会返回新写入的值。
barrier 指令要耗费几百个CPU周期,而且容易出错。因此,我们可以用高级点的 atomic compare-and-swap,或者直接用更高级的锁,通常是标准库提供。比如go中的atomic包,就提供了原子操作。
结尾
其实CPU重排这一块,还涉及到CPU多核的情况下,数据一致性的问题,这就涉及到了CPU的缓存一致性协议,比如因特尔提出的MESI协议。以及cpu cacheline (cpu缓存), cpu的 store buffer 和 invalid queue等等。后面再写文章说说自己的理解。
以上是关于Golang的调度模型的主要内容,如果未能解决你的问题,请参考以下文章