Golang中Sync.Once与单例初始化方式的对比

Golang中Sync.Once与单例初始化方式的对比 我需要获取数据库的单例实例。为此,我编写了以下代码:

func getDb() map[string]int {
	if db == nil {
		lock.Lock()
		if db == nil {
			db = map[string]int{}
		}
		lock.Unlock()
	}
	return db
}

还有另一种使用 sync.Once 的方法:

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()
	}
}

我应该使用哪一种? atomic.LoadUint32(&o.done) == 0 的计算成本是否比 db == nil 更高?


更多关于Golang中Sync.Once与单例初始化方式的对比的实战教程也可以访问 https://www.itying.com/category-94-b0.html

7 回复

非常感谢大家, 我已经得到了答案。 smile

更多关于Golang中Sync.Once与单例初始化方式的对比的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


这两个示例做的事情并不相同。一个是现成的解决方案,而第一个示例是手工实现的。如果需要多次使用……或许使用 Once 更好。

嗯,如果130纳秒对你的用例来说很关键……那就用第一个方案,否则我倾向于使用“现成”的解决方案;但这只是我的个人想法。

但是,基准测试工具却给出了不同的结果 -

Screenshot 2023-11-04 at 11.40.06 PM

基准测试工具显示两者性能相近

你好 @Harshit_Soni

使用 sync.Once 来创建单例,这是符合 Go 语言习惯的做法。

你为什么要关心这种简单且很少使用的操作有多快?通常在你的应用程序中,你只需要创建一次数据库连接,为什么要在意它是否多花了 10% 的时间?如果代码更易于阅读(和维护),通常值得选择性能稍差的算法。

你的基准测试只显示了非常微小的差异,如果运行更长时间,甚至可能显示相反的结果。选择 sync.Once 似乎是一个简单的决定。

嗯,如果对于你的使用场景来说,130纳秒至关重要……那就用第一个解决方案,否则我倾向于使用“开箱即用”的解决方案;但这只是我的想法。

确实。如果我没记错的话,我们讨论的是0.00000013秒,而这项操作根据定义只会执行一次。根据具体的使用场景,这或许也是一个适合使用init函数的候选方案。这样你就可以完全排除并发问题,这个db属性在你的应用启动时就已就绪。

原子操作是底层内存原语,根据我的使用和理解,它们的性能相当不错。我在想,那个微小的性能差距是否是由defer o.m.Unlock()引起的?也可能是因为使用了多个函数/闭包而产生了极小的开销。无论如何,这都无关紧要,因为我可以向你保证,连接到数据库并进行身份验证所花费的时间,将比你使用sync.Once可能带来的任何性能损失高出几个数量级。

在Go中,sync.Once是专门为单次初始化设计的标准库方案,相比手动实现的双重检查锁(DCL)更可靠。以下是具体分析:

1. 正确性对比 你的DCL实现存在数据竞争问题:db == nil的第一次检查没有加锁,多个goroutine可能同时通过检查,导致多次初始化。虽然后续加锁检查能防止重复赋值,但竞态条件可能引发未定义行为。sync.Once通过原子操作和互斥锁的组合保证了绝对的单次执行。

2. 性能分析 atomic.LoadUint32的成本与db == nil相当,都是单次内存读取。但sync.Once在初始化完成后会通过原子加载快速路径返回,避免锁开销。示例:

var (
    db   map[string]int
    once sync.Once
)

func getDb() map[string]int {
    once.Do(func() {
        db = make(map[string]int)
        // 初始化代码...
    })
    return db
}

3. 内存顺序保证 sync.Once使用defer atomic.StoreUint32(&o.done, 1)确保初始化函数对内存的修改对其他goroutine可见,这是手动实现容易忽略的细节。

4. 错误处理 sync.Once确保即使初始化函数panic,也标记为已执行,避免重复执行失败代码:

var config *Config
var once sync.Once

func loadConfig() {
    once.Do(func() {
        config = &Config{}
        if err := config.Load(); err != nil {
            panic(err) // 后续调用不会重复执行
        }
    })
}

结论:始终使用sync.Once。它是标准库经过验证的解决方案,避免了手动实现DCL时容易出现的竞态条件和内存可见性问题。性能上两者差异可以忽略,但正确性上有本质区别。

回到顶部