如何实现一次性初始化操作,sync.Once实现原理

一、使用场景

在实际的项目开发中,我们会遇到需要初始化单例资源的场景,在Go中,初始化单例资源的方法由很多,例如:

定义Package包级别的变量

go 复制代码
package onceDemo

import "time"

var startTime = time.Now()

通过定义包级别的全局变量,在程序启动时能够进行初始化。

init函数初始化

go 复制代码
package onceDemo

import "time"

var startTime time.Time

func init() {
    startTime = time.Now()
}

main函数执行初始化函数

go 复制代码
package main

import (
    "fmt"
    "time"
)

var startTime time.Time

func initConfig() {
    startTime = time.Now()
}

func main() {
    initConfig()
    fmt.Println(startTime)
}

这三种初始化的方法都是线程安全的方法,并且后两种方法可以根据实际传入的参数来定制化初始化操作

当然我们也会遇到需要进行延迟初始化的场景,在单例资源初始化时,可能会用到如下的方法:

go 复制代码
package main

import (
    "net"
    "sync"
    "time"
)

// 使用互斥锁保证线程安全
var mu sync.Mutex
var conn net.Conn

func getConn() net.Conn {
    mu.Lock()
    defer mu.Unlock()

    // 返回已创建好的连接
    if conn != nil {
       return conn
    }

    // 创建连接
    conn, _ = net.DialTimeout("tcp", "baidu.com:80", 10 * time.Second)
    return conn
}

func main() {
    // 创建连接
    conn := getConn()
    if conn == nil {
       panic("conn is nil")
    }
}

上述通过互斥锁Mutex的方式来保证初始化的线程安全,实现方式简单,但存在一定的性能问题。当连接创建好后,每次请求获取连接实例的时候,还需要竞争锁才能够读取到连接,这个过程比较浪费资源,因为连接如果创建好之后,其实就不需要锁的保护了。

在这种场景下,可以使用Go中提供的Once并发原语来解决。

二、Once介绍

在Go的并发原语中,Once可以用来执行且仅执行一次的动作,常常用于单例对象的初始化场景

具体来说,sync.Once对外只暴露一个Do方法,Do方法可以多次调用,但只有第一次调用Do方法时,f参数才会执行,后续的Do方法调用f参数都不会执行,f是一个无参数无返回值的函数

go 复制代码
func (o *Once) Do(f func())

举个例子:

go 复制代码
package main

import (
    "fmt"
    "sync"
)

func main() {
    var once sync.Once

    fun1 := func() {
       fmt.Println("fun1")
    }

    once.Do(fun1) // fun1

    fun2 := func() {
       fmt.Println("fun2")
    }

    once.Do(fun2) // 无输出
}

上述代码中,定义了fun1fun2两个不同的函数,但是对于同一个Once实例来说,第一次调用Do方法时,传入的func1函数会执行,后续第二次调用Do方法传入的fun2函数却不会执行。

这是因为同一个Once实例,当且仅当第一次调用Do方法时,参数f才会执行,即使后续调用第二次、第三次、第n次Do方法,甚至后续的每次Do方法调用时参数f都不同,参数f也不会被执行。

Do方法在使用上,也可以通过传入闭包函数的方式来引用匿名函数外部的参数 来作为Do方法的参数,因为f参数是一个无参数无返回的函数:

go 复制代码
func main() {
    var once sync.Once
    var addr = "https://golang.google.cn/"
    var conn net.Conn
    var err error
    once.Do(func() {
       conn, err = net.Dial("tcp", addr)
    })
}

在实际的使用中,绝大多数情况下都会使用闭包的方式去初始化外部的一个资源,例如:

go 复制代码
func NewStore(db *gorm.DB) *datastore {
    // 确保 S 只被初始化一次
    once.Do(func() {
       S = &datastore{db}
    })

    return S
}

另外在math/big/sqrt.go中实现的一个数据结构,它通过Once封装了一个只初始化一次的值:

go 复制代码
// 值是3.0或者0.0的一个数据结构
var threeOnce struct {
    sync.Once
    v *Float
}
  
// 返回此数据结构的值,如果还没有初始化为3.0,则初始化
func three() *Float {
    threeOnce.Do(func() { // 使用Once初始化
      threeOnce.v = NewFloat(3.0)
    })
    return threeOnce.v
}

上述代码中,threeOnce结构体将sync.Once*Float封装成一个对象,并且提供了three方法只初始化一次的值v,three方法虽然每次都调用threeOnce.Do方法,但是参数只会被调用一次。

在实际使用Once时,可以采用上述将值与Once封装成一个新的数据结构的方式,并且提供只初始化一次值的方法。

总的来说,Once并发原语解决的问题和使用场景:Once常用于初始化单例资源,或者并发访问只需初始化一次的共享资源,或者在测试的时候初始化一次测试资源

三、实现原理

Once并发原语实现上,你可能会说很简单,只需要使用一个flag标记,通过flag标记来标识是否初始化过即可,通过atomic原子操作来修改这个flag,例如:

