用Go语言实现建造者模式---单例模式

大家好,我是网管,首先我问大家一个问题,你们面试的时候,面试官有没有问过你们:"你都用过什么设计模式?",我猜多数人的回答会把单例模式,放在第一位。

我:"呃… 我用过单例、工厂、观察者,反向代理,装饰器,哨兵"…. ",

面试官内心OS:"我都没用过这么多...反向代理是什么鬼,这小子背串了吧,不管了先就坡下驴,从头开始问"。

面试官:"用过的挺多哈,那么你能说下单例你都在什么情况下用,顺便在这张纸上实现一下单例吧"。

我:"当需要确保一个类型,只有一个实例时就需要使用单例模式了"。

面试官:"好,那你在纸上实现一下"

十分钟后的我:"不好意思,我们之前项目里都封装好了,我只用过,没有机会实现,所以..."

面试官内心OS:"好吧,这个面试KPI要求得进行三十分钟,这还有小二十分钟呢,随便再问问,就让他回去等信儿吧"

面试卒...

上面是我给大家编的一个场景,如有雷同,请憋住,不要在工位上笑喷~。单例模式虽然简单,不过还是有一些说道儿的,一是应用比较广泛,再来如果不注意容易在多线程环境下造成BUG,今天就给大家简单说下单例模式的应用,以及用Go语言怎么正确地实现单例模式。

单例模式

上面对话里说的没错,单例模式是用来控制类型实例的数量的,当需要确保一个类型只有一个实例时,就需要使用单例模式。

由于要控制数量,那么可想而之只能把实例的访问进行收口,不能谁来了都能 new 一个出来,所以单例模式还会提供一个访问该实例的全局端口,一般都会命名个 GetInstance之类的函数用作实例访问的端口。

又因为在什么时间创建出实例,单例模式又可以分裂出饿汉模式 和 懒汉模式,前者适用于在程序早期初始化时创建已经确定需要加载的类型实例,比如项目的数据库实例。后者其实就是延迟加载的模式,适合程序执行过程中条件成立才创建加载的类型实例。

下面我们用 Go 代码把这两种单例模式实现一下。

饿汉模式

这个模式用 Go 语言实现时,借助 Go 的init函数来实现特别方便

package main

// 饿汉式单例
// 注意定义非导出类型
type  databaseConn struct{
	//todo
}

var dbConn *databaseConn

func init() {
	dbConn = &databaseConn{}
}

// GetInstance 获取实例
func Db() *databaseConn {
	return dbConn
}

这里初始化数据库的细节咱们就不多费文笔了,实际情况肯定是从配置中心加载下来数据库连接配置再实例化数据库的连接对象。这里有人可能会有个问题,你这一个程序进程就只有一个数据连接实例,那这么多请求都用一个数据库连接行吗?

诶,这个是对数据库连接的抽象呀,这个实例会维护一个连接池,那里才是真正去请求数据库用的连接。是不是有点晕,有点晕去看看你们项目里这块的代码。一般会看到初始化实例时,让你设置最大连接数、闲置连接数和存活时间这样的连接池配置。

懒汉模式
class Singleton {
    private volatile static Singleton instance = null;

    private Singleton() {}

    public static Singleton getInstance() {
        if(instance==null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

这段代码是经典的双重锁定(Double-Checked Locking)实现单例模式的代码。在多线程环境下,通过这种方式可以确保只有一个实例被创建,并且实例的创建是延迟进行的(懒加载)。

  1. 当第一个线程进入getInstance()方法时,会检查instance是否为null,即是否已经创建了实例。
  2. 如果instance为null,则该线程进入第一个if块内部。
  3. 在进入synchronized (Singleton.class)代码块之前,会获取Singleton.class的锁,确保在同一时刻只有一个线程可以进入临界区。
  4. 由于该线程获得了锁,进入临界区后,再次检查instance是否为null。
  5. 在进入临界区时的第二次检查是为了防止在等待锁期间,其他线程已经创建了实例。如果instance仍然为null,则该线程在临界区内创建一个新的Singleton实例。
  6. 当线程执行完实例创建的过程后,离开临界区,释放Singleton.class的锁,其他线程可以进入临界区。
  7. 其他线程(例如线程B、线程C等)在进入getInstance()方法时,由于此时instance已经不为null,它们直接跳过第一个if块和synchronized代码块,直接返回已经存在的实例。
  8. 之后,所有线程都会返回同一个实例,从而保证了单例模式的正确性。

这样,即使在多线程环境下,也能正确地保证只有一个实例被创建,并且实例的创建是延迟进行的。不过需要注意,为了确保双重锁定模式的正确性和线程安全性,建议在instance变量前加上volatile关键字,以避免指令重排带来的问题。

问题的出现是由于指令重排序的原因。在多线程环境下,编译器和处理器为了提高性能可能会对指令进行重排序,这种重排序在单线程环境下不会产生问题,但在多线程环境下可能导致意想不到的结果

具体来说,在以下的代码中:

if(instance==null) {
    synchronized (Singleton.class) {
        if(instance==null)
            instance = new Singleton();
    }
}

可能会被重排序成以下的步骤:

  1. 分配内存空间给instance对象。
  2. 调用Singleton的构造函数,初始化instance对象。
  3. instance对象指向分配的内存空间。

在多线程环境下,如果线程A执行了1和3步骤,然后线程B进入getInstance()方法,此时由于instance不为null,线程B可能会直接返回一个尚未初始化的实例,即使线程A已经将instance设置为了非null值。这样就导致了线程B获取到的实例是不完整的,这违反了单例模式的原则。

为了解决这个问题,可以通过在instance变量前加上volatile关键字来保证指令重排序的有序性,从而正确实现双重锁定的单例模式

那么 Go 里边没有volatile这种机制,我们该怎么办呢?聪明的你一定能想得出,我们定义一个实例的状态变量,然后用原子操作atomic.Loadatomic.Store去读写这个状态变量,不就是实现了吗?像下面这样:

package main

import (
	"sync"
	"sync/atomic"
)

// 定义一把锁
var mutex sync.Mutex

//定义singleton结构体

type singleton struct {
	//todo

}

// 定义一个实例
var instance *singleton

// 定义一个标志位
var initialized uint32

//getInstance

func GetInstance() *singleton {
	if atomic.LoadUint32(&initialized) == 1 {   //原子操作
		return instance
	}
	mutex.Lock()
	defer mutex.Unlock()
	if initialized == 0 {   //双重检测
		instance = &singleton{}
		atomic.StoreUint32(&initialized, 1)
	}
	return instance
}

确实,相当于把上面 Java 的例子翻译成用 Go 实现了,不过还有另外一种更Go native 的写法,比这种写法更简练。如果用 Go 更惯用的写法,我们可以借助其sync库中自带的并发同步原语Once来实现:

package singleton

import (
    "sync"
)

type singleton struct {}

var instance *singleton
var once sync.Once

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

这篇文章其实是把单例模式的应用,和Go的单例模式版本怎么实现给大家说了一下,现在教程大部分都是用 Java 讲设计模式的,虽然我们可以直接翻译,不过有的时候 Go 有些更native 的实现方式,让实现更简约一些。

你可能感兴趣的:(golang,建造者模式,单例模式)