Go同步原语之sync/Once

本文详细介绍了Go语言中的sync.Once对象如何确保某个动作仅执行一次,以及如何在初始化资源、单例模式和并发场景中使用。通过实例展示了sync.Once配合全局变量、包级别变量和互斥锁的用法,并强调了sync.Once在并发环境下的正确使用方法。
摘要由CSDN通过智能技术生成

基本用法

sync.Once 官方描述 Once is an object that will perform exactly one action, 即 sync.Once 是一个对象,它提供了保证某个动作只被执行一次功能。何处需要保障某个动作只执行一次呢?这让我们想起了资源的初始化,这些资源的初始化往往使用单例模式。

image-20230904111249267

单例模式是一种设计模式,它确保一个类仅有一个实例,并提供一个全局访问点来获取该实例。这样可以确保在整个程序中只有一个共享的实例,以避免不必要的对象创建和节省资源。

单例模式通常用于管理全局状态、数据库连接池、线程池等需要全局唯一性的场景。

Go 语言中,全局变量会在程序启动时自动初始化。因此,如果在定义全局变量时给它赋值,则对象的创建也会在程序启动时完成,可以通过此来实现单例模式,以下是一个示例代码:

type MySingleton struct {
    // 字段定义
}

var mySingletonInstance = &MySingleton{
    // 初始化字段
}

func GetMySingletonInstance() *MySingleton {
    return mySingletonInstance
}

在上面的代码中,我们定义了一个全局变量 mySingletonInstance 并在定义时进行了赋值,从而在程序启动时完成了对象的创建和初始化。在 GetMySingletonInstance 函数中,我们可以直接返回全局变量 mySingletonInstance,从而实现单例模式。

我们可以使用 init 函数来实现单例模式。init 函数是在包被加载时自动执行的函数,因此我们可以在其中创建并初始化单例对象,从而保证在程序启动时就完成对象的创建。以下是一个示例代码:

package main

type MySingleton struct {
    // 字段定义
}

var mySingletonInstance *MySingleton

func init() {
    mySingletonInstance = &MySingleton{
        // 初始化字段
    }
}

func GetMySingletonInstance() *MySingleton {
    return mySingletonInstance
}

在上面的代码中,我们定义了一个包级别的全局变量 mySingletonInstance,并在 init 函数中创建并初始化了该对象。在 GetMySingletonInstance 函数中,我们直接返回该全局变量,从而实现单例模式。

当然我们也可以使用使用了Go语言的sync.Mutex来实现单例模式,以下是一个示例代码:

package main

import (
	"fmt"
	"sync"
)

type Singleton struct {
	data int
}

var (
	once     sync.Mutex
	instance *Singleton
)

func GetInstance() *Singleton {
	if instance == nil {
		once.Lock()
		defer once.Unlock()
		if instance == nil {
			instance = &Singleton{data: 42}
		}
	}
	return instance
}

func main() {
	// 获取单例实例
	singleton1 := GetInstance()
	fmt.Println("Singleton 1 data:", singleton1.data)

	// 再次获取单例实例,应该是同一个实例
	singleton2 := GetInstance()
	fmt.Println("Singleton 2 data:", singleton2.data)

	// 验证两个实例是否相同
	if singleton1 == singleton2 {
		fmt.Println("Singleton instances are the same.")
	} else {
		fmt.Println("Singleton instances are different.")
	}
}

在上面的示例中,我们仍然使用了包级别的变量 instance 来保存单例实例,并使用 sync.Mutex 来保证在并发情况下只有一个 goroutine 能够创建实例。尽管这种方法可以工作,但它引入了额外的互斥锁,可能会对性能产生一些影响。

在实际应用中,使用 sync.Once 仍然是更常见和更优雅的选择。

sync.Once 对外只提供了一个函数:

func (o *Once) Do(f func()) {...}

我们只需要调用它即可使用,先来看一个例子,来说明 sync.Once 的特性,即保证某个动作只被执行一次功能:

import (
	"fmt"
	"sync"
)

func f1()  {
	fmt.Println("This is f1 fun\r\n")
}

func f2()  {
	fmt.Println("This is f2 fun\r\n")
}

func f3()  {
	fmt.Println("This is f3 fun\r\n")
}

func main() {
	var once sync.Once
	once.Do(f1)
	once.Do(f2)
	once.Do(f3)
}

#执行结果
This is f1 fun

上面代码使用 once.Do调用了 函数 f1f2f3但结果只有 f1 函数被执行了,f2f3 函数都被忽略了。

你看,sync.Once 的使用场景很明确。常常用来初始化单例资源,或者并发访问只需初始化一次的共享资源,或者在测试的时候初始化一次测试资源。

下面代码是经过 sync.Once 改写过的单例模式:

package main

import (
	"fmt"
	"sync"
)

// Singleton 是一个单例结构
type Singleton struct {
	data int
}

var instance *Singleton
var once sync.Once

// GetInstance 用于获取单例实例
func GetInstance() *Singleton {
	once.Do(func() {
		instance = &Singleton{data: 42}
	})
	return instance
}

