深入理解go sync.Once的具体使用
在很多情况下,我们可能需要控制某一段代码只执行一次,比如做某些初始化操作,如初始化数据库连接等。
对于这种场景,go 为我们提供了 sync.Once
对象,它保证了某个动作只被执行一次。
当然我们也是可以自己通过 Mutex
实现 sync.Once
的功能,但是相比来说繁琐了那么一点,因为我们不仅要自己去控制锁,还要通过一个标识来标志是否已经执行过。
Once 的实现
Once
的实现非常简单,如下,就只有 20 来行代码,但里面包含了 go 并发、同步的一些常见处理方法。
package sync import ( "sync/atomic" ) type Once struct { done uint32 m Mutex } func (o *Once) Do(f func()) { if atomic.LoadUint32(&o.done) == 0 { 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() } }
简要说明:
done
字段指示了操作是否已执行,也就是我们传递给Do
的函数是否已经被执行。Do
方法接收一个函数参数,这个函数参数只会被执行一次。Once
内部是通过Mutex
来实现不同协程之间的同步的。
使用示例
在下面的例子中,once.Do(test)
被执行了 3 次,但是最终 test
只被执行了一次。
package sync import ( "fmt" "sync" "testing" ) var once sync.Once var a = 0 func test() { a++ } func TestOnce(t *testing.T) { var wg sync.WaitGroup wg.Add(3) for i := 0; i < 3; i++ { go func() { // once.Do 会调用 3 次,但最终只会执行一次 once.Do(test) wg.Done() }() } wg.Wait() fmt.Println(a) // 1 }
Once 的一些工作机制
Once
的Do
方法可以保证,在多个 goroutine 同时执行Do
方法的时候,
在第一个抢占到Do
执行权的 goroutine 执行返回之前,其他 goroutine 都会阻塞在Once.Do
的调用上,
只有第一个Do
调用返回的时候,其他 goroutine 才可以继续执行下去,并且其他所有的 goroutine
不会再执行传递给Do
的函数。(如果是初始化的场景,这可以避免尚未初始化完成就执行其他的操作)如果
Once.Do
发生panic
的时候,传递给Do
的函数依然被标记为已完成。后续对Do
的调用也不会再执行传给Do
的函数参数。我们不能简单地通过
atomic.CompareAndSwapUint32
来决定是否执行f()
,因为在多个 goroutine 同时执行的时候,它无法保证f()
只被执行一次。所以Once
里面用了Mutex
,这样就可以有效地保护临界区。
// 错误实现,这不能保证 f 只被执行一次 if atomic.CompareAndSwapUint32(&o.done, 0, 1) { f() }
Once.Do
的函数参数是没有参数的,如果我们需要传递一些参数,可以再对f
做一层包裹。
config.once.Do(func() { config.init(filename) })
Once 详解
hotpath
这里说的 hotpath
指的是 Once
里的第一个字段 done
:
type Once struct { // hotpath done uint32 m Mutex }
Once
结构体的第一个字段是 done
,这是因为 done
的访问是远远大于 Once
中另外一个字段 m
的,
放在第一个字段中,编译器就可以做一些优化,因为结构体的地址其实就是结构体第一个字段的地址,
这样一来,在访问 done
字段的时候,就不需要通过结构体地址 + 偏移量的方式来访问,
这在一定程度上提高了性能。
结构体地址计算示例:
type person struct { name string age int } func TestStruct(t *testing.T) { var p = person{ name: "foo", age: 10, } // p 和 p.name 的地址相同 // 0xc0000100a8, 0xc0000100a8 fmt.Printf("%p, %p\n", &p, &p.name) // p.age 的地址 // 0xc0000100b8 fmt.Printf("%p\n", &p.age) // p.age 的地址也可以通过:结构体地址 + age 字段偏移量 计算得出。 // 0xc0000100b8 fmt.Println(unsafe.Add(unsafe.Pointer(&p), unsafe.Offsetof(p.age))) }
atomic.LoadUint32
func (o *Once) Do(f func()) { if atomic.LoadUint32(&o.done) == 0 { o.doSlow(f) } }
在 Do
方法中,是通过 atomic.LoadUint32
的方式来判断 done
是否等于 0 的,
这是因为,如果直接使用 done == 0
的方式的话,就有可能导致在 doSlow
里面对 done
设置为 1 之后,
在 Do
方法里面无法正常观测到。因此用了 atomic.LoadUint32
。
而在 doSlow
里面是可以通过 done == 0
来判断的,这是因为 doSlow
里面已经通过 Mutex
保护起来了。
唯一设置 done = 1
的地方就在临界区里面,所以 doSlow
里面通过 done == 0
来判断是完全没有问题的。
atomic.StoreUint32
func (o *Once) doSlow(f func()) { o.m.Lock() defer o.m.Unlock() if o.done == 0 { defer atomic.StoreUint32(&o.done, 1) f() } }
在 doSlow
方法中,设置 done
为 1 也是通过 atomic.StoreUint32
来设置的。
这样就可以保证在设置了 done
为 1 之后,可以及时被其他 goroutine 看到。
Mutex
doSlow
的实现里面,最终还是要通过 Mutex
来保护临界区,
通过 Mutex
可以实现 f
只被执行一次,并且其他的 goroutine 都可以使用这一次 f
的执行结果。
因为其他 goroutine 在第一次 f
调用未返回之前,都阻塞在获取 Mutex
锁的地方,
当它们获取到 Mutex
锁的时候,得以继续往下执行,但这个时候 f
已经执行完毕了,
所以当它们获取到 Mutex
锁之后其实什么也没有干。
但是它们的阻塞状态被解除了,可以继续往下执行。
总结
Once
保证了传入的函数只会执行一次,这常常用在一些初始化的场景、或者单例模式。Once
可以保证所有对Do
的并发调用都是安全的,所有对Once.Do
调用之后的操作,一定会在第一次对f
调用之后执行。(没有获取到f
执行权的 goroutine 会阻塞)- 即使
Once.Do
里面的f
出现了panic
,后续也不会再次调用f
。
到此这篇关于深入理解go sync.Once的具体使用的文章就介绍到这了,更多相关go sync.Once内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!
相关文章
Go框架三件套Gorm Kitex Hertz基本用法与常见API讲解
这篇文章主要为大家介绍了Go框架三件套Gorm Kitex Hertz的基本用法与常见API讲解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪<BR>2023-02-02go logger不侵入业务代码使用slog替换zap并实现callerSkip详解
这篇文章主要为大家介绍了go logger不侵入业务代码使用slog替换zap并实现callerSkip详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪2023-09-09
最新评论