概念
问题:什么是单例模式?什么时候使用单例模式? :::danger 确保一个类只有一个实例,并提供一个全局访问点来访问该实例。
项目中,往往有以下使用场景:
- 日志类,配置文件类:全局唯一,提供给项目内所有位置使用。
- 资源唯一:在系统中,本身存在只允许保存一份的数据,如:生成唯一ID, 数据库连接池对象等。
单例模式的初始化分为两种:
简单使用
简单的思考下,单例模式在golang中如何编码?
var doOnce sync.Once
var doSomething *DoSomething
type DoSomething struct {
}
func NewDoSomething() *DoSomething {
doOnce.Do(func() {
fmt.Println("init doSomething..")
doSomething = &DoSomething{}
})
fmt.Println("outer doOnce..")
return doSomething
}
func TestSimpleOnce(t *testing.T) {
first := NewDoSomething()
second := NewDoSomething()
fmt.Printf("first:%p second:%p \n", first, second)
}
以上方式,即可在单个进程中创建一个单例对象,提供给其他函数使用。
分析
在项目中,单例模式算是使用的比较多的设计模式。我在使用过程中,存在以下几个问题:
代码可测试性
随意的在代码中直接引用全局对象(单例模式对象往往是全局对象),会导致程序的可测试降低。在编写单元测试的过程中,都遇到过以下几个场景:
- 日志初始化问题:日志往往都是直接使用内部封装库,在需要使用的时候,直接引用:log.Infof(),然后单元测试直接崩溃: 日志对象未初始化。
:::danger 解决方式: - 在日志封装库中,使用init函数进行默认初始化操作。比如 zap.NewExample()。
- 通过依赖注入框架,在类初始化时,由外部传入log对象。(推荐) :::
- 配置文件全局引用:同#1的问题,在对象内部直接引用全局配置对象。
- 类似DB操作,无法进行mock替换。
单测初始化问题
从简单使用章节发现once.Do()函数,只支持func()类型的函数,不存在任何返回值,以及参数初始化。这里就存在两个使用场景:
- 无法返回error类型,如果对象没有正确完成初始化,那么在调用的时候,一定会出现非预期的错误。
- 无法传递参数:只能通过闭包的方式引用外部变量或者使用全局配置的方式。
对于这种,我目前的操作都是使用饿汉式,在main()中直接进行初始化,这样如果初始化失败,则直接可以panic(),提前发现错误。
隐藏类依赖关系
同可测试性一样,由于是全局对象,直接可以在函数内部进行使用。 当代码逻辑比较复杂的情况下,比较难得分析当前类的依赖关系。(与init函数一样的问题)
once的实现原理
在go中单例模式 sync.Once的源码,还是比较简单的:
type Once struct {
// 它在结构体中排在第一位,因为它在热路径中使用。
// 热路径在每个调用位置都进行了内联。
// 将 done 放在前面可以在某些架构(amd64/386)上使用更紧凑的指令、
// 在其他架构上,则可以减少指令(计算偏移量)
// done 表示操作是否已执行。
done atomic.Uint32
m Mutex
}
/*
Do 调用函数 f,前提是且仅当该 Once 实例第一次调用 Do 时。换句话说,给定
var once Once
如果多次调用 once.Do(f),只有第一次调用才会调用 f,即使每次调用的 f 值都不同。
每个函数的执行都需要一个新的 Once 实例。
Do 用于必须精确运行一次的初始化。由于 f 为 niladic,因此可能需要使用函数字面来捕获 Do 将调用的函数的参数:
config.once.Do(func() { config.init(filename) })
由于在对 f 的一次调用返回之前,对 Do 的调用都不会返回,因此如果 f 导致 Do 被调用,它就会陷入死锁。
如果 f 陷入恐慌,Do 就会认为它已经返回;以后调用 Do 都不会再调用 f。
*/
func (o *Once) Do(f func()) {
/*
注:以下是 Do 的一个错误实现:
如果 o.done.CompareAndSwap(0, 1) {
f()
}
Do 保证当它返回时,f 已完成。这种实现无法实现这一保证:
在两个同时调用的情况下,其中一个会调用 f,第二个会立即返回,而不会等待第一个对 f 的调用完成。
这就是慢速路径回退到互斥的原因,也是 o.done.Store 必须延迟到 f 返回之后的原因。
*/
if o.done.Load() == 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.Load() == 0 {
defer o.done.Store(1)
f()
}
}
可以看出,在golang中 单例模式使用的是 懒汉式+二次检测 的方式实现。 而注释中也说明了,为什么应该这么实现: :::danger 当f()函数为slow函数的时候,如果使用单次CAS的方式,会导致并发的时候,其他协程在调用单例对象时,对象并未初始化完成,而出现非预期的错误。 :::
总结
最近在突破自己的瓶颈,疯狂在阅读技术文章中,接下来会经常总结自己的一些思考,以及使用场景。
参考
- 《设计模式之美》-单例模式
- 《Go并发编程实战课》-sync.Once的使用