[Golang修仙之路]单例模式

1. 什么是单例模式?

我认为单例模式的核心就是:确保一个类只有一个实例被创建,在其他地方都可以访问到这个实例。

2. 什么场景用单例模式?

在实习公司现在的项目中,感觉Config、Logger、DB的Conn 其实这种都可以使用单例模式。

3. 懒汉模式 vs 饿汉模式

  • 饿汉模式:饥饿,所以没等程序运行就要创建单例。
  • 懒汉模式:懒蛋,所以只有当单例第一次被使用的时候,才会被创建。

各自优缺点:

  • 懒汉模式相比于饿汉模式更节省内存,如果单例十分大,无论是否被使用都创建,对内存是个浪费。
  • 饿汉模式相对来说实现简单,没有并发问题。

3.1 饿汉模式

饿汉模式我先是看了 刘丹冰老师 的代码示例,实现很简单,也讲清楚了哪些地方需要导出,哪些地方需要保持私有。

3.1.1 饿汉v1

golang 复制代码
// 1. 为什么这里singleton不能导出?
// 答:防止外部通过类创建实例。
type singleton struct {}

// 2. 为什么这里s不能导出?
// 答:防止外部更改指针。比如s=nil。
var s *singleton = new(singleton)

// 3. 为什么这里GetInstance是导出的?
// 答:需要让外部使用。
// 4. 这能不能写成 singleton 类的结构体方法?
// 答:不能。因为,结构体方法需要实例才能使用,这个就是创建实例的方法。
func GetInstance() *singleton {
    return s
}
3.1.1.1 存在问题

但是这个程序有个啥问题呢?就是 「导出的方法返回了私有的变量」,我感觉这样写是bug。因为恶意代码仍然可以修改指针,不符合单例。该代码的问题如下:

golang 复制代码
package main

import "fmt"

type singleton struct {
	name string
}

var s *singleton = &singleton{
	name: "singleton",
}

func GetInstance() *singleton {
    return s
}

func main() {
    instance := GetInstance()
    fmt.Printf("instance: %v\n", instance) // 输出:instance: &{singleton}

    // 恶意代码仍然可以修改指针
    *instance = singleton{
        name: "new singleton",
    }
    a := GetInstance()
    fmt.Printf("a: %v\n", a) // 输出:a: &{new singleton}
}

3.1.2 饿汉v2

然后参考了 小徐先生的编程世界 的公众号,他的介绍单例模式的文章里,提到了这个问题(但是没有写我的例子),他认为这只是一种代码规范问题,并给出了正确的写法:定义一个接口,获取实例的方法返回的是抽象的接口而不是指向实例的指针。

golang 复制代码
type Instance interface {
    Work()
}

type singleton struct{}

// singleton类实现Instance接口
func (s *singleton) Work() {
    fmt.Println("working...")
}

var s *singleton = new(singleton)

func GetInstance() Instance {
    return s
}

3.2 懒汉模式

3.2.1 懒汉模式v1

这个版本就是错的,存在并发问题。

为啥有并发问题?

设想单例还没有被创建,A,B两个协程同时尝试创建单例,都发现s == nil,于是都创建了单例。单例 却被 重复创建,很显然是不行的。会导致内存泄露。

golang 复制代码
type Instance interface {
    Work()
}

type singleton struct{}

// singleton类实现Instance接口
func (s *singleton) Work() {
    fmt.Println("working...")
}

var s *singleton

func GetInstance() Instance {
    if s == nil {
        s = new(singleton)
    }
    return s
}

3.2.2 懒汉模式v2

那简单啊,加锁呗。但是你要知道,单例虽然只被创建一次,但是会被获取很多次,每次获取都要有锁竞争,性能又出问题。

golang 复制代码
type Instance interface {
    Work()
}

type singleton struct{}

// singleton类实现Instance接口
func (s *singleton) Work() {
    fmt.Println("working...")
}

var s *singleton

var lock sync.Mutex

func GetInstance() Instance {
    lock.Lock()
    defer lock.Unlock()
    
    if s == nil {
        s = new(singleton)
    }
    return s
}

3.2.3 懒汉模式v3

你说,那么既然大多数情况下实例已经被创建了,那我们就先判断呗,如果实例已经存在,就不加锁,直接返回。这样多数情况下不会加锁,岂不美哉?

