「Golang」sync.Once用法以及源码讲解

前言

在我们开发过程中经常会使用到单例模式这一经典的设计模式,单例模式可以帮助开发者针对某个(些)变量或者对象或者函数(方法)进行在程序运行期间只有一次的初始化或者函数调用操作,比如在开发项目中针对某一类连接池的初始化(如数据库连接池等)。针对这种情况,我们就需要使用单例模式进行操作。

单例模式🌰

自己搞得单例模式

要实现一个单例模式,我们会很快就想到了在一个结构体中放置一个flag字段用于标记当前的函数是否被执行过,举个🌰:

golang 复制代码
type SingletonPattern struct {
	done bool
}

func (receiver *SingletonPattern) Do(f func())  {
	if !receiver.done {
		f()
		receiver.done=true
	}
}

看似很美好,但是此时,如果传入的需要调用的函数f()会执行很长时间,比如数据库查询或者做一些连接什么的,当别的goroutine运行到此处的时候由于还没有执行完f(),就会发现done标记仍然是false,那么仍然会调用一次f(),此时就违背了单例模式的初衷。

那么如何解决上面的并发的问题呢。此时就可以使用go标准库中所提供的并发原语---sync.Once

标准库真香系列之sync.Once 话不多说先上sync.Once 结构体的源代码:

golang 复制代码
type Once struct {
	// 标记符号,用于标记是否执行过
	done uint32
	// 互斥锁,用于保护并发调用以及防止copy
	m    Mutex
}

结构体就这么简单,字段done用于标记是否执行过函数,至于为什么使用uint32类型,作者的理解是为了之后使用atomic操作做的妥协,m字段值用于保护并发情况下的情形,并且由于继承了Locker接口可以通过vet校验到其是否被复制

接下来看一下用于执行函数调用的Do()函数的实现:

golang 复制代码
func (o *Once) Do(f func()) {
	// 原子获取当前 done 字段是否等于0
	// 如果当前字段等于1 
	// 则代表已经 执行过
	// 这是第一层校验
	if atomic.LoadUint32(&o.done) == 0 {
	// 如果为0则代表没被调用过则调用
	// 此处写成一个函数的原因是为了
	// 进行函数内联提升性能
		o.doSlow(f)
	}
}

func (o *Once) doSlow(f func()) {
	// 此处加锁用于防止其他goroutine同时访问调用
	o.m.Lock()
	defer o.m.Unlock()
	// 二次校验
	// 为的是防止多个goroutine进入此函数的时候,可能发生的重复执行 f()
	if o.done == 0 {
		// 函数执行结束设置done 字段为 1代表已经执行完毕
		defer atomic.StoreUint32(&o.done, 1)
		// 执行
		f()
	}
}

此时,sync.Once 的所有源代码已经解析完毕了(惊不惊喜,意不意外),其实sync.Once 的过程很简单,就是根据标记进行双重判断确定函数是否执行过,没执行就执行,执行了就跳过。

sync.Once 的使用问题 哪来的deadlock? sync.Once 的确很简单,使用也很简单,但是还是会有使用上可能出现的一些问题比如下列代码:

golang 复制代码
func main() {
	var once sync.Once
	once.Do(
		func() {
			fmt.Println("one once do")
			once.Do(
				func() {
					fmt.Println("second once do")
				})
		})
}

该代码会出现什么问题?答案是:

fatal error: all goroutines are asleep - deadlock!

为什么会这样?因为内层个Do是被外层的同一个once对象所调用,由于此时已经进入了第一个Do并且已经调用了函数,那么此时sync.Once 中的互斥锁字段,已经被加了锁,此时二次加锁就会产生死锁。因此使用sync.Once 最重要的一点就是:

不要在执行函数中,嵌套当前的sync.Once 对象
不要在执行函数中,嵌套当前的sync.Once 对象
不要在执行函数中,嵌套当前的sync.Once 对象。
(重要的话要说三遍)

哪来的invalid memory address or nil pointer dereference?

看一下下面的代码:

golang 复制代码
func main() {
	var once sync.Once
	var conn net.Conn
	once.Do(
		func() {
			var err error
			conn, err = net.Dial("tcp", "")
			if err != nil {
				return
			}
		})
	conn.RemoteAddr()
}

在运行时,会出现:

panic: runtime error: invalid memory address or nil pointer dereference

为什么?因为sync.Once只保证执行一次,但是不保证执行是否出错 ,即我只管调用,出错了跟我无关,上述代码中

golang 复制代码
conn, err = net.Dial("tcp", "")

必定出现err!=nil的情况,此时如果不对conn变量进行判断为nil,就会出现空指针异常,那么,如何来保证他执行成功了呢,我们需要对其进行改造

golang 复制代码
type Once struct {
	once sync.Once
}

func (receiver *Once) OnceDo(f func() error) error {
	var err error
	receiver.once.Do(
		func() {
			err = f()
		})
	return err
}

func main() {
	var once Once
	var conn net.Conn
	err := once.OnceDo(
		func() error {
			var err error
			conn, err = net.Dial("tcp", "")
			if err != nil {
				return err
			}
			return nil
		})
	if err != nil {
		log.Fatal(err)
	}
}

经过封装,我们就可以得到sync.Once 执行时是否出错,以适配各种错误处理。

此封装可能会有更好的解决方案,上面的方案也仅仅是一个🌰罢了。

总结

至此sync.Once 的用法以及源码解析就完成了,可能有些地方有些理解上的错误,请各位谅解并且帮忙指出修改意见,如果这篇文章能帮到你,这是我的荣幸。

相关推荐
DemonAvenger21 小时前
深入剖析 sync.Once:实现原理、应用场景与实战经验
分布式·架构·go
一个热爱生活的普通人2 天前
Go语言中 Mutex 的实现原理
后端·go
孔令飞2 天前
关于 LLMOPS 的一些粗浅思考
人工智能·云原生·go
小戴同学2 天前
实时系统降低延时的利器
后端·性能优化·go
Golang菜鸟3 天前
golang中的组合多态
后端·go
Serverless社区3 天前
函数计算支持热门 MCP Server 一键部署
go
Wo3Shi4七3 天前
二叉树数组表示
数据结构·后端·go
网络研究院3 天前
您需要了解的有关 Go、Rust 和 Zig 的信息
开发语言·rust·go·功能·发展·zig
27669582923 天前
拼多多 anti-token unidbg 分析
java·python·go·拼多多·pdd·pxx·anti-token
程序员爱钓鱼4 天前
Go 语言邮件发送完全指南:轻松实现邮件通知功能
后端·go·排序算法