likes
comments
collection
share

探索 Go sync.Once 的实现原理Go 语言中 sync.Once 的实现原理、注意事项和常见应用,包括原子操作

作者站长头像
站长
· 阅读数 16

前言

在代码开发过程中,我们有时需要一个对象只有一个实例,例如: 某个数据库的客户端实例, 某个全局配置等。这也是我们常说的单例模式,实现单例模式有两种方式:饿汉模式、 懒汉模式。

饿汉模式:通常在服务启动时就完成了单例的初始化工作,在 GO 中通常都是在 main 函数或 init 函数里进行初始化工作, main函数 、 Init 函数天然就支持只初始化一次的要求。

懒汉模式:通常是单例被使用时才被初始化,但是在程序的整个生命周期里会多次使用单例,甚至会并发使用,所以使用懒汉模式时,需要确保单例只被初始化一次,还要保证并发安全。Go 语言中的 sync.Once 就是一个优雅且并发安全的解决方案。

sync.Once 是什么

在 Go 语言中,sync.Once是一个同步原语,用于确保某个操作只被执行一次。

package main

import (
    "fmt"
    "sync"
)

var once sync.Once

func initData() {
    fmt.Println("Initializing data...")
}

func main() {
    wg := sync.WaitGroup{}
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            once.Do(initData)
        }()
    }
    
    wg.Wait()
}

$ go run main.go
Initializing data...  // 只初始化一次

sync.Once 实现原理

Once 结构体

sync.Once 是一个结构体,里面有 done、m 两个字段:

type Once struct {
    done atomic.Uint32
    m    Mutex
}
  • done:表示该操作是否已经执行,0 代表未执行,1 代表已经执行。

  • m: 互斥锁,确保只有一个协程执行操作。

done 和 m 都不是引用类型,所以 sync.Once 被复制后就没办法保证只执行一次了。

Once.Do

Once.Do 是 sync.Once 的核心逻辑,首先 Do 会判断 done 的状态,如果 done == 0 说明第一次没有执行或者是没执行完,然后会进入 doSlow 流程,否则会直接退出,这里使用 done.Load() 来加载 done 状态的,没有使用锁是为了提升性能。

func (o *Once) Do(f func()) {
    if o.done.Load() == 0 {
       o.doSlow(f)
    }
}

之前《一文搞定 Go 原子操作》也提到过Load() 操作是原子的,但是 o.done.Load() == 0 的 if 判断并不是原子的,这里分两种情况讨论:

  1. 当 done 不等于 0 时,也就是 == 1 时,这时不再会有协程来改变 done 的状态,所以可以直接返回,速度比较快。

  2. 当 done 等于 0 时,这时可能会有多个协程去竞争改变 done 的值,所以 doSlow 里需要加锁,来保证只有一个协程去改变 done 的值,速度会较慢一些。

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done.Load() == 0 {
       defer o.done.Store(1)
       f()
    }
}

这里还有一个注意点,done.Store(1) 要在 f() 执行完后再执行,否则会出现资源还没初始化完成就被使用的情况。

官方文档也给了一个例子, 下面这种实现是错误的, 因为 Do 需要保证当它返回时,f() 已经执行完毕:

func (o *Once) Do(f func()) {
    if o.done.CompareAndSwap(0, 1) {
        f()
    }
}

sync.Once 的注意事项

sync.Once 不能被复制

sync.Once 一旦被复制,就失去只执行一次的限制,sync.Once 的实现原理是判断 Once 里面的done 的状态,Once 只能保证一个Once实例只执行一次。

package main

import (
    "fmt"
    "sync"
)

var once sync.Once

func initData() {
    fmt.Println("Initializing data...")
}

func main() {
    wg := sync.WaitGroup{}
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func() {
                        once := once // 复制一个 once
            defer wg.Done()
            once.Do(initData)
        }()
    }
    
    wg.Wait()
}

$ go run main.go
Initializing data...
Initializing data...
Initializing data...
Initializing data...
Initializing data...

