go语言基础库sync中提供了基本的同步原语,包括OncewaitGroup等。注意,sync包中定义的类型均不可被复制。

sync.Once

sync.Once包中仅对外暴露了一个方法,即Do方法,该方法仅接受一个f func(){} 作为参数,保障当且仅当第一次调用once市里的Do方法时,才会执行f func()
如果多次调用 once.Do(f), 仅有第一次调用时才会调用f
常用于单例模式,例如初始化配置、保持数据库连接等

使用方式

如下样例中,使用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
package parallel

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

Once和init的区别

  • init在所在的 package 首次被加载时执行
  • 而Once可以放在任意位置进行初始化和调用,并发场景下安全

sync.Once的实现原理

Once的结构体如下,使用无符号数标识方法是否已经被执行,同时内部维护互斥锁以保障并发安全。

1
2
3
4
type Once struct {
done uint32
m Mutex
}

Once对外暴露如下方法:

1
2
3
4
5
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
o.doSlow(f)
}
}

接收一个func()作为入参,函数中进行判断once.done是否已被执行过(此时已经获取互斥锁,所以是直接比较),如果未执行过(未执行记为0),则执行o.doSlow()方法。
该方法保障当此方法返回时, f已经被执行了。

1
2
3
4
5
6
7
8
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}

在调用方法时,预先加锁,然后再次进行原子判断是否为被执行过,如果是,则执行传入的方法。

tips

  1. 为什么要将done 放置在第一个参数位置
    由于Once.done作为热路径存在,将它放置在结构体的第一个参数位置能够减少CPU指令。因为结构体指针和结构体的第一个参数的地址是一致的。如果将done放置在其余位置,在寻址时将需要额外的CPU指令完成寻址。
  2. 上述实现能否修改为如下实现?
    1
    2
    3
    if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    f()
    }
    答案是否定的,因为Do必须保障return返回时就已经执行f了,而上述语句在f未被调用时,如果有两个协程并发调用,其中一个会执行f;而另外一个会直接返回。不满足上述约束。

参考

1. Go sync.Once
2. 一文了解go语言sync标准库