深圳幻海软件技术有限公司 欢迎您!

简单的单例模式,Go版本的实现你写对了吗?

2023-02-28

大家好,我是网管,首先我问大家一个问题,你们面试的时候,面试官有没有问过你们:"你都用过什么设计模式?",我猜多数人的回答会把单例模式,放在第一位。我:"呃…我用过单例、工厂、观察者,反向代理,装饰器,哨兵"….",面试官内心OS:"我都没用过这么多...反向代理是什么鬼,这小子背串了吧,不管了先就

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

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

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

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

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

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

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

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

面试卒...

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

单例模式

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

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

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

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

饿汉模式

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

如果你想了解 Go init 函数的方方面面,可以看以前的老文章Go语言init函数你必须记住的六个特征

下面用单例模式返回数据库连接实例,相信你们在项目里都见过类似代码。

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

var dbConn *databaseConn

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

// GetInstance 获取实例
func Db() *databaseConn {
 return dbConn
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

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

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

懒汉模式

懒汉模式--通俗点说就是延迟加载,不过这块特别注意,要考虑并发环境下,你的判断实例是否已经创建时,是不是用的当前读。在一些教设计模式的教程里,一般这种情况下会举一个例子--用 Java 双重锁实现线程安全的单例模式,双重锁指的是volatile和synchronized。

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;
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

上面这个例子里,如果不给instance​属性加上 volatile​修饰符,那么虽说创建的过程已经用synchronized​给类加了锁,但是有可能读到的instance​是线程缓存是滞后的,有可能属性此时已经被其他线程初始化了,所以就必须加上volatile保证当前读(读主存里属性的状态)。

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

如果 Go 原子操作你还不熟,请看老文章Golang 五种原子性操作的用法详解

import "sync"
import "sync/atomic"

var initialized uint32

type singleton struct {
  ...
}

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
}
  • 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.

确实,相当于把上面 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
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

关于sync.One ​的使用和其实现原理…我发现我的Go 并发编程系列​里没单独写Once​这个原语,可能是觉得太简单了吧,后期抽空补上吧… 不过只是原理分析没写,怎么应用在Go语言sync包的应用详解里也能找到。

总结

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