这几天我翻了翻golang的提交记录,发现了一条颇有意思的提交:bc593ea,这个提交看似简单,可是引人深思。git
commit的标题是“sync: document implementation of Once.Do”,显然是对文档作些补充,然而奇怪的是为何要对某个功能的实现作文档说明呢,难道不是配合代码+注释就能理解的吗?github
根据commit的描述咱们得知,Once.Do的实现问题在过去几个月内被问了至少两次,因此官方决定澄清:golang
It's not correct to use atomic.CompareAndSwap to implement Once.Do,
and we don't, but why we don't is a question that has come up
twice on golang-dev in the past few months.
Add a comment to help others with the same question.编程
不过这不是这个commit的精髓,真正有趣的部分是添加的那几行注释。安全
commit添加的内容以下:并发
乍一看可能平平无奇,然而仔细思考事后,咱们就会发现问题了。函数
众所周知,sync.Once
用于保证某个操做只会执行一次,所以咱们首先考虑到的就是为了并发安全加mutex,可是once对性能有必定要求,因此咱们选用原子操做。性能
这时候atomic.CompareAndSwapUint32
很天然的就会浮如今脑海里,而下面的结构也很天然的就给出了:atom
func (o *Once) Do(f func()) { if atomic.CompareAndSwapUint32(&o.done, 0, 1) { f() } }
然而正是这种天然联想的方案倒是官方否认的,为何?3d
缘由很简单,举个例子,咱们有一个模块,使用模块里的方法前须要初始化,不然会报错:
module.go:
package module var flag = true func InitModule() { // 这个初始化模块的方法不能够调用两次以上,以便于结合sync.Once使用 if !flag { panic("call InitModule twice") } flag = false } func F() { if flag { panic("call F without InitModule") } }
main.go:
package main import ( "module" "sync" "time" ) var o = &sync.Once{} func DoSomeWork() { o.Do(module.InitModule()) // 不能屡次初始化,因此要用once module.F() } func main() { go DoSomeWork() // goroutine1 go DoSomeWork() // goroutine2 time.Sleep(time.Second * 10) }
如今无论goroutine1仍是goroutine2后运行,module
都能被正确初始化,对于F
的调用也不会panic,但咱们不能忽略一种更常见的状况:两个goroutine同时运行会发生什么?
咱们列举其中一种状况:
InitModule
开始执行InitModule
执行被跳过F()
InitModule
在goroutine1中由于某些缘由没执行完,因此咱们不能调用F
你可能已经看出问题了,咱们没有等到被调用函数执行完就返回了,致使了其余goroutine得到了一个不完整的初始化状态。
解决起来也很简单:
f
的操做f
f
只会被调用一次了这是代码:
func (o *Once) Do(f func()) { if atomic.LoadUint32(&o.done) == 0 { // Outlined slow-path to allow inlining of the fast-path. o.doSlow(f) } } func (o *Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() if o.done == 0 { defer atomic.StoreUint32(&o.done, 1) f() } }
从这个问题咱们能够看到,并发编程其实并不难,咱们给出的解决方案是至关简单的,然而难的在于如何全面的思考并发中会遇到的问题从而编写并发安全的代码。
golang的这个commit给了咱们一个很好的例子,同时也是一个很好的启发。