Go单例模式实现sync.Once与双重检查
作者:白话机器学习
本篇讲解Go语言单例模式的实现,使用sync.Once保证并发安全初始化(原子Done标志加Mutex双重保险),对比双重检查锁、init函数、sync.Once三种方案差异,分享Once.Do中panic导致永久阻塞的踩坑经验。
开篇故事
上个月重构配置中心客户端,全局只需要一个ConfigClient实例,连接etcd集群拉配置。我当时随手写了双重检查锁的代码,自认为很标准。压测时开了50个goroutine同时拿配置客户端,偶尔出现空指针panic。
排查了一晚上发现双重检查锁的写法有微妙问题。Go的内存模型下,指针赋值不是原子操作,没有Happens Before关系保证时,其他goroutine可能看到部分初始化的对象。后来改用sync.Once,问题消失。配置客户端初始化耗时大概200毫秒,50个goroutine同时进来,只有一个能真正执行初始化,其他49个阻塞等待。
这篇把sync.Once的原理讲清楚,再对比几种单例实现方式的差异。
一、sync.Once实现原理
sync.Once是标准库提供的并发安全单次执行工具。核心是两个字段,一个atomic.Bool类型的done标志,一个sync.Mutex互斥锁。
package singleton
import (
"sync"
)
// Config 全局配置单例
type Config struct {
AppName string
Version string
Timeout int
}
var (
instance *Config
once sync.Once
)
// GetInstance 获取单例实例
// sync.Once保证初始化只执行一次,并发安全
func GetInstance() *Config {
// Do方法内部先检查done标志
// done为true直接返回,不加锁
once.Do(func() {
// 这里执行初始化逻辑
// 只会有一个goroutine执行到这里
instance = &Config{
AppName: "my-app",
Version: "v1.0.0",
Timeout: 30,
}
})
return instance
}
sync.Once.Do的实现逻辑拆开看是这样。先原子检查done标志,已经完成直接返回。没完成就加锁,再次检查done(双重检查),还是没完成就执行函数,执行完设置done标志。
package sync
import (
"sync/atomic"
)
// Once 标准库sync.Once的简化实现
type Once struct {
done atomic.Bool
m Mutex
}
// Do 核心方法
func (o *Once) Do(f func()) {
// 快速路径: 原子检查done标志
// 已完成则直接返回,无需加锁
if o.done.Load() {
return
}
// 慢速路径: 加锁后双重检查
o.doSlow(f)
}
// doSlow 加锁路径,处理真正初始化
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
// 双重检查,防止多个goroutine同时通过快速路径
if !o.done.Load() {
// 延迟设置done,确保f执行完成后才标记完成
defer o.done.Store(true)
f()
}
}
done标志是atomic.Bool类型,不是普通bool。atomic保证读写操作的可见性,一个goroutine设置done后,其他goroutine立刻能看到。普通bool在多核CPU上可能读到旧值,导致初始化被执行多次。
注意done标志的设置在defer里。这是关键设计,f执行完成(包括正常返回和panic)后才标记done为true。保证初始化要么完整完成,要么没完成。
二、双重检查锁实现
双重检查锁(Double-Checked Locking)是单例模式的经典实现。第一次检查不加锁,避免每次调用都加锁的性能开销。第二次检查加锁后再次确认,防止多个goroutine同时通过第一次检查。
package singleton
import (
"sync"
"sync/atomic"
"unsafe"
)
// Database 数据库连接单例
type Database struct {
url string
}
var (
dbInstance *Database
dbMu sync.Mutex
)
// GetDB 双重检查锁实现单例
// 比直接加锁快,读多写少场景优势明显
func GetDB() *Database {
// 第一次检查: 不加锁
// 用atomic.LoadPointer保证读到完整的指针值
ptr := atomic.LoadPointer(
(*unsafe.Pointer)(unsafe.Pointer(&dbInstance)),
)
if ptr != nil {
// 已初始化,直接返回
return (*Database)(ptr)
}
// 加锁准备初始化
dbMu.Lock()
defer dbMu.Unlock()
// 第二次检查: 防止多个goroutine同时通过第一次检查
if dbInstance == nil {
// 真正的初始化逻辑
dbInstance = &Database{
url: "mysql://root:pass@127.0.0.1:3306/db",
}
}
return dbInstance
}
双重检查锁在Go里有个坑。直接读dbInstance指针不是原子操作,编译器和CPU都可能重排指令,其他goroutine可能看到指针已赋值但对象字段未初始化完的状态。用atomic.LoadPointer读指针,用atomic.StorePointer写指针,才能保证内存顺序。
Go里更推荐用sync.Once,标准库已经处理了这些内存顺序问题,代码更简洁。双重检查锁理解原理就好,生产代码直接用sync.Once。
三、踩坑经验:Once.Do中panic导致永久阻塞
这个坑我踩过。生产环境有个缓存初始化代码,里面调用了一个外部HTTP接口拉配置。某次网络抖动,HTTP调用panic了,整个进程的初始化卡死。
现象是Once.Do之后再调用GetInstance永远返回nil。排查发现sync.Once在f执行panic时有个微妙行为。看标准库源码,done标志的设置在defer里,f panic后defer会执行,done被设置成true。但panic会继续向上传播。
问题出在recover的处理上。我们在外层recover了panic,但done已经被设置成true。后续调用Do直接返回,但instance是nil,业务代码空指针panic连环爆。
package singleton
import (
"log"
"sync"
)
var (
cacheInstance *Cache
cacheOnce sync.Once
)
// Cache 缓存单例
type Cache struct {
data map[string]string
}
// GetCache 错误示范: 在Do内部recover
func GetCache() *Cache {
cacheOnce.Do(func() {
// 在Do内部recover,问题在于
// panic被recover后done仍会被设为true
defer func() {
if r := recover(); r != nil {
log.Printf("初始化panic: %v", r)
}
}()
// 这里模拟可能panic的初始化
cacheInstance = &Cache{
data: map[string]string{
"key": "value",
},
}
// 假设这行panic了
// done会被设为true,但cacheInstance可能不完整
})
return cacheInstance
}
解决方案是用可重置的Once,初始化失败后允许重新初始化。标准库sync.Once不支持重置,自己封装一个可重试版本。
package singleton
import (
"sync"
"sync/atomic"
)
// ResettableOnce 可重置的Once
// 初始化失败后可以重置,允许重新初始化
type ResettableOnce struct {
done atomic.Bool
m sync.Mutex
}
// Do 执行f,done为false时才执行
// f返回error,不设置done,允许重试
func (o *ResettableOnce) Do(f func() error) error {
// 快速路径: 已完成直接返回
if o.done.Load() {
return nil
}
o.m.Lock()
defer o.m.Unlock()
// 双重检查
if !o.done.Load() {
// 执行初始化
if err := f(); err != nil {
// 失败,不设置done,允许重试
return err
}
// 成功才设置done
o.done.Store(true)
}
return nil
}
// Reset 重置状态,允许重新初始化
func (o *ResettableOnce) Reset() {
o.m.Lock()
defer o.m.Unlock()
o.done.Store(false)
}
// 可重置单例的使用示例
var (
configInstance *Config
configOnce ResettableOnce
)
// GetConfigWithRetry 支持重试的配置获取
func GetConfigWithRetry() (*Config, error) {
err := configOnce.Do(func() error {
// 初始化逻辑,返回error而非panic
cfg, initErr := initConfigFromRemote()
if initErr != nil {
return initErr
}
configInstance = cfg
return nil
})
if err != nil {
return nil, err
}
return configInstance, nil
}
// initConfigFromRemote 模拟从远程拉配置
func initConfigFromRemote() (*Config, error) {
return &Config{
AppName: "my-app",
Version: "v1.0.0",
Timeout: 30,
}, nil
}
关键点是初始化函数返回error而不是panic,失败不设置done标志,调用方可以重试。这样即使远程配置中心暂时不可用,恢复后下次调用能正常初始化。
四、对比分析
| 单例方案 | 并发安全 | 性能 | 可重试 | 代码复杂度 |
|---|---|---|---|---|
| sync.Once | 安全 | 高(一次原子读) | 不支持 | 低 |
| 双重检查锁 | 需正确实现 | 高 | 可实现 | 中 |
| init函数 | 安全(启动时) | 最高 | 不支持 | 最低 |
| 全局变量 | 不安全 | 最高 | 不支持 | 最低 |
init函数在包初始化时执行,只有一个goroutine,天然并发安全。但init函数不可错误恢复,panic会导致进程退出。全局变量最简单,多goroutine并发读写不安全,只在单goroutine或只读场景能用。sync.Once是生产环境的首选,标准库保证正确性,初始化完成后性能开销只有一次原子读。
总结
单例模式在Go里有几种实现方式,sync.Once是标准库提供的并发安全方案,原理是atomic标志加Mutex双重检查。双重检查锁能自己实现,但要处理内存顺序问题,容易出错。初始化逻辑可能失败时,不要用panic,改用返回error的可重试Once。下一篇我们聊函数选项模式,看看怎么让配置代码更灵活。
到此这篇关于Go单例模式实现sync.Once与双重检查的文章就介绍到这了,更多相关Go sync.Once与双重检查内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