func main() {
	// 获取单例实例
	singleton1 := GetInstance()
	fmt.Println("Singleton 1 data:", singleton1.data)

	// 再次获取单例实例,应该是同一个实例
	singleton2 := GetInstance()
	fmt.Println("Singleton 2 data:", singleton2.data)

	// 验证两个实例是否相同
	if singleton1 == singleton2 {
		fmt.Println("Singleton instances are the same.")
	} else {
		fmt.Println("Singleton instances are different.")
	}
}

在上面的示例中,我们定义了一个名为 Singleton 的结构,表示单例对象。通过 GetInstance 函数来获取单例实例。在 GetInstance 函数中,我们使用 sync.Once 来确保 instance 只会被创建一次。这样,在多次调用 GetInstance 时,都会返回相同的实例。

结构体

sync.Once 结构体很简单,如下:

type Once struct {
	done uint32
	m    Mutex
}

主要字段解释如下:

  • done 用于判定函数是否执行。值为1,则表示函数已经执行过;如果为 0,则表示未执行过;

  • m 是一个互斥锁, 说明 sync.Once 采取了锁的方式保证了同步性。

源码分析

sync.Once 对外提供的一个方法:

func (o *Once) Do(f func()) {
    //加载done值,如果值为1,则直接结束,如果值为0,则进入 doSlow函数
	if atomic.LoadUint32(&o.done) == 0 {
		o.doSlow(f)
	}
}

其实在 Go源码注释中,还提供了一种未使用锁得错误使用方法:

if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    f()
}

直接通过 atomic包的CompareAndSwapUint32 方法更改 done值,虽然这个能确保操作是原子操作,但是最大的问题是如果并发调用,一个 goroutine 执行,另外一个不会等正在执行的这个成功之后返回,而是直接就返回了,这就不能保证传入的方法一定会先执行一次了。

为了优化性能,所以引进了atomic.LoadUint32 + doSlow 方法,将慢路径(slow-path)代码从 Do 方法中分离出来,使得 Do 方法的快路径(fast-path)能够被内联(inlined),从而提高性能。

来看 doSlow 函数:

func (o *Once) doSlow(f func()) {
	o.m.Lock() //加锁
	defer o.m.Unlock() //函数执行完解锁
    //如果 o.done值为0,则调用执行传入的f函数,然后更改o.done为1
	if o.done == 0 {
		defer atomic.StoreUint32(&o.done, 1)
		f()
	}
}

一个正确的 sync.Once 实现要使用一个互斥锁,这样初始化的时候如果有并发的 goroutine,就会进入doSlow 方法。互斥锁的机制保证只有一个 goroutine 进行初始化,同时利用双检查的机制(double-checking),再次判断 o.done 是否为 0,如果为 0,则是第一次执行,执行完毕后,就将 o.done 设置为 1,然后释放锁。

为什么要存在两次对 done 的值的判断?

  • 第一次检查:在获取锁之前,先使用原子加载操作 atomic.LoadUint32 检查 done 变量的值,如果 done 的值为 1,表示操作已执行,此时直接返回,不再执行 doSlow 方法。这一检查可以避免不必要的锁竞争。
  • 第二次检查:获取锁之后,再次检查 done 变量的值,这一检查是为了确保在当前协程获取锁期间,其他协程没有执行过 f 函数。如果 done 的值仍为 0,表示 f 函数没有被执行过。

通过双重检查,可以在大多数情况下避免锁竞争,提高性能。

总结

来通过几个问题,来引入一些总结性的注意点:

  1. sync.Once()方法传入的函数中再次调用sync.Once()方法会有什么问题吗?

如下代码:

func main() {
   once := sync.Once{}
   once.Do(func() {
      once.Do(func() {
         fmt.Println("init...")
      })
   })
}

通过分析 sync.Once 的源码,可以看到它包含一个名为 m 的互斥锁字段。当我们在 Do 方法内部重复调用 Do 方法时,将会多次尝试获取相同的锁。但是 mutex 互斥锁并不支持可重入操作,因此这将导致死锁现象。

  1. sync.Once()方法中传入的函数发生了panic,重复传入还会执行吗?

    如下代码:

    func panicDo()  {
     once := &sync.Once{}
     defer func() {
      if err := recover();err != nil{
       once.Do(func() {
        fmt.Println("run in recover")
       })
      }
     }()
     once.Do(func() {
      panic("panic i=0")
     })
    }
    

    sync.Once.Do 在执行的过程中如果 f 出现 panic,后面也不会再执行了;所以不会打印任何东西,sync.Once.Do 方法中传入的函数只会被执行一次,哪怕函数中发生了 panic

  2. 下面函数会打印出什么呢?

    如下代码:

    func nestedDo()  {
     once1 := &sync.Once{}
     once2 := &sync.Once{}
     once1.Do(func() {
      once2.Do(func() {
       fmt.Println("test")
      })
     })
    }
    

    sync.Once 保证了传入的函数只会执行一次,所以 打印 test

    once1once2是两个对象,互不影响。所以sync.Once是使方法只执行一次对象的实现。

参考资源:

晁岳攀(鸟窝) https://time.geekbang.org/column/intro/100061801

mohuishou https://lailin.xyz/post/go-training-week3-once.html

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值