1.简介

sync.Once 是 Go 标准库提供的使函数只执行一次的实现,常应用于单例模式,例如初始化配置、保持数据库连接等。作用与 init 函数类似,但有区别。

  • init 函数是当所在的 package 首次被加载时执行,若迟迟未被使用,则既浪费了内存,又延长了程序加载时间。
  • sync.Once 可以在代码的任意位置初始化和调用,因此可以延迟到使用时再执行,并发场景下是线程安全的。

在多数情况下,sync.Once 被用于控制变量的初始化,这个变量的读写满足如下三个条件:

  • 当且仅当第一次访问某个变量时,进行初始化(写);
  • 变量初始化过程中,所有读都被阻塞,直到初始化完成;
  • 变量仅初始化一次,初始化完成后驻留在内存里。

2.原理

sync.Once 用来保证函数只执行一次。要达到这个效果,需要做到两点:

  • 计数器,统计函数执行次数;
  • 线程安全,保障在多 Go 程的情况下,函数仍然只执行一次,比如锁。

2.1 源码

下面看一下 sync.Once 结构,其有两个变量。使用 done 统计函数执行次数,使用锁 m 实现线程安全。果不其然,和上面的猜想一致。

// Once is an object that will perform exactly one action.
//
// A Once must not be copied after first use.
type Once struct {
    // done indicates whether the action has been performed.
    // It is first in the struct because it is used in the hot path.
    // The hot path is inlined at every call site.
    // Placing done first allows more compact instructions on some architectures (amd64/386),
    // and fewer instructions (to calculate offset) on other architectures.
    done uint32
    m    Mutex
}

sync.Once 仅提供了一个导出方法 Do(),参数 f 是只会被执行一次的函数,一般为对象初始化函数。

// go version go1.17 darwin/amd64

// Do calls the function f if and only if Do is being called for the
// first time for this instance of Once. In other words, given
//     var once Once
// if once.Do(f) is called multiple times, only the first call will invoke f,
// even if f has a different value in each invocation. A new instance of
// Once is required for each function to execute.
//
// Do is intended for initialization that must be run exactly once. Since f
// is niladic, it may be necessary to use a function literal to capture the
// arguments to a function to be invoked by Do:
//     config.once.Do(func() { config.init(filename) })
//
// Because no call to Do returns until the one call to f returns, if f causes
// Do to be called, it will deadlock.
//
// If f panics, Do considers it to have returned; future calls of Do return
// without calling f.
//
func (o *Once) Do(f func()) {
    // Note: Here is an incorrect implementation of Do:
    //
    //    if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    //        f()
    //    }
    //
    // Do guarantees that when it returns, f has finished.
    // This implementation would not implement that guarantee:
    // given two simultaneous calls, the winner of the cas would
    // call f, and the second would return immediately, without
    // waiting for the first's call to f to complete.
    // This is why the slow path falls back to a mutex, and why
    // the atomic.StoreUint32 must be delayed until after f returns.

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

抛去大段的注释,可以看到 sync.Once 实现非常简洁。Do() 函数中,通过对成员变量 done 的判断,来决定是否执行传入的任务函数。执行任务函数前,通过锁保证任务函数的执行和 done 的修改是一个互斥操作。在执行任务函数前,对 done 做一个二次判断,来保证任务函数只会被执行一次,done 只会被修改一次。

2.2 done 为什么是第一个字段?

从字段 done 前有一段注释,说明了done 为什么是第一个字段。

done 在热路径中,done 放在第一个字段,能够减少 CPU 指令,也就是说,这样做能够提升性能。

热路径(hot path)是程序非常频繁执行的一系列指令,sync.Once 绝大部分场景都会访问 o.done,在热路径上是比较好理解的。如果 hot path 编译后的机器码指令更少,更直接,必然是能够提升性能的。

为什么放在第一个字段就能够减少指令呢?因为结构体第一个字段的地址和结构体的指针是相同的,如果是第一个字段,直接对结构体的指针解引用即可。如果是其他的字段,除了结构体指针外,还需要计算与第一个值的偏移(calculate offset)。在机器码中,偏移量是随指令传递的附加值,CPU 需要做一次偏移值与指针的加法运算,才能获取要访问的值的地址。因为,访问第一个字段的机器代码更紧凑,速度更快。

参考 What does “hot path” mean in the context of sync.Once? - StackOverflow

3.性能差异

我们以一个简单示例,来说明使用 sync.Once 保证函数只会被执行一次和多次执行,二者的性能差异。

考虑一个简单的场景,函数 ReadConfig 需要读取环境变量,并转换为对应的配置。环境变量在程序执行前已经确定,执行过程中不会发生改变。ReadConfig 可能会被多个协程并发调用,为了提升性能(减少执行时间和内存占用),使用 sync.Once 是一个比较好的方式。

type Config struct {
    GoRoot string
    GoPath string
}

var (
    once   sync.Once
    config *Config
)

func ReadConfigWithOnce() *Config {
    once.Do(func() {
        config = &Config{
            GoRoot: os.Getenv("GOROOT"),
            GoPath: os.Getenv("GOPATH"),
        }
    })
    return config
}

func ReadConfig() *Config {
    return &Config{
        GoRoot: os.Getenv("GOROOT"),
        GoPath: os.Getenv("GOPATH"),
    }
}

我们看下二者的性能差异。

func BenchmarkReadConfigWithOnce(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = ReadConfigWithOnce()
    }
}

func BenchmarkReadConfig(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = ReadConfig()
    }
}

执行测试结果如下:

go test -bench=. main/once
goos: darwin
goarch: amd64
pkg: main/once
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkReadConfigWithOnce-12          670438965                1.732 ns/op
BenchmarkReadConfig-12                  13339154                87.46 ns/op
PASS
ok      main/once       3.006s

sync.Once 中保证了 Config 初始化函数仅执行了一次,避免了多次重复初始化,在并发环境下很有用。

powered by Gitbook该文章修订时间: 2024-03-22 15:30:00

results matching ""

    No results matching ""