前言

近期手上有一些需要定时任务的需求(Go定时任务可以看这一篇:Go-并发编程与定时器),而单例模式可以很好的保证定时任务不被重复创建,Go在官方库中也提供了优雅的单例模式实现方式,即sync包中的Once类型

Once 官方描述 Once is an object that will perform exactly one action,即 Once 是一个对象,它提供了保证某个动作只被执行一次功能,最典型的场景就是单例模式

需要了解的几个词

  • 单例模式:单例模式,也叫单子模式,是一种常用的软件设计模式,属于创建型模式的一种。在应用这个模式时,单例对象的必须保证只有一个实例存在。许多时候整个系统只需要拥有一个的全局对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理(Wiki百科
  • Goroutine:Go 程(goroutine)是由Go 运行时管理的轻量级线程。 go f(x, y, z). 会启动一个新的Go 程并执行 f(x, y, z)
  • 原子操作:指具有原子性的操作,原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败
  • 可观测性:Go中两个Goroutine之间不具有可观测性,这在并发编程中格外重要,值得注意

单例模式实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
once.Do(func() {
//检查redis上限任务
tickerR := time.NewTicker(time.Second * time.Duration(60*15))
go func() {
defer tickerR.Stop()
for {
select {
case <-tickerR.C:
if err = GFeatureRedisController.CheckFieldNumLimit(msg); err != nil {
kutil.Logger.ErrorfWithSpan(&spanNew, "FeatureRedisController.CheckFieldNumLimit error: %s", err.Error())
}
case stop := <-StopChan:
if stop {
kutil.Logger.Infof("shutdown ticker with task CheckFieldNumLimit")
return
}
}
}
}()
})

once.Do 中的函数只会执行一次,并保证 once.Do 返回时,传入Do的函数已经执行完成。(多个 goroutine 同时执行 once.Do 的时候,可以保证抢占到 once.Do 执行权的 goroutine 执行完 once.Do 后,其他 goroutine 才能得到返回 )

源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package sync

import (
"sync/atomic"
)

type Once struct {
done uint32
m Mutex
}

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

遇事不决读源码,往往都能收获不一样的东西

这里的三个值得注意的问题:

  • Do 方法为什么不直接 o.done == 0 而要使用 atomic.LoadUint32(&o.done) == 0
  • 为什么 doSlow 方法中直接使用 o.done == 0
  • 既然已经在临界区内,为什么不直接 o.done = 1, 还需要使用 atomic.StoreUint32(&o.done, 1)

第一个问题很简单,如果直接 o.done == 0,会导致无法及时观察 doSlow 对 o.done 的值设置。具体原因可以参考 Go 的内存模型 ,文章中提到:

Programs that modify data being simultaneously accessed by multiple goroutines must serialize such access.

To serialize access, protect the data with channel operations or other synchronization primitives such as those in the sync and sync/atomic packages.

大意是当一个变量被多个 Goroutine 访问的时候,必须要保证他们是有序的(同步),可以使用 sync 或者 sync/atomic 包来实现。用了 LoadUint32 可以保证 doSlow 设置 o.done 后可以及时的被取到

再看第二个问题,可以直接使用 o.done == 0 是因为使用了 Mutex 进行了锁操作,o.done == 0 处于锁操作的临界区中,所以可以直接进行比较

那么第三个问题呢? atomic.StoreUint32(&o.done, 1) 也处于临界区,为什么不直接通过 o.done = 1 进行赋值?这其实还是和内存模式有关;Mutex 只能保证临界区内的操作是可观测的 即只有处于o.m.Lock() 和 defer o.m.Unlock()之间的代码对 o.done 的值是可观测的;也就是说 Do函数 中对 o.done 访问就无法及时观测到,因此需要使用 StoreUint32 保证原子性,这样才不会出现一个Goroutine内已经o.done置一了,另一个Goroutine内判断if o.done == 0时因为没有及时观测到o.done已经被置一了而继续调用f函数的情况

总结

Go的单例模式的实现其实很优雅简单,但我们可以深究源码,深入了解到Go并发编程的一些相关知识