「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 的用法以及源码解析就完成了,可能有些地方有些理解上的错误,请各位谅解并且帮忙指出修改意见,如果这篇文章能帮到你,这是我的荣幸。

相关推荐
心月狐的流火号1 小时前
分布式锁技术详解与Go语言实现
分布式·微服务·go
一个热爱生活的普通人3 小时前
使用 Makefile 和 Docker 简化你的 Go 服务部署流程
后端·go
HyggeBest19 小时前
Golang 并发原语 Sync Pool
后端·go
来杯咖啡19 小时前
使用 Go 语言别在反向优化 MD5
后端·go
郭京京1 天前
redis基本操作
redis·go
郭京京1 天前
go操作redis
redis·后端·go
你的人类朋友2 天前
说说你对go的认识
后端·云原生·go
用户580559502102 天前
channel原理解析(流程图+源码解读)
go
HiWorld2 天前
Go源码学习(基于1.24.1)-slice扩容机制-实践才是真理
go
程序员爱钓鱼2 天前
Go语言实战案例-Redis连接与字符串操作
后端·google·go