go 复制代码
type Once struct {
    done uint32
}

func (o *Once) Do(f func()) {
    if !atomic.CompareAndSwapUint32(&o.done, 0, 1) {
       return
    }
    f()
}

上述方式虽说是一种实现Once的方式,但存在一个很大的问题,即如果参数f执行时间很长 ,则在后续调用Do方法的goroutine虽然通过CAS得到done已经设置为已执行,但在获取初始化资源时,可能会得到空资源,这是由于f还未执行完成的原因导致。

一个正确的Once实现需要使用一个互斥锁,保证在初始化时如果有并发的goroutine进入Do方法,则进入doSlow方法。

互斥锁的作用在于保证只有一个goroutine来进行初始化操作,同时利用双检查机制,再次判断once.done是否为0,如果为0,则为第一次执行,执行完毕后将once.done置为1,然后释放互斥锁。

通过使用互斥锁,即使多个goroutine同时进入doSlow方法,也会因为双检查机制而使后续进入的goroutine检查到once.done置为了1,不会再执行f。既保证了并发的goroutine会等待f完成,而且还不会多次执行f

Once原语的实现如下:

go 复制代码
type Once struct {
    done uint32
    m    Mutex
}

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

四、注意事项

在使用Once时,如果使用不当,也会造成错误,从而发生意想不到的后果。

错误一:死锁

Do方法在第一次调用时,会执行一次f,但是,如果在f中再次调用这个Once实例的Do方法,则会导致死锁的情况发生。这是由于Lock的递归调用导致的死锁。我们都知道互斥锁是不支持重入的,并且Once在实现上也使用了互斥锁。

go 复制代码
func main() {
    var once sync.Once
    once.Do(func() {
       once.Do(func() {
          fmt.Println("初始化操作")
       })
    })
}

错误二:未初始化成功

当我们调用Do方法执行f时,如果f方法执行的时候发生了panic,或者f执行初始化资源时失败了,这时Once还是会认为初次执行已经成功,即使再调用Do方法也不会再执行f

既然执行过 Once.Do 方法也可能因为函数执行失败的原因未初始化资源,并且以后也没机会再次初始化资源,那么这种初始化未完成的问题该怎么解决呢?

我们可以通过自己实现一个类似Once的并发原语,既可以返回当前调用Do方法是否正确完成,并且可以在初始化失败的时候,再次调用Do方法来再次尝试初始化,直到初始化成功才不再执行Do方法。

go 复制代码
type Once struct {
    m    sync.Mutex
    done uint32
}

// Do 传入的函数f有返回值error,如果初始化失败,则返回失败的error
// Do 方法将error返回给Do方法的调用者
func (o *Once) Do(f func() error) error {
    if atomic.LoadUint32(&o.done) == 1 { // fast path
       return nil
    }
    return o.slowDo(f)
}

func (o *Once) slowDo(f func() error) error {
    o.m.Lock()
    defer o.m.Unlock()
    var err error
    if o.done == 0 { // 双检查
       err = f()
       if err == nil { // 如果f执行成功,则将done置为1,初始化成功才将标记置为已初始化
          atomic.StoreUint32(&o.done, 1)
       }
    }
    return err
}

上述代码中,为Do方法以及参数都增加error返回参数,如果f执行失败,则会将这个error返回给调用者。并且在slowDo方法中,如果f调用失败了,并不会将标记为done进行更改,以便后续能够的goroutine能够再次执行goroutine。直到f执行成功后,才会将done的值修改为1。

五、总结

在Go中,并发原语Once以用来执行且仅仅执行一次动作,常常用于单例对象的初始化场景。

sync.Once只暴露了一个方法 Do,可以多次调用 Do 方法,但是只有第一次调用 Do 方法时 f 参数才会执行。

一旦遇到只需要初始化一次的场景,首先想到的就应该是 Once 并发原语。

参考文章

time.geekbang.org/column/arti...

相关推荐
疯一样的码农3 小时前
基于Spring Boot + Vue3实现的在线商品竞拍管理系统源码+文档
java·spring boot·后端
Y编程小白4 小时前
SpringBoot的pom.xml文件中,scope标签有几种配置?
xml·spring boot·后端
健康平安的活着5 小时前
springboot整合log4j2日志框架1
spring boot·后端·log4j
是一只派大鑫5 小时前
从头开始学MyBatis—04缓存、逆向工程、分页插件
java·后端·mybatis
GoGeekBaird5 小时前
69天探索操作系统-第25天:文件系统基础
后端·操作系统
技术小泽6 小时前
代码思想之快慢路径
java·开发语言·spring boot·后端·设计规范
*长铗归来*6 小时前
ASP.NET Core Web API Hangfire
后端·c#·asp.net·.netcore
江东飞过6 小时前
.net core 的文件操作
开发语言·后端·golang
久久不觉6 小时前
.net core 的字符串处理
开发语言·后端·golang
久久不觉6 小时前
.net core 的软件开发工具
开发语言·后端·golang