type Once struct 🚀

  1. Once是只执行一次动作的对象,应用场景,比如加载配置文件只需要加载一次。
  2. 首次使用Once后,不能复制Once。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 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 表示操作是否已经执行。
    // 它在结构体中位于首位,因为它在 hot path 中使用。
    // hot path 内联在每个调用点。
    // 在某些体系结构上(amd64/386),将done放在第一位可以让指令更紧凑,而在其他体系结构上可以让指令更少(用于计算偏移量)。
    done uint32		// 0.未被调用过 1.已被调用过
    m    Mutex		// 互斥锁
}

// 其中解释了为什么将 done 置为 Once 的第一个字段:done 在热路径中,done 放在第一个字段,能够减少 CPU 指令,也就是说,这样做能够提升性能。
//  1. 热路径(hot path)是程序非常频繁执行的一系列指令,sync.Once 绝大部分场景都会访问 o.done,在热路径上是比较好理解的,
//	如果 hot path 编译后的机器码指令更少,更直接,必然是能够提升性能的。
//  2. 为什么放在第一个字段就能够减少指令呢?因为结构体第一个字段的地址和结构体的指针是相同的,如果是第一个字段,直接对结构体的指针解引用即可。
//  如果是其他的字段,除了结构体指针外,还需要计算与第一个值的偏移(calculate offset)。在机器码中,偏移量是随指令传递的附加值,
//  CPU 需要做一次偏移值与指针的加法运算,才能获取要访问的值的地址。因为,访问第一个字段的机器代码更紧凑,速度更快。

Do()

  1. 当且仅当 Do 是第一次为 Once 实例调用函数 f 时,Do 才会调用函数 f。
  2. 换句话说,给定var once Once,如果 once.Do(f) 被多次调用,只有第一次调用会调用f,即使每次调用 f 的值不同。
  3. 每个函数执行时都需要一个 Once 的新实例。
  4. Do 用于必须只运行一次的初始化。
  5. 由于 f 是 niladic,因此可能需要使用函数字面量来捕获Do调用的函数的参数:config.once.Do(func() { config.init(filename) })
    • niladic:被解释为不带参数的闭包函数。
  6. 因为只有在对 f 的调用返回之前,才会返回对 Do 的调用,如果 f 导致Do被调用,它就会死锁。(f函数内不能在调用外层的Do函数)
  7. 如果f发生panic,Do认为它回来了;Do的后续调用不需要调用f。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// 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.
    //
    // 注意:下面是一个不正确的Do实现。
    //  以下形式不能保证Do函数返回时f函数已执行完,因为我们是先标记后执行f的。
    //  if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    //      f()
    //  }
    // 
    // Do 保证当它返回时,f 已经完成
    // 这种实现不会实现这种保证:给定两个同时调用,cas的赢家将调用f,而第二个将立即返回,而无需等待第一个调用完成。
    // 这就是慢路径回退到互斥量的原因,也是原子性的原因。StoreUint32必须延迟到f返回之后。

    // 后续大部分情况会从这里判断失败
    if atomic.LoadUint32(&o.done) == 0 {
        // Outlined slow-path to allow inlining of the fast-path.
        o.doSlow(f)
    }
}

doSlow()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func (o *Once) doSlow(f func()) {
    o.m.Lock() // 获取锁
    defer o.m.Unlock() // 延迟释放锁
    // 当出现并发时,这里会拦住等待着的协程
    if o.done == 0 {	
        // 在f()执行完后才会标记done为1。
        defer atomic.StoreUint32(&o.done, 1)
        // 需要保证f函数不会发生panic
        // 如果发生panic,则o.done会被标记为1,后续不会在调用f函数
        f()
    }
}

使用场景

  • sync.Once 是 Go 标准库提供的使函数只执行一次的实现。
  • 常应用于【单例模式】,例如【初始化配置】、【保持数据库连接】等。作用与 init 函数类似,但有区别。
    • init 函数是当所在的 package 首次被加载时执行,若迟迟未被使用,则既浪费了内存,又延长了程序加载时间。
    • sync.Once 可以在代码的任意位置初始化和调用,因此可以延迟到使用时再执行,并发场景下是线程安全的。
  • 在多数情况下,sync.Once 被用于控制变量的初始化,这个变量的读写满足如下三个条件:
    • 当且仅当第一次访问某个变量时,进行初始化(写)。
    • 变量初始化过程中,所有读都被阻塞,直到初始化完成。
    • 变量仅初始化一次,初始化完成后驻留在内存里。
  • sync.Once 仅提供了一个方法 Do,参数 f 是对象初始化函数。
    • func (o *Once) Do(f func())

