Golang Sync.Once 使用与原理,看这一篇足矣

解锁并发:Go的Sync.Once深度研究

golang sync.Once (from github.com/MariaLetta/free-gophers-pack)|300

1.1 简介

我们都知道 Go 中 init() 函数里面可以做初始化,但是它是在所在包首次被加载时执行,若未实际使用,既浪费了内存,又延缓了程序启动时间。

而 sync.Once 可以在任何位置调用,我们可以在实际依赖某个变量时才去初始化,这样就可以实现延迟初始化,并且可以有效地减少不必要的性能浪费。

更重要的,sync.Once 是 Go 语言中的一个同步原语,是并发安全的。无论有多少个 goroutines 尝试同时执行它,它可以保证某些代码在并发环境下只执行一次。这在多线程环境中特别有用,可以防止初始化代码被执行多次。

在如下情况尤为重要:

  • 初始化全局资源
  • 数据库连接池的创建
  • 配置文件的加载
  • 等等

1.2 实战用法

1.2.1 单例模式

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
package main

import (
"fmt"
"sync"
)

type Singleton struct{}

var singleton *Singleton
var once sync.Once

func GetSingletonObj() *Singleton {
once.Do(func() {
fmt.Println("Create Singleton")
singleton = new(Singleton)
})
return singleton
}

func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
obj := GetSingletonObj()
fmt.Printf("%p\n", obj)
}()
}
wg.Wait()
}

运行上述代码时,你会看到如下输出:

1
2
3
4
5
6
Create Singleton
0x574380
0x574380
0x574380
0x574380
0x574380

1.2.2 关闭 channel

在 Go 语言中,如果尝试关闭一个已经关闭的通道,会导致运行时 panic。为了安全地关闭通道,可以使用 sync.Once 来确保通道只被关闭一次。

1
2
3
4
5
6
7
8
9
10
11
type SafeChannel struct {
ch chan int
once sync.Once
}

// Safely close the channel
func (sc *SafeChannel) Close() {
sc.once.Do(func() {
close(sc.ch)
})
}

1.2.3 Std case

  1. UnescapeString
  2. populateMaps
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
func UnescapeString(s string) string {
populateMapsOnce.Do(populateMaps)
i := strings.IndexByte(s, '&')

if i < 0 {
return s
}
...
}
// populateMapsOnce guards calling populateMaps.
var populateMapsOnce sync.Once

// populateMaps populates entity and entity2.
func populateMaps() {
entity = map[string]rune{
"AElig;": '\U000000C6',
"AMP;": '\U00000026',
"Aacute;": '\U000000C1',
"Abreve;": '\U00000102',
"Acirc;": '\U000000C2',
"Acy;": '\U00000410',
"Afr;": '\U0001D504',
"Agrave;": '\U000000C0',
"Alpha;": '\U00000391',
"Amacr;": '\U00000100',
"And;": '\U00002A53',
"Aogon;": '\U00000104',
"Aopf;": '\U0001D538',
"ApplyFunction;": '\U00002061',
}
...
}

html.UnescapeString(s) 函数是线程安全的,在进入函数的时候,首先就会依赖包里内置的 populateMapsOnce 实例(本质是一个 sync.Once) 来执行初始化 entity、entity2 的操作。字典 entity、entity2 包含大量键值对,若使用 init 在包加载时初始化,若不被使用,将会浪费大量内存。

1.3 实现原理

go1.23进行分析,代码很精炼,可以打开进行阅读。

1.3.1 内部结构

在 Go 的源码中,sync.Once 的定义如下:

1
2
3
4
5
6
7
8
9
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 atomic.Uint32
m Mutex
}

1.3.2 基本实现

sync.Once 的核心方法是 Do,这个方法的实现如下:

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
func (o *Once) Do(f func()) {
// Note: Here is an incorrect implementation of Do:
//
// if o.done.CompareAndSwap(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 o.done.Store must be delayed until after f returns.
if o.done.Load() == 0 {
o.doSlow(f)
}
}

func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 {
defer o.done.Store(1)
f()
}
}
  1. 快速路径
1
2
3
if o.done.Load() == 0 {
o.doSlow(f)
}

首先通过 atomic.Load 检查 done 的值是否为 0。如果是 1,说明已经有 goroutine 执行过传入的函数,函数结束立刻返回。

  1. 慢速路径
    如果 done 的值为 0,代码进入慢速路径,加锁以确保只有一个 goroutine 进入临界区。
1
2
o.m.Lock()
defer o.m.Unlock()
  1. 再次检查和执行函数
    在持有锁的情况下,再次检查 done 的值,如果仍然是 0,就执行目标函数,并在函数执行完毕后将 done 设置为 1。
1
2
3
4
if o.done.Load() == 0 {
defer o.done.Store(1)
f()
}

1.3.3 实现特点

  1. 原子操作的作用
    在 Do 方法的实现中,原子操作 atomic.Load 和 atomic.Store 被用来检查和设置 done 标志。这些原子操作是底层操作系统提供的 CPU 级别的操作,确保操作的原子性,从而避免竞态条件(race condition),在无锁的情况下保证并发安全。

  2. 为什么会有双重检查(double check)
    先说结论:通过双重检查,可以在大多数情况下避免锁竞争,提高性能并保证在并发环境下只执行一次。

  • 第一次检查:在获取锁之前,先使用原子加载操作 atomic.Load 检查 done 变量的值是否为 1,如果是,则此时函数返回,这一检查可以避免不必要的锁竞争。
  • 第二次检查:原子性只保证了获取值为0或为1,没有中间状态,但还是有可能多个goroutine进入到 doSlow 函数中。所以在doSlow 函数中获取锁之后,再次检查 done 变量的值,从未确保在并发环境下只执行一次f函数。
  1. 注释一:hot path
