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可能对指令重排。例如:
- 在
doSlow
中,done = 1
的写入被重排到解锁(o.m.Unlock()
)之后。 - 另一个goroutine可能在
done = 1
生效前获取锁,并再次执行f()
。
虽然Go的Mutex
本身会插入内存屏障,但用户代码中done
的读写未受保护,仍存在理论上的风险。
但是他说得对不对,我不好说,欢迎大家讨论。
5. 参考
- 公众号,小徐先生的编程世界:mp.weixin.qq.com/s/KRgNwJt1C...
- 刘丹冰,Easy设计模式:www.bilibili.com/video/BV1Eg...