Go sync.Once 使用场景和源码解析

142 阅读4分钟

sync.once 解析

Once 描述是 Once is an object that will perform exactly one action,即 Once 是一个只执行一次的对象。

使用场景

当我们需要在多个 goroutine 中只执行一次初始化操作时,可以使用 Once 来完成。

先举一个官网给出的例子:

import (
    "fmt"
    "sync"
)

func main() {
    var once sync.Once
    onceBody := func() {
        fmt.Println("Only once")
    }
    done := make(chan bool)
    for i := 0; i < 10; i++ {
        go func() {
            once.Do(onceBody)
            done <- true
        }()
    }
    for i := 0; i < 10; i++ {
        <-done
    }
}

在这个例子中,我们创建了 10 个 goroutine,但是 onceBody 只执行了一次。即只会打印一次 Only once。

Once 的 Do 方法接收一个函数类型的参数,这个函数类型的参数就是需要保证只执行一次的那段代码。Once 会通过互斥锁和布尔值来保证只执行一次。

Once 经常被用来做单例模式,因为单例模式需要保证只执行一次初始化操作。

这里也给出一个单例模式的例子:

type singleton struct{}

var instance *singleton
var once sync.Once

func GetInstance() *singleton {
    once.Do(func() {
        instance = &singleton{}
    })
    return instance
}

而 Once 还可以用来做等待事件的触发器,比如下面这个例子:

func main() {
    var once sync.Once
    var wg sync.WaitGroup
    wg.Add(2)
    go func() {
        defer wg.Done()
        once.Do(func() {
            fmt.Println("Print 1")
        })
    }()
    go func() {
        defer wg.Done()
        once.Do(func() {
            fmt.Println("Print 2")
        })
    }()
    wg.Wait()
}

在这个例子中,我们使用 Once 来实现了一个等待事件的触发器,即两个 goroutine 都会等待 once 的触发,而 once 只会触发一次。

Once 的实现原理

Once 的实现原理是通过一个 Mutex 和一个布尔值来实现的,布尔值记录是否已经执行过,Mutex 用来保证并发安全。

看一下 Once 的源码:

package sync
 
import (
    "sync/atomic"
)
 
type Once struct {
    done uint32
    m    Mutex
}
 
func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        // Outlined slow-path to allow inlining of the fast-path.
        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()
    }
}
  1. 为什么要使用 atomic.LoadUint32(&o.done) == 0 而不使用 o.done == 0 呢?

    如果直接使用 o.done == 0,会无法及时观察 doSlow 中对 o.done 的修改,从而导致可能的多次执行 f()。这是与 go 语言的内存模型有关的。只有用保证并发安全的方式读取 o.done,才能保证读取到的是最新的值。而下面的 atomic.StoreUint32(&o.done, 1) 也是同样的道理。

  2. doSlow 是什么意思?

    doSlow 方法的存在主要是为了性能优化。将慢路径(slow-path)代码从 Do 方法中分离出来,使得 Do 方法的快路径(fast-path)能够被内联,从而提高性能。

  3. 为什么需要两次检查 done 的值

    doSlow 内部检查是为了避免多次执行 f(),因为在 o.done == 0 为真时,可能已经有多于一个的 goroutine 进入了 doSlow,多个 goroutine 在等待第一个获取到锁的 goroutine 释放锁让他们好执行函数,但是我们需要保证只有一次的执行,因此其他 goroutine 获取到锁后需要再次检查 done 的值,如果已经执行过 f(),则不再执行。从而保证在 doSlowdefer atomic.StoreUint32(&o.done, 1) 执行后,其他 goroutine 获取到锁后不会再执行 f()

Do 方法不提供返回值,因此如果有需要在初次执行后有 error 的情况下继续执行其他调用 Once 直到成功的需求,可以写一个自己的 Once,比如下面这个例子:

type Once struct {
    done uint32
    m    Mutex
}

func (o *Once) Do(f func() error) error {
    if atomic.LoadUint32(&o.done) == 0 {
        // Outlined slow-path to allow inlining of the fast-path.
        return o.doSlow(f)
    }
    return nil
}

func (o *Once) doSlow(f func() error) error {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
       err := f()
        if err == nil{
            atomic.StoreUint32(&o.done, 1)
        }
    }
    return nil
}

死锁

常见的像这样的死锁:

func main() {
    var onceA, onceB sync.Once
    var initB func()
    initA := func() { onceB.Do(initB) }
    initB = func() { onceA.Do(initA) }
    onceA.Do(initA)
}

还有一个死锁的例子,就是在 Do 里面调用 Do,比如下面这个例子:

func main() {
    var once sync.Once
    once.Do(func() {
        once.Do(func() {
            fmt.Println("hello")
        })
    })
}

内部的 once.Do 会等待外部 once.Do 的执行结果,但是外部 once.Do 的执行结果需要等待内部 once.Do 的执行结果,从而导致死锁。

总结

sync.Once 是 Go 语言标准库中的一个同步工具,用于确保在多个 Goroutine 中某个函数只会被执行一次。

  1. 确保函数只执行一次:sync.Once 通过一个内部的标记位来保证其中的函数只会被执行一次,后续调用会被阻塞,直到第一次调用完成。
  2. 安全地并发执行:多个 Goroutine 并发调用 sync.Once.Do(f),其中 f 是需要确保只执行一次的函数,但只有一个 Goroutine 实际上会执行函数 f,其他 Goroutine 会被阻塞。
  3. 零值安全:sync.Once 的零值为 nil,可以直接使用而无需初始化。
  4. 灵活性:sync.Once 可以用于单例模式、延迟初始化等场景。