1
2
3
// 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.

hot path表示程序非常频繁执行的一系列指令,sync.Once绝大多数场景都会访问o.done,访问 done 的机器指令显然是处于hot path上的。
在Go中,结构体第一个字段的地址和结构体的指针是相同的,如果访问第一个字段,直接对结构体的指针解引用即可获得访问地址,如果访问其他的字段,还需要额外计算与第一个值的偏移,才能获取要访问的值的地址。
将done放在第一个字段,是为了访问它的机器代码更紧凑,速度更快。
4. 注释二:CAS

1
2
3
4
5
6
7
8
9
10
11
12
13
// Note: Here is an incorrect implementation of Do:
//
// if o.done.CompareAndSwap(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 o.done.Store must be delayed until after f returns.

注释说的很清楚,当一次 once.Do 返回时,执行的 f 一定是完成的状态,所以 CAS 并不满足这个条件,那么为什么会有这种要求呢?
举个例子,我们都知道,在分布式系统中,有个必不可少的组件是服务发现,客户端访问服务端资源时,需要通过服务发现来获取服务端的建立链接的信息,如IP、Port。
当多个客户端访问服务端资源时,大致有两个流程:

  • 初始化AddrManager(服务发现),用于获取建立链接的服务端信息。
  • 多个客户端与服务端建立链接,访问具体的资源

回到CAS 这个问题,显然 AddrManager 应该使用sync.Once进行唯一的初始化,而如果初始化 AddrManager 的程序未执行完成,后续的流程显然会出错、崩溃。这就是sync.Once执行后 f 一定是完成状态的一个原因。

  1. 注释三:panic
1
2
// If f panics, Do considers it to have returned; future calls of Do return
// without calling f.

所以,我们应该在 sync.Once.Do 的 goroutine 里进行 recover,避免错过可能的panic 信息,也能避免注释二中例子里更严重的后果。

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
once := &sync.Once{}
defer func() {
if err := recover(); err != nil {
fmt.Println("recover panic")
// metrics or log
}
}()
once.Do(func() {
panic("panic i=0")
})
}
  1. 注释四:not be copied
1
A Once must not be copied after first use.

参考文章:noCopy Strategies You Should Know in Golang | by Wesley Wei | Sep, 2024 | Programmer’s Career

  • 如果sync.Once被copy 了,显然会执行多次  f 函数,这与我们的目标相悖。
  • 为什么sync.Once中不使用 noCopy ? 因为可能真的有人 copy 后初始化多个单一资源吧😂,尽管这样用并不优雅。

1.4 自定义优化

针对上面的问题可以看出,我们完全可以对 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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
package main

import (
"errors"
"fmt"
"sync"
"sync/atomic"
)

type noCopy struct{}

func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}

type Once struct {
done atomic.Uint32
noCopy
m sync.Mutex
}

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

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

func main() {
once := Once{}
var mu sync.Mutex
onceFunc := func() error {
fmt.Println("init...")
return errors.New("error when init")
}
var wg sync.WaitGroup
var globalErr error
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer func() {
if err := recover(); err != nil {
fmt.Println("recover panic")
// metrics or log
}
wg.Done()
}()
if err := once.Do(onceFunc); err != nil {
mu.Lock()
globalErr = err
mu.Unlock()
}

}()
}
wg.Wait()
fmt.Printf("global err: %v", globalErr)
}

输出

1
2
3
4
5
6
init...
init...
init...
init...
init...
global err: error when init

上述代码实现了一个简单加强的 Once 结构体:

  1. 只有在没有发生 error 的情况下,才会跳过函数执行,避免初始化失败。
  2. 使用 noCopy来提示不要进行拷贝

1.5 注意事项

  1. 合理评估需求,在非并发情况下,没必要使用sync.Once造成性能浪费。
  2. 不要在 Do 的 f 中嵌套调用 Do
1
2
3
4
5
6
7
8
func testDo()  {
once := &sync.Once{}
once.Do(func() {
once.Do(func() {
fmt.Println("run testDo")
})
})
}

原因比较简单,同一个锁,第二次执行 Lock时会一直等待第一次Lock释放,造成死锁。一个有趣的点在于Go 不支持可重入锁,可以进一步阅读:Reentrant Locks Your Should Know in Golang | by Wesley Wei | Programmer’s Career

  1. . 不要拷贝一个 sync.Once 使用或作为参数传递
    参考上文 A Once must not be copied after first use.

1.6 总结

本文详细介绍了 Go 语言中的 sync.Once,包括它的基本定义、使用场景和应用实例以及源码分析等。在实际开发中,sync.Once 经常被用于实现单例模式和延迟初始化操作。

另外重点介绍了 sync.Once 源代码中的以下内容:

  1. 原子操作的作用
  2. 双重检查
  3. hot path
  4. CAS
  5. not be copied

根据以上内容给出了一个简单优化版的 sync.Once 并给出了一些注意事项。


更多该系列文章,参考medium链接:

https://wesley-wei.medium.com/list/you-should-know-in-golang-e9491363cd9a

English post: https://programmerscareer.com/golang-syncOnce/
作者:Wesley Wei – LinkedIn, Wesley Wei – Medium, Wesley Wei – Twitter
发表日期:原文在 2024-09-26 22:41 时创作于 https://programmerscareer.com/zh-cn/golang-syncOnce/
版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证

从 Stringer 工具的自动化生成了解 Go AST golang select 的使用及基本实现

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×