Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

由Golang happens before 带来的学习与思考 #25

Open
wisecsj opened this issue Oct 22, 2020 · 10 comments
Open

由Golang happens before 带来的学习与思考 #25

wisecsj opened this issue Oct 22, 2020 · 10 comments

Comments

@wisecsj
Copy link
Owner

wisecsj commented Oct 22, 2020

首先想说明一下诞生背景,这篇文章缘起于在某个Go讨论群里看到有人贴了 https://golang.org/ref/mem 里的一段sample代码,说是a不一定保证打印"hello, world",也可能打印空字符串。代码如下

var a string
var done bool

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

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

当时我是挺惊讶的(有点颠覆我的编码认知),于是读完了 go memory model这篇Go Blog。但说实话,收获也只是限于了解 Happens Before 语义,以及如何避免上面这种无法保证预期输出打印的代码产生(通过Go提供的同步原语,Once,Mutex,Channel)。

但是我是一个比较喜欢刨根究底的人,我仍旧不清楚为什么我们需要Mutex来保证 Happens Before 语义,进而保证写出的代码符合预期。

于是我根据已了解到的相关keyword,Google了一圈。然后发现涉及到的面非常多,也很底层,关乎到CPU执行优化,编译器指令重排,CPU cache ...

所以这篇blog不太可能面面俱到,更多的还是我自己学习整合其它文章的心得与体会,也难免会有错误。希望未来随着自己的精进,也能不断更新完善这篇blog

Don't be clever.

@wisecsj
Copy link
Owner Author

wisecsj commented Oct 24, 2020

首先聊聊CPU cache。在现如今的cpu参数上,我们都会看到有叫L1 L2 L3 cache的东西。没错,它就是除了寄存器cpu能访问到的最快的存储器,为了避免cpu每次从内存去拿数据而浪费的算力。随着多核时代的到来,每个核都有自己的cache,便会出现同一个内存地址上的数据对象有多个副本分布在不同cpu核的cache上,且数据内容不一致。也就是数据一致性问题。

(这里顺带提一下Cache Line这个概念,它是CPU Cache的最小读取单元,一般为cpu字长,比如64bit。这也是为什么在编程语言里可以看到许多结构体会内存对齐,特意padding,避免false sharing问题。)

为了解决CPU cache的数据一致性问题(也可以说是内存可见性问题),比较著名的有在Intel系列cpu广泛使用的MESI协议。它实际代表了四种数据状态:Modified、Exclusive、 Share 、Invalid。这篇文章很好地描述了MESI协议的原理:聊聊缓存一致性协议

虽然MESI协议确实能解决缓存一致性问题,但是消息在总线上的发送接收和相关的处理都是有成本的,会导致一些性能和稳定性问题。为了优化性能,cpu又引入了store buffer这个模块。详细的工作原理不太清楚,我的抽象理解就是延迟执行,间接引入了内存可见性问题

@wisecsj
Copy link
Owner Author

wisecsj commented Oct 24, 2020

总结来说,有这么几个层次会导致指令重排(或者结果看起来是那样):

  1. 编译器优化

  2. CPU为了优化执行速度的乱序执行(流水线执行)

  3. 为了优化CPU Cache一致性协议(引入了Store Buffer),而导致的内存可见性问题

@wisecsj
Copy link
Owner Author

wisecsj commented Oct 24, 2020

为了解决由上述几个点带来的可见性问题,同时为了屏蔽不同的系统平台底层硬件的差异,编程语言抽象出了memory model这个概念。只要开发者正确使用语言提供的同步原语,就能保证程序行为与预期一致。

内存模型:

  1. https://albk.tech/%E8%81%8A%E8%81%8AJMM.html#more
  2. https://www.cnblogs.com/adinosaur/p/6338075.html

@wisecsj
Copy link
Owner Author

wisecsj commented Oct 24, 2020

上面有说到CPU为了提高性能可能会乱序执行,进而多线程程序导致内存可见性问题。针对这点,处理器提供了相关的内存屏障(Memory Barrier)指令,来保证内存可见性:

  1. 刷新store buffer。
  2. 等待直到内存屏障之前的操作已经完成。
  3. 不将内存屏障后面的指令提前到内存屏障之前执行

推荐这篇文章给大家,写得很好: 聊聊内存屏障

语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果

@wisecsj
Copy link
Owner Author

wisecsj commented Oct 24, 2020

接下来我们通过一个实际的例子(sync.Once的实现 ),来感受下上面所说的东西,在我们实际编码中应该如何应用使得程序行为符合预期。

@wisecsj
Copy link
Owner Author

wisecsj commented Nov 5, 2020

首先明确sync.Once语义。它接收一个参数,类型为 f func()。且保证在任何情况下,once.Do(f)在f函数执行完后返回,f执行且仅执行一次(exactly once)。

明确语义之后,我们来看几个错误的实现sample,来了解官方源码实现为什么要这么写

@wisecsj
Copy link
Owner Author

wisecsj commented Nov 5, 2020

1


	//	if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
	//		f()
	//	}

这个现在是直接放在源码文档里的,我印象里之前是没有这段说明的(貌似是go官方被问烦了为什么不这么实现)

这个错误就是当某个goroutine A成功抢到cas,置o.done为1;同时另一个goroutine B检测到o.done为1,直接返回了。而此时,A中的f可能还没有执行完,而B已经返回,违背了once的语义

@wisecsj
Copy link
Owner Author

wisecsj commented Nov 7, 2020

2


type Once struct {
	done uint32
	m    sync.Mutex
}

func (o *Once) Do(f func()) {
         // fast path
	if o.done == 1 {
		return 
	}
         // slow path
	o.m.Lock()
	defer o.m.Unlock()
	if o.done == 0 {
		o.done = 1
		f()
	}
}


这个例子有以下问题:

  1. o.done应该加上defer

既是保证once的语义,在f()执行完之后才返回;同时防止用户函数f内部panic导致o.done未更新

  1. 从直觉来看,会首先想到在读取更新变量o.done时没有用原子操作。但是具体为什么呢?

其实这里o.done的load和set是否使用sync/atomic,对程序的正确性没有影响。其实once早先的官方实现是没有fast path的,最早只有slow path代码,done的类型也是bool。所以,如果不用atomic,会影响的是fast path的o.done可能读到的不是最新数据,从而走到slow path,仅此而已。

那现在使用了atomic,好处是什么?(以下讨论基于x86-64 架构)
image

在目前官方的atomic.Store实现,编译查看汇编代码,可以发现使用的是汇编指令XCHG。而The LOCK prefix is automatically assumed for XCHG instruction.。也就意味着,atomic.Stroe具有happens before语义,从而保证了o.done的load能取到最新的值,不会走到slow path,整体的benchmark会更快

Ref:

  1. Golang 1.3 sync.Atomic源码解析
  2. 探索 Golang 一致性原语

----- Updated 2021.3.5 -------

https://codereview.appspot.com/4641066/#ps1

@wisecsj
Copy link
Owner Author

wisecsj commented Nov 7, 2020

PS: rsc 在13年就提出建议需要一个描述sync.Atomic与memeroy model关系的说明。golang/go#5045
但是一直pending到现在。
(略微扫了一遍,目前水平无法理解讨论的内容)

@wisecsj
Copy link
Owner Author

wisecsj commented Nov 9, 2020

https://github.com/cch123/golang-notes/blob/master/memory_barrier.md (更完整的一篇讲memory barrier)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant