sync.Once 的使用场景
sync.Once 是 Golang package 中使方法只执行一次的对象实现,作用与 init 函数类似。但也有所不同。
- init 函数是当所在的 package 首次被加载时执行,若迟迟未被使用,则既浪费了内存,又延长了程序加载时间。
- sync.Once 可以在代码的任意位置初始化和调用,因此可以延迟到使用时再执行,并发场景下是线程安全的。
使用方法
在多数情况下,sync.Once 被用于控制变量的初始化,这个变量的读写满足如下三个条件:
- 当且仅当第一次访问某个变量时,进行初始化(写);
- 变量初始化过程中,所有读都被阻塞,直到初始化完成;
- 变量仅初始化一次,初始化完成后驻留在内存里。
sync.Once 仅提供了一个方法 Do,参数 f 是对象初始化函数。
func (o *Once) Do(f func())
- 只有在当前的 Once 实例第一次调用 Do 方法时,才会真正执行 f。哪怕在多次调用 Do 中间 f 的值有所变化,也只会被实际调用一次;
- Do 针对的是只希望执行一次的初始化操作,由于f 是没有参数的,如果需要传参,可以采用包装一层 func 的形式来实现:config.once.Do(func() { config.init(filename) })
在对f 的调用返回之前,不会返回对Do的调用,所以如果f方法中又调用来Do方法,将会死锁。所以不要做这样的操作:
func main() {
var once sync.Once
once.Do(func() {
once.Do(func() {
fmt.Println("hello kenmawr.")
})
})
}
如果 f 抛出了 panic,此时Do会认为f已经返回,后续多次调用Do也不会再触发对 f 的调用。
作者:ag9920
链接:https://juejin.cn/post/7088305487753510925
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
使用示例
在实际的工作中,你可能会有这样的需求:让代码只执行一次,哪怕是在高并发的情况下,比如创建一个单例。
针对这种情形,Go 语言为我们提供了 sync.Once 来保证代码只执行一次,如下所示:
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
//把要执行的函数(方法)作为参数传给once.Do方法即可
once.Do(greet)
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
func greet() {
fmt.Println("hello world")
}
输出结果:
hello world
虽然启动了 10 个协程来执行 greet 函数,但是因为用了 once.Do 方法,所以函数 greet 只会被执行一次。也就是说在高并发的情况下,sync.Once 也会保证 greet 函数只执行一次。
问题
sync.Once类型值的Do方法是怎么保证只执行参数函数一次的?
与 sync.WaitGroup 类型一样,sync.Once 类型(以下简称 Once 类型)也属于结构体类型,同样也是开箱即用和并发安全的。
由于这个类型中包含了一个 sync.Mutex 类型的字段,所以,复制该类型的值也会导致功能的失效。
Once 类型的 Do 方法只接受一个参数,这个参数的类型必须是 func() ,即:无参数声明和结果声明的函数。
该方法的功能并不是对每一种参数函数都只执行一次,而是只执行“首次被调用时传入的”那个函数,并且之后不会再执行任何参数函数。
func main() {
var count int
increment := func() { count++ }
decrement := func() {
fmt.Println("enter decrement")
count--
}
var once sync.Once
once.Do(increment)
once.Do(decrement)
fmt.Printf("count is %v", count) // count is 1
}
可以看到 count 并不是 0,同时呢我们发现 decrement 函数中并没有进去,所以就验证了上面的说法。once 只执行一次 Do 方法。
如果你有多个只需要执行一次的函数,那么就应该为它们中的每一个都分配一个 sync.Once 类型的值(以下简称 Once 值)
func main() {
var count int
increment := func() { count++ }
decrement := func() {
fmt.Println("enter decrement")
count--
}
var onceIn sync.Once // 给 increment 函数声明一个 Once 类型值
var onceDe sync.Once // 给 decrement 函数声明一个 Once 类型值
onceIn.Do(increment)
onceDe.Do(decrement)
fmt.Printf("count is %v", count)
// enter decrement
// count is 0
}
实战用法
任何只希望执行一次的操作
初始化
很多同学可能会有疑问,我直接在 init() 函数里面做初始化不就可以了吗?效果上是一样的,为什么还要用 sync.Once,这样还需要多声明一个 once 对象。
原因在于:
- init() 函数是在所在包首次被加载时执行,若未实际使用,既浪费了内存,又延缓了程序启动时间。
- sync.Once 可以在任何位置调用,而且是并发安全的,我们可以在实际依赖某个变量时才去初始化,这样「延迟初始化」从功能上讲并无差异,但可以有效地减少不必要的性能浪费。
我们来看 Golang 官方的 html 库中的一个例子,我们经常使用的转义字符串函数
func UnescapeString(s string) string
在进入函数的时候,首先就会依赖包里内置的 populateMapsOnce 实例(本质是一个 sync.Once) 来执行初始化 entity 的操作。这里的entity是一个包含上千键值对的 map,如果init()时就去初始化,会浪费内存。
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',
// 省略后续键值对
}
}
func UnescapeString(s string) string {
populateMapsOnce.Do(populateMaps)
i := strings.IndexByte(s, '&')
if i < 0 {
return s
}
// 省略后续的实现
...
}
单例模式
开发中我们经常会实现 Getter 来暴露某个非导出的变量,这个时候就可以把 once.Do 放到 Getter 里面,完成单例的创建。
package main
import (
"fmt"
"sync"
)
type Singleton struct{}
var singleton *Singleton
var once sync.Once
func GetSingletonObj() *Singleton {
once.Do(func() {
fmt.Println("Create Obj")
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()
}
/*--------- 输出 -----------
Create Obj
0x119f428
0x119f428
0x119f428
0x119f428
0x119f428
**/
关闭channel
一个channel如果已经被关闭,再去关闭的话会 panic,此时就可以应用 sync.Once 来帮忙。
type T int
type MyChannel struct {
c chan T
once sync.Once
}
func (m *MyChannel) SafeClose() {
// 保证只关闭一次channel
m.once.Do(func() {
close(m.c)
})
}
实现原理
sync.Once 源码:
type Once struct {
done uint32
m Mutex
}
// 大写 只对外暴露
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 0 {
// 原子获取 done 的值,判断 done 的值是否为 0,如果为 0 就调用 doSlow 方法,进行二次检查。
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
// 二次检查时,持有互斥锁,保证只有一个 goroutine 执行。
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
// 二次检查,如果 done 的值仍为 0,则认为是第一次执行,执行参数 f,并将 done 的值设置为 1。
defer atomic.StoreUint32(&o.done, 1)
f()
}
}
sync.once 仅提供了一个Do()方法,其参数为待执行的函数,该函数的代码块期望仅被执行一次。下面看代码实现过程:
首先,atomic读取done字段值是否被改变,然后当如果没有改变时执行doSlow方法。当进入doSlow方法,开始执行锁操作,在并发环境下仅有一个线程被执行,然后基于done字段是否被改变执行待执行函数,如果没有改变则执行f函数。当代码块执行后,done字段被激活。