详解并发编程之sync.Once的实现(附上三道面试题)
这是并发编程系列的第二篇文章. 上一篇我们一起分析了
atomic
包,今天我们一起来看一看sync/once
的使用与实现.
什么是sync.once
Go语言标准库中的sync.Once
可以保证go
程序在运行期间的某段代码只会执行一次,作用与init
类似,但是也有所不同:
init
函数是在文件包首次被加载的时候执行,且只执行一次。sync.Once
是在代码运行中需要的时候执行,且只执行一次。
还记得我之前写的一篇关于go
单例模式,懒汉模式的一种实现就可以使用sync.Once
,他可以解决双重检锁带来的每一次访问都要检查两次的问题,因为sync.once
的内部实现可以完全解决这个问题(后面分析完源码就知道原因了),下面我们来看一看这种懒汉模式怎么写:
type singleton struct {
}
var instance *singleton
var once sync.Once
func GetInstance() *singleton {
once.Do(func() {
instance = new(singleton)
})
return instance
}
实现还是比较简单,就不细说了。
源码解析
sync.Once
的源码还是很少的,首先我们看一下他的结构:
// Once is an object that will perform exactly one action.
type Once struct {
// done indicates whether the action has been performed.
// It is first in the struct because it is used in the hot path.
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/x86),
// and fewer instructions (to calculate offset) on other architectures.
done uint32
m Mutex
}
只有两个字段,字段done
用来标识代码块是否执行过,字段m
是一个互斥锁。
接下来我们一起来看一下代码实现:
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
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()
}
}
这里把注释都省略了,反正都是英文,接下来咱用中文解释哈。sync.Once
结构对外只提供了一个Do()
方法,该方法的参数是一个入参为空的函数,这个函数也就是我们想要执行一次的代码块。接下来我们看一下代码流程:
首先原子性的读取
done
字段的值是否改变,没有改变则执行doSlow()
方法.一进入
doslow()
方法就开始执行加锁操作,这样在并发情况下可以保证只有一个线程会执行,在判断一次当前done
字段是否发生改变(这里肯定有朋友会感到疑惑,为什么这里还要在判断一次flag
?这里目的其实就是保证并发的情况下,代码块也只会执行一次,毕竟加锁是在doslow()
方法内,不加这个判断的在并发情况下就会出现其他goroutine
也能执行f()
),如果未发生改变,则开始执行代码块,代码块运行结束后会对done
字段做原子操作,标识该代码块已经被执行过了.
优化sync.Once
如果让你自己写一个这样的库,你会考虑的这样全面吗?相信聪明的你们也一定会写出这样一段代码。如果要是我来写,上面的代码可能都一样,但是在if o.done == 0
这里我可能会采用CAS
原子操作来代替这个判断,如下:
type MyOnce struct {
flag uint32
lock sync.Mutex
}
func (m *MyOnce)Do(f func()) {
if atomic.LoadUint32(&m.flag) == 0{
m.lock.Lock()
defer m.lock.Unlock()
if atomic.CompareAndSwapUint32(&m.flag,0,1){
f()
}
}
}
func testDo() {
mOnce := MyOnce{}
for i := 0;i<10;i++{
go func() {
mOnce.Do(func() {
fmt.Println("test my once only run once")
})
}()
}
}
func main() {
testDo()
time.Sleep(10 * time.Second)
}
// 运行结果:
test my once only run once
我就说原子操作是并发编程的基础吧,你看没有错吧~。
小试牛刀
上面我们也看了源码的实现,现在我们来看三道题,你认为他们的答案是多少?
问题一
sync.Once()
方法中传入的函数发生了panic
,重复传入还会执行吗?
func panicDo() {
once := &sync.Once{}
defer func() {
if err := recover();err != nil{
once.Do(func() {
fmt.Println("run in recover")
})
}
}()
once.Do(func() {
panic("panic i=0")
})
}
问题二
sync.Once()
方法传入的函数中再次调用sync.Once()
方法会有什么问题吗?
func nestedDo() {
once := &sync.Once{}
once.Do(func() {
once.Do(func() {
fmt.Println("test nestedDo")
})
})
}
问题三
改成这样呢?
func nestedDo() {
once1 := &sync.Once{}
once2 := &sync.Once{}
once1.Do(func() {
once2.Do(func() {
fmt.Println("test nestedDo")
})
})
}
总结
在本文的最把上面三道题的答案公布一下吧:
问题一:不会打印任何东西,
sync.Once.Do
方法中传入的函数只会被执行一次,哪怕函数中发生了panic
;问题二:发生死锁,根据源码实现我们可以知道在第二个
do
方法会一直等doshow()
中锁的释放导致发生了死锁;问题三:打印
test nestedDo
,once1,once2是两个对象,互不影响。所以sync.Once
是使方法只执行一次对象的实现。
你们都做对了吗?
代码已上传:https://github.com/asong2020/Golang_Dream/tree/master/code_demo/once_demo.(欢迎Star)
推荐阅读