【Go实现】设计模式:01-创建型-单例模式

单例模式

模式定义

单例模式(Singleton Pattern)是一种创建型设计模式,它保证一个类只有一个实例,并提供一个全局访问点。单例模式常用于需要共享资源或控制资源访问的场景,例如线程池、全局缓存、对象池等,这种场景下就适合使用单例模式。

Ensure a class only has one instance, and provide a global point of access to it.

但是,并非所有全局唯一的场景都适合使用单例模式。比如,考虑需要统计一个API调用的情况,有两个指标,成功调用次数和失败调用次数。这两个指标都是全局唯一的,所以有人可能会将其建模成两个单例SuccessApiMetricFailApiMetric。按照这个思路,随着指标数量的增多,你会发现代码里类的定义会越来越多,也越来越臃肿。这也是单例模式最常见的误用场景,更好的方法是将两个指标设计成一个对象ApiMetric下的两个实例ApiMetic successApiMetic fail

如何判断一个对象是否应该被建模成单例?

通常,被建模成单例的对象都有“中心点”的含义,比如线程池就是管理所有线程的中心。所以,在判断一个对象是否适合单例模式时,先思考下,这个对象是一个中心点吗?

模式结构

@startuml

class Singleton {
    - instance: Singleton
    - Singleton()
    + getInstance(): Singleton
}


Singleton -->  Singleton : create

@enduml

代码实现

有一些错误是很常见的,比如不考虑并发安全的单例模式。就像下面的示例代码:

package singleton

type singleton struct {}

var instance *singleton

func GetInstance() *singleton {
	if instance == nil {
		instance = &singleton{}   // 不是并发安全的
	}
	return instance
}

在上述情况下,多个goroutine可以执行第一个检查,并且它们都将创建该singleton类型的实例并相互覆盖。无法保证它将在此处返回哪个实例,并且对该实例的其他进一步操作可能与开发人员的期望不一致。

不好的原因是,如果有代码保留了对该单例实例的引用,则可能存在具有不同状态的该类型的多个实例,从而产生潜在的不同代码行为。这也成为调试过程中的一个噩梦,并且很难发现该错误,因为在调试时,由于运行时暂停而没有出现任何错误,这使非并发安全执行的可能性降到了最低,并且很容易隐藏开发人员的问题。

激进的加锁

也有很多对这种并发安全问题的糟糕解决方案。使用下面的代码确实能解决并发安全问题,但会带来其他潜在的严重问题,通过加锁把对该函数的并发调用变成了串行。

var mu Sync.Mutex

func GetInstance() *singleton {
    mu.Lock()                    // 如果实例存在没有必要加锁
    defer mu.Unlock()

    if instance == nil {
        instance = &singleton{}
    }
    return instance
}

在上面的代码中,我们可以看到在创建单例实例之前通过引入Sync.Mutex和获取Lock来解决并发安全问题。问题是我们在这里执行了过多的锁定,即使我们不需要这样做,在实例已经创建的情况下,我们应该简单地返回缓存的单例实例。在高度并发的代码基础上,这可能会产生瓶颈,因为一次只有一个goroutine可以获得单例实例。

因此,这不是最佳方法。我们必须考虑其他解决方案。

Check-Lock-Check模式

在C ++和其他语言中,确保最小程度的锁定并且仍然是并发安全的最佳和最安全的方法是在获取锁定时利用众所周知的Check-Lock-Check模式。该模式的伪代码表示如下。

if check() {
    lock() {
        if check() {
            // 在这里执行加锁安全的代码
        }
    }
}

该模式背后的思想是,你应该首先进行检查,以最小化任何主动锁定,因为IF语句的开销要比加锁小。其次,我们希望等待并获取互斥锁,这样在同一时刻在那个块中只有一个执行。但是,在第一次检查和获取互斥锁之间,可能有其他goroutine获取了锁,因此,我们需要在锁的内部再次进行检查,以避免用另一个实例覆盖了实例。

如果将这种模式应用于我们的GetInstance()方法,我们会写出类似下面的代码:

func GetInstance() *singleton {
    if instance == nil {     // 不太完美 因为这里不是完全原子的
        mu.Lock()
        defer mu.Unlock()

        if instance == nil {
            instance = &singleton{}
        }
    }
    return instance
}

通过使用sync/atomic这个包,我们可以原子化加载并设置一个标志,该标志表明我们是否已初始化实例。

import "sync"
import "sync/atomic"

var initialized uint32
... // 此处省略

func GetInstance() *singleton {

    if atomic.LoadUInt32(&initialized) == 1 {  // 原子操作 
		    return instance
	  }

    mu.Lock()
    defer mu.Unlock()

    if initialized == 0 {
         instance = &singleton{}
         atomic.StoreUint32(&initialized, 1)
    }

    return instance
}

但是……这看起来有点繁琐了,我们其实可以通过研究Go语言和标准库如何实现goroutine同步来做得更好。

Go语言惯用的单例模式

我们希望利用Go惯用的方式来实现这个单例模式。我们在标准库sync中找到了Once类型。它能保证某个操作仅且只执行一次。下面是来自Go标准库的源码(部分注释有删改)。

// 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
}

func (o *Once) Do(f func()) {
	if atomic.LoadUint32(&o.done) == 0 { // check
		// Outlined slow-path to allow inlining of the fast-path.
		o.doSlow(f)
	}
}

func (o *Once) doSlow(f func()) {
	o.m.Lock()                          // lock
	defer o.m.Unlock()
	
	if o.done == 0 {                    // check
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}

这说明我们可以借助这个实现只执行一次某个函数/方法,once.Do()的用法如下:

once.Do(func() {
    // 在这里执行安全的初始化
})

下面就是单例实现的完整代码,该实现利用sync.Once类型去同步对GetInstance() 的访问,并确保我们的类型仅被初始化一次。

package singleton

import (
    "sync"
)

type singleton struct {}

var instance *singleton
var once sync.Once

func GetInstance() *singleton {
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

因此,使用sync.Once包是安全地实现此目标的首选方式,类似于Objective-C和Swift(Cocoa)实现dispatch_once方法来执行类似的初始化。

注意一点:单例模式同时解决了两个问题:保证一个类只有一个实例为该实例提供一个全局访问节点 。所以违反了单一职责原则。

典型应用场景

  1. 日志。每个服务通常都会需要一个全局的日志对象来记录本服务产生的日志。

  2. 全局配置。对于一些全局的配置,可以通过定义一个单例来供客户端使用。

  3. 唯一序列号生成。唯一序列号生成必然要求整系统只能有一个生成实例,非常合适使用单例模式。

  4. 线程池、对象池、连接池等。xxx池的本质就是共享,也是单例模式的常见场景。

  5. 全局缓存

  • 19
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值