sync.Once 不能嵌套使用

doSlow 里面使用了互斥锁 ,在 doSlow 没有返回前不会释放锁,doSlow如果要返回就需要执行完 f(),如果 f() 中也请求了锁就会造成相互等待, 就会出现死锁。

package main

import (
    "fmt"
    "sync"
)

var once sync.Once


func main() {
    once.Do(func(){
        once.Do(func(){
            fmt.Println("内层 once")
        })
        
        fmt.Println("外层 once")
    })

}

$ go run main.go

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_SemacquireMutex(0x579bc0, 0x4b6500, 0x1)
        /usr/local/go-1.13.5/src/runtime/sema.go:71 +0x47
sync.(*Mutex).lockSlow(0x579bbc)
        /usr/local/go-1.13.5/src/sync/mutex.go:138 +0xfc
sync.(*Mutex).Lock(...)
        /usr/local/go-1.13.5/src/sync/mutex.go:81
sync.(*Once).doSlow(0x579bb8, 0x4c96b8)
        /usr/local/go-1.13.5/src/sync/once.go:62 +0x122
sync.(*Once).Do(...)
        /usr/local/go-1.13.5/src/sync/once.go:57
main.main.func1()
        /box/main.go:13 +0xa9
sync.(*Once).doSlow(0x579bb8, 0x4c96c0)
        /usr/local/go-1.13.5/src/sync/once.go:66 +0xe3
sync.(*Once).Do(...)
        /usr/local/go-1.13.5/src/sync/once.go:57
main.main()
        /box/main.go:12 +0x4d

Exited with error status 2

sync.Once 可能执行失败

通过源码可知,无论 f() 执行的结果如何,sync.Once 都不允许执行第二遍,所以就会出现一种情况:f() 执行失败,然后想重试也重试不了的情况。所以需要再 f() 内部做一些重试、或者错处理的操作。

var client *goredis.Client
var once sync.Once

func getRedisClient() *goredis.Client {
    once.Do(func() {
        var err error
        // 重试逻辑
        for i:=0; i++; i<3 {
            client,err = goredis.NewClient(xxxx)
            if err == nil {
                return
            }
        }   
    })
    
    return client
}

sync.Once 的常见应用

sync.Once主要用于确保某个操作只执行一次,在很多场景下都非常有用。

单例模式实现

在 Go 语言中,可以使用sync.Once来实现单例模式,确保一个类型的实例在整个程序中只被创建一次。

package main

import (
    "fmt"
    "sync"
)

type singleton struct {
    data int
}

var instance *singleton
var once sync.Once

func getInstance() *singleton {
    once.Do(func() {
        instance = &singleton{data: 42}
    })
    return instance
}

func main() {
    s1 := getInstance()
    s2 := getInstance()
    fmt.Println(s1 == s2) // true,说明是同一个实例
}

懒加载资源

当某些资源的初始化比较耗时或者不是在程序启动时就立即需要,可以使用sync.Once进行懒加载。

package main

import (
    "fmt"
    "sync"
)

var resourceLoaded bool
var resourceData string
var loadResourceOnce sync.Once

func loadResource() {
    fmt.Println("Loading resource...")
    resourceData = "This is the loaded resource data."
    resourceLoaded = true
}

func getResource() string {
    if!resourceLoaded {
        loadResourceOnce.Do(loadResource)
    }
    
    return resourceData
}

func main() {
    fmt.Println(getResource())
    fmt.Println(getResource())
}

总结

  • 实现原理:

    • 原子操作保证高性能;
    • 互斥锁保证程序只执行一次,且 Do 返回前 f() 已经执行完。
  • 注意事项:

    • sync.Once 不能被复制
    • sync.Once 不能嵌套使用
    • sync.Once 可能执行失败
  • 常见应用:本质就是只执行一次

    • 单例模式
    • 懒加载
转载自:https://juejin.cn/post/7411080402272796698
评论
请登录