单例模式保证一个类或者结构体在整个应用程序的生命周期中只存在一个实例,并且提供一个全局的访问点来获取这个实例,主要用于管理共享资源和控制全局状态。
例如在管理配置中,程序自动读取config.yaml并且映射到config结构体中,因为配置数据在运行期间是只读且全局共享的,不需要每次使用都重新读取文件,保留一份内存副本即可。还有初始化 sql.DB 或 Redis Client,建立连接是昂贵的操作(TCP 握手等),通常希望在全局复用同一个连接池对象,而不是频繁创建和销毁。
我们都知道单例模式有饿汉和懒汉两种实现方式,下面先介绍饿汉式。
饿汉式在程序启动(包加载)阶段就立刻创建实例,不管后面用不用得到。利用 Go 的包初始化机制(全局变量初始化或 init 函数),这是天然线程安全的。在main函数执行之前,Go 的 Runtime(运行时)接管程序的初始化阶段,在这个阶段,Go Runtime 只启动了一个主 Goroutine来负责所有的初始化工作,既然只有一个Goroutine 在跑,不存在第二个 Goroutine 去争抢资源,自然也就不存在数据竞争,所以它是绝对线程安全的。
下面是示例代码:
go
package singleton
type eagerSingleton struct {
Name string
}
// 在包加载时直接实例化
// 优点:没有线程安全问题,Go 的 runtime 保证全局变量初始化是原子的。
var eagerInstance = &eagerSingleton{Name: "Eager"}
// 提供一个公开访问接口
func GetEagerInstance() *eagerSingleton {
return eagerInstance
}
优点是实现极其简单,而且不需要考虑线程安全加锁相关,性能好。缺点是如果这个对象初始化非常耗时(比如加载巨大的模型文件)或者占用大量内存,但程序运行很久后才用到(甚至完全没用到),就会造成启动慢和内存浪费。
懒汉式实例在第一次被调用 GetInstance 时才创建。这就避免了饿汉式的内存浪费问题,缺点就是第一次访问时需要同步等待初始化完成,稍微有一点点性能损耗。
go
type Singleton struct {
Data string
}
var (
instance *Singleton
mu sync.Mutex
)
func GetInstance() *Singleton {
if instance == nil {
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &Singleton{Data: "I am Singleton"}
}
}
return instance
}
在GetInstance中,第一次检测是否为nil是为了性能优化,如果实例已经被创建了,就没必要去抢锁了。直接返回实例,避免了锁竞争带来的性能损耗。如果没被创建就加锁,保证同一时刻只有一个协程能进入初始化环节。第二次检查是否为nil是为了防止重复创建,假设协程 A 和 B 同时通过了第一层检查。A 抢到了锁,创建了实例并释放锁。此时 B 拿到锁进来,如果没有第二次检查,B 就会再次创建一个新的实例,覆盖掉 A 创建的,从而破坏了单例的唯一性。
上述逻辑也可以使用sync.Once代替,如下:
go
type Singleton struct {
Data string
}
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
// once.Do 接收一个函数,该函数在整个程序生命周期中只会被执行一次
once.Do(func() {
fmt.Println("Initializing Singleton...")
instance = &Singleton{Data: "I am Singleton"}
})
return instance
}
其中once.Do()这个方法可以保证函数只会在程序的生命周期内被调用一次,所以可以确保线程安全,不会重复初始化单例。
有关sync.Once的部分也可以看这篇文章:geektutu.com/post/hpg-sy...
go
// 简化版源码分析
package sync
import (
"sync/atomic"
)
type Once struct {
done uint32
m Mutex
}
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
在once.Do()中,第一行代码 atomic.LoadUint32(&o.done)是检测快速路径的原子读取,一旦单例创建完成,后续成千上万次的调用都只会走这一行代码。原子读的性能损耗极低(纳秒级),这比 Mutex.Lock() 快得多。
如果检测到单例没有创建完成,就会走到doSlow()路径,实际上就是我们之前实现的两次检测是否为nil的流程。
接下来我们想想,有没有什么方法可以破坏单例模式呢,比如说创建两个。因为结构体是小写的 type singleton struct,在 main 包里根本无法写出 &singleton.singleton{},编译器会直接报错,而且使用sync.Once的话,核心控制字段 done 是小写的(未导出的)。你不能在外部写 once.done = 0,编译器也会报错。
看起来我们束手无策。Go 语言的编译器像一个尽职的保安,因为它看到了首字母小写的字段,就严格禁止我们在包外进行任何读写操作。按照正常的编程逻辑,只要单例初始化过一次,那个 done 标记就永远是 1,我们永远无法触发第二次初始化。
当程序运行起来后,所有的结构体、所有的私有字段,本质上都只是内存中的一段段字节序列。如果我们能绕过编译器的静态检查,直接找到存储 done 标记的那块内存,并将它强行改写为 0,sync.Once 就会失忆。下次我们再调用 GetInstance() 时,它就会以为自己从没运行过,从而再次执行初始化代码,创建出第二个实例。
这可以使用反射做到,反射允许我们在运行时动态地检查变量的类型和值,甚至窥探那些未导出的私有字段。虽然 Go 的反射库出于安全考虑,默认禁止修改私有字段(会触发 Panic),但配合 unsafe 指针操作,我们就能获得上帝视角,对内存进行修改。
go
package main
import (
"fmt"
"reflect"
"sync"
"sync/atomic"
"unsafe"
)
type singleton struct {
data string
}
type SingletonManager struct {
instance *singleton
once sync.Once
}
var Mgr = &SingletonManager{}
func (m *SingletonManager) GetInstance() *singleton {
m.once.Do(func() {
fmt.Println(">>> 单例初始化逻辑执行中...")
m.instance = &singleton{data: "UniqueInstance"}
})
return m.instance
}
func main() {
Mgr.GetInstance()
fmt.Println("第一次获取完成。\n")
fmt.Println("正在通过反射重置 sync.Once 状态...")
val := reflect.ValueOf(Mgr).Elem()
onceField := val.FieldByName("once")
doneField := onceField.FieldByName("done")
var finalField reflect.Value
if doneField.Kind() == reflect.Struct {
for i := 0; i < doneField.NumField(); i++ {
if doneField.Field(i).Kind() == reflect.Uint32 {
finalField = doneField.Field(i)
break
}
}
} else {
finalField = doneField
}
if !finalField.IsValid() {
panic("无法定位 done 标记位")
}
ptr := (*uint32)(unsafe.Pointer(finalField.UnsafeAddr()))
atomic.StoreUint32(ptr, 0)
fmt.Println("重置成功!\n")
Mgr.GetInstance()
fmt.Println("第二次获取完成(注意上方是否再次打印了初始化日志)。")
}
执行结果:
erlang
>>> 单例初始化逻辑执行中...
第一次获取完成。
正在通过反射重置 sync.Once 状态...
重置成功!
>>> 单例初始化逻辑执行中...
第二次获取完成(注意上方是否再次打印了初始化日志)。
由于 Mgr 是一个指针,我们需要先调用 .Elem() 来"解引用",拿到它指向的实际 SingletonManager 结构体,否则无法访问其内部字段。
只会我们使用了 FieldByName。即使 once 和 done 是私有字段(首字母小写),反射依然能通过名称找到它们的元数据。此时我们只能看,不能动,因为如果直接调用 Set() 方法修改它们,Go 会直接 Panic。
在 Go 1.23 之前,sync.Once 的 done 标记只是一个普通的 uint32 整数。但在 Go 1.23 及更高版本中,为了更好的语义,官方将其改为了 atomic.Uint32 结构体。我们的代码会自动检测 done 的类型,如果是结构体,它会遍历内部字段,找到那个真正存储数据的 uint32 字段。
最关键的就是ptr := (*uint32)(unsafe.Pointer(finalField.UnsafeAddr()))这一行,直接返回该字段在内存中的物理地址,将这个地址转换为通用指针,剥离了所有原有的类型保护,告诉编译器,把这块内存当成一个普通的整数指针给我处理。
最后,我们使用原子操作将该内存地址的值强制写回 0。 对于 sync.Once 来说,它判断是否初始化的唯一依据就是这块内存的值。一旦变为 0,它就"失忆"了------下次调用 Do 方法时,它会认为自己从未运行过,从而允许单例被再次创建。
如果不使用sync.Once的懒汉单例模式也可以使用类似方式破坏:
go
// 假设目标是 var instance *singleton
// 1. 获取 instance 变量的反射值
val := reflect.ValueOf(Mgr).Elem()
instanceField := val.FieldByName("instance")
// 2. 强行设置为 nil
// 注意:这里需要绕过私有检查,和之前一样使用 unsafe
ptr := (*unsafe.Pointer)(unsafe.Pointer(instanceField.UnsafeAddr()))
*ptr = nil // 归零!
// 3. 再次调用 GetInstance
// 此时 instance == nil 成立,由于是懒汉式,它会重新 new 一个对象
但是对于饿汉单例模式就不一样了,
go
func GetInstance() *singleton {
return instance //没有任何判断逻辑,直接返回变量
}
如果你把 instance 设为 nil, 调用 GetInstance() 就会返回 nil。你成功把单例搞挂了,但你没有让它重新创建一个,程序大概率会 Panic。
如果你想创建另外一个单例,你必须手动通过反射创建一个新的 singleton 对象(利用 reflect.New),然后强行把这个新对象的地址塞给全局变量 instance。