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

相关推荐
架构师那点事儿4 小时前
golang 用unsafe 无所畏惧,但使用不得到会panic
架构·go·掘金技术征文
于顾而言20 小时前
【笔记】Go Coding In Go Way
后端·go
qq_1728055920 小时前
GIN 反向代理功能
后端·golang·go
follycat1 天前
2024强网杯Proxy
网络·学习·网络安全·go
OT.Ter1 天前
【力扣打卡系列】单调栈
算法·leetcode·职场和发展·go·单调栈
探索云原生1 天前
GPU 环境搭建指南:如何在裸机、Docker、K8s 等环境中使用 GPU
ai·云原生·kubernetes·go·gpu
OT.Ter1 天前
【力扣打卡系列】移动零(双指针)
算法·leetcode·职场和发展·go
码财小子2 天前
k8s 集群中 Golang pprof 工具的使用
后端·kubernetes·go
明月看潮生5 天前
青少年编程与数学 02-003 Go语言网络编程 04课题、TCP/IP协议
青少年编程·go·网络编程·编程与数学
明月看潮生6 天前
青少年编程与数学 02-003 Go语言网络编程 03课题、网络编程协议
青少年编程·go·网络编程·编程与数学