[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的读写未受保护,仍存在理论上的风险。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值