但现实并非如此,这样仍然可能有并发问题:

  • 时刻1: A 和 B两个 Goroutine 同时尝试获取实例, 都发现 s==nil,于是继续执行。
  • 时刻2: A率先获取到锁,正常创建实例。B 没获取到锁,等待锁。
  • 时刻3: A释放锁。
  • 时刻4: B获取到锁,开始创建实例。(啊哦~,实例还是被创建了2次捏)
  • 时刻5: B释放锁。
golang 复制代码
type Instance interface {
    Work()
}

type singleton struct{}

// singleton类实现Instance接口
func (s *singleton) Work() {
    fmt.Println("working...")
}

var s *singleton

var lock sync.Mutex

func GetInstance() Instance {
    if s != nil {
        return s
    }

    lock.Lock()
    defer lock.Unlock()
    
    s = new(singleton)
    return s
}

3.2.4 懒汉模式v4

正确的终于来了,双重检查锁!

基于v3的并发问题的场景,在v4就不会存在。

  • 时刻1: A 和 B两个 Goroutine 同时尝试获取实例, 都发现 s==nil,于是继续执行。
  • 时刻2: A率先获取到锁,正常创建实例。B 没获取到锁,等待锁。
  • 时刻3: A释放锁。
  • 时刻4: B获取到锁,第二次检查,发现实例已经存在,不会创建实例。(That's the difference!)
  • 时刻5: B释放锁。
golang 复制代码
type Instance interface {
    Work()
}

type singleton struct{}

// singleton类实现Instance接口
func (s *singleton) Work() {
    fmt.Println("working...")
}

var s *singleton

var lock sync.Mutex

func GetInstance() Instance {
    // 第一次检查
    if s != nil {
        return s
    }

    lock.Lock()
    defer lock.Unlock()
    // 第二次检查
    if s == nil {
        s = new(singleton)
    }
    
    return s
}

4. Go语言内置sync.Once()单例模式实现

golang 复制代码
type Once struct {
	done atomic.Uint32
	m    Mutex
}

func (o *Once) Do(f func()) {
    if o.done.Load() == 0 { // first check
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done.Load() == 0 { // double check
        defer o.done.Store(1)
        f()
    }
}

也是一种「双重检查锁」的实现方式。先在外部检查一次,如果32位无符号整数done为1,则说明函数f已经被执行。如果32位无符号整数done为0, 则说明函数f还没有被执行,则加锁执行函数,并更新done的值为1.

4.1 为啥要用atomic.Uint32?

Go标准库的思想还是「双重检查锁」的思想,但是,为啥要用atomic.Uint32而不是uint32,你想过没?

我还真想过。没人给我解答,这就是一个人学习的迷茫。

uint32 代码如下:

golang 复制代码
type Once struct {
    done uint32
    m    Mutex
}

func (o *Once) Do(f func()) {
    if done == 0 {
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if done == 0 {
        done = 1
        f()
    }
}

DeepSeek 给了我一个解释:

在极端情况下,如果没有原子操作,编译器和CPU可能对指令重排。例如:

  1. doSlow中,done = 1的写入被重排到解锁(o.m.Unlock())之后。
  2. 另一个goroutine可能在done = 1生效前获取锁,并再次执行f()

虽然Go的Mutex本身会插入内存屏障,但用户代码中done的读写未受保护,仍存在理论上的风险。

但是他说得对不对,我不好说,欢迎大家讨论。

5. 参考

  1. 公众号,小徐先生的编程世界:mp.weixin.qq.com/s/KRgNwJt1C...
  2. 刘丹冰,Easy设计模式:www.bilibili.com/video/BV1Eg...
相关推荐
骊山道童7 小时前
设计模式-外观模式
设计模式·外观模式
找了一圈尾巴7 小时前
设计模式(结构型)-享元模式
设计模式·享元模式
小马爱打代码9 小时前
设计模式:迪米特法则 - 最少依赖,实现高内聚低耦合
设计模式·迪米特法则
骊山道童9 小时前
设计模式-观察者模式
观察者模式·设计模式
自在如风。13 小时前
Java 设计模式:组合模式详解
java·设计模式·组合模式
cccccchd13 小时前
23种设计模式生活化场景,帮助理解
设计模式
未定义.22113 小时前
Java设计模式实战:装饰模式在星巴克咖啡系统中的应用
java·开发语言·设计模式·软件工程
blackA_14 小时前
Java学习——day29(并发控制高级工具与设计模式)
java·学习·设计模式
Antonio91515 小时前
【设计模式】适配器模式
设计模式·oracle·适配器模式