[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...
相关推荐
vker9 小时前
第 1 天:单例模式(Singleton Pattern)—— 创建型模式
java·设计模式
晨米酱1 天前
JavaScript 中"对象即函数"设计模式
前端·设计模式
数据智能老司机1 天前
精通 Python 设计模式——分布式系统模式
python·设计模式·架构
数据智能老司机1 天前
精通 Python 设计模式——并发与异步模式
python·设计模式·编程语言
数据智能老司机1 天前
精通 Python 设计模式——测试模式
python·设计模式·架构
数据智能老司机1 天前
精通 Python 设计模式——性能模式
python·设计模式·架构
使一颗心免于哀伤1 天前
《设计模式之禅》笔记摘录 - 21.状态模式
笔记·设计模式
数据智能老司机2 天前
精通 Python 设计模式——创建型设计模式
python·设计模式·架构
数据智能老司机2 天前
精通 Python 设计模式——SOLID 原则
python·设计模式·架构
烛阴2 天前
【TS 设计模式完全指南】懒加载、缓存与权限控制:代理模式在 TypeScript 中的三大妙用
javascript·设计模式·typescript