使用示例

简单的示例

  1. 考虑一个简单的场景,函数 ReadConfig 需要读取环境变量,并转换为对应的配置。
  2. 环境变量在程序执行前已经确定,执行过程中不会发生改变。
  3. ReadConfig 可能会被多个协程并发调用,为了提升性能(减少执行时间和内存占用),使用 sync.Once 是一个比较好的方式。
  4. 在这个例子中,声明了 2 个全局变量,once 和 config。
  5. config 是需要在 ReadConfig 函数中初始化的(将环境变量转换为 Config 结构体),ReadConfig 可能会被并发调用。
  6. 如果 ReadConfig 每次都构造出一个新的 Config 结构体,既浪费内存,又浪费初始化时间。
  7. 如果 ReadConfig 中不加锁,初始化全局变量 config 就可能出现并发冲突。
  8. 这种情况下,使用 sync.Once 既能够保证全局变量初始化时是线程安全的,又能节省内存和初始化时间。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
type Config struct {
    Server string
    Port   int64
}

var (
    once   sync.Once
    config *Config
)

func ReadConfig() *Config {
    once.Do(func() {
        var err error
        config = &Config{Server: os.Getenv("TT_SERVER_URL")}
        config.Port, err = strconv.ParseInt(os.Getenv("TT_PORT"), 10, 0)
        if err != nil {
            config.Port = 8080 // default port
        }
        log.Println("init config")
    })
    return config
}

func main() {
    for i := 0; i < 10; i++ {
        go func() {
            _ = ReadConfig()
        }()
    }
    time.Sleep(time.Second)
}

标准库中的使用

  1. 比如 package html 中,对象 entity 只被初始化一次。
  2. 字典 entity 包含 2005 个键值对,若使用 init 在包加载时初始化,若不被使用,将会浪费大量内存。
  3. html.UnescapeString(s) 函数是线程安全的,可能会被用户程序在并发场景下调用,因此对 entity 的初始化需要加锁,使用 sync.Once 能保证这一点。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var populateMapsOnce sync.Once
var entity           map[string]rune

func populateMaps() {
    entity = map[string]rune{
        "AElig;":                           '\U000000C6',
        "AMP;":                             '\U00000026',
        "Aacute;":                          '\U000000C1',
        "Abreve;":                          '\U00000102',
        "Acirc;":                           '\U000000C2',
        // 省略 2000 项
    }
}

func UnescapeString(s string) string {
    populateMapsOnce.Do(populateMaps)
    i := strings.IndexByte(s, '&')

    if i < 0 {
            return s
    }
    // 省略后续的实现
}

普通示例

  1. 对只需要运行一次的代码,如全局性的初始化操作,或者防止多次重复执行(比如重复提交等)都有很好的作用
  2. 无论 sync.Once.Do(f func()) 里面的f函数是否变化,只要 Once.Do() 运行一次就没有机会再次运行了
  3. Once 是一个结构体,通过判断 done 值来确定是否执行下一步
    • 当 done 为1时直接返回,否则锁定后执行f函数以及置done值为1
    • 而对 done 的值得修改使用了 atomic.StoreUint32(原子级的操作)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

import (
    "fmt"
    "sync"
    "time"
)

var once sync.Once

func onces() {
    fmt.Println("once")
}

func onced() {
    fmt.Println("onced")
}

func main() {
    for i, v := range make([]string, 10){
        once.Do(onces)
        fmt.Println("v:", v, "--i:", i)
    }

    for i := 0; i < 10; i++ {
        go func(i int) {
            once.Do(onced)
            fmt.Println(i)
        }(i)
    }

    time.Sleep(3 * time.Second)
}
once
v:  --i: 0
v:  --i: 1
v:  --i: 2
v:  --i: 3
v:  --i: 4
v:  --i: 5
v:  --i: 6
v:  --i: 7
v:  --i: 8
v:  --i: 9
0
1
3
4
2
6
5
7
8
9