Once
sync.Once 一般用来确保某个动作至多执行一次。普遍用于初始化资源,单例模式(对于一个结构体,永远只有一个实例)。
Once实现
结构体
type Once struct {
done uint32
m Mutex
}
sync.Once 的结构很简单,只有一个标记位和一个 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()
}
}
如上面的代码片段所示, sync.Once 的实现也很简单,类似于DoubleCheck,没有直接使用锁,而是使用原子操作来读写标记位 done ,在 Do 方法中,先利用原子操作读取标记位(类似于先加读锁,检查是否存在),而后,在 doSlow 方法中,加上锁之后,再次判断标记位 done(等同于加上写锁之后,再次进行判断),然后进行对标记位进行写入。
Once的使用
在正式讲使用之前,有下面的一段代码值得,我们思考,在这段代码中的输出是什么呢?
type Single struct {
once sync.Once
}
func (s *Single) DoV1() {
s.once.Do(func() {
fmt.Println("Do1")
})
}
func (s Single) DoV2() {
s.once.Do(func() {
fmt.Println("Do2")
})
}
func main() {
s := &Single{}
for i := 0; i < 5; i++ {
s.DoV1()
}
s2 := Single{}
for i := 0; i < 5; i++ {
s2.DoV2()
}
}
通过我们具体的执行,我们可以看到上面的输出结果为
Do1
Do2
Do2
Do2
Do2
Do2
从上面的结果中我们不难看出来,只有使用指针时,Once 才会达成我们想要的结果。之所以会这样,是因为如果不是指针的话,每次调用相当于将结构体复制了一次,在真正调用 Do 方法的时候,我们是在副本上进行的操作。
所以,这一点我们需要注意:在使用 Once 的时候,我们应该使用指针。
在我们实际的使用中,sync.Once 一般用于资源或者其他结构体的初始化阶段。sync.Once 一般用于原子性的初始化,而对于包级别的初始化动作,我们一般使用 init 函数来进行处理。从功能上讲,init 函数和 sync.Once 是等价的。
Pool
从它的名字我们就可以知道,sync.Pool 是一个池,而池最主要的作用就是——资源的复用。
一般情况下,如果要考虑缓存资源,比如说创建好的对象,那么可以使用 sync.Pool:
sync.Pool会先查看自己是否有资源,有则直接返回- 没有则创建一个新的
sync.Pool会在 GC 的时候释放缓存的资源
我们在日常的使用当中,使用 sync.Pool 都是为了复用内存,因为它减小内存分配,从而减轻了GC的压力;减小了CPU压力(GC与内存分配都是CPU密集操作)。
Pool实现细节
在了解 sync.Pool 之前,我们应先了解Go的 GMP 调度模型:其中 P 代表的是处理器,在 GMP 模型中,任何绑定在 P 上的数据,都不需要竞争,因为在 P 上,同一时间,只有一个 G(goroutine)在运行。
简单了解Go的
GMP 模型之后,再来看 sync.Pool 的设计(如上图所示):
- 每个
P一个poolLocal对象以及一个victim对象 - 每个
poolLocal有一个private和shared shared指向的是一个poolChain;poolChain的数据会被别的P给偷走poolChain是一个链表 +ring buffer的双重结构- 从整体上来说,它是一个双向链表
- 从单个节点来说,它指向了一个
ring buffer。后一个节点的ring buffer都是前一个节点的两倍
Pool与GC
sync.Pool 作为一个资源池,它的容量不可能无限大,这就涉及到了它的容量与淘汰问题。Go中的 sync.Pool 容量控制完全依赖于Go的GC,它的核心机制,就是上图中的 local 与 victim 两部分。
在触发GC的过程中,GC会将 victim 进行回收并将 sync.Pool 中的 local 变为 victim ,也就是说 sync.Pool 中的资源得进行两次GC才能完成回收。但是,为了避免GC资源回收后,Pool 又大量创建带来的性能抖动问题,sync.Pool 中的 victim 部分有所谓的 复活 机制,即如果 victim 中的资源又一次被用到,则该资源会被放到 local 中,使其逃过下次GC的回收。
Get方法
基于上图,sync.Pool 的 Get 方法有如下步骤:
- 看
private可不可用,可用就直接返回 - 不可用则从自己的
poolChain里面尝试获取一个- 从头开始找。注意,头指向的其实是最近创建的
ring buffer - 从队头往队尾找
- 从头开始找。注意,头指向的其实是最近创建的
- 找不到则尝试从别的
P里面偷一个出来。偷的过程就是全局并发,因为理论上来说,其它P都可能恰好一起来偷了;偷是从队尾偷的 - 如果偷也偷不到,那么就会去找
victim的 - 连
victim的也没有,那就去创建一个新的
Put方法
private中如果没有资源,就直接放private中- 否则,准备放
poolChain- 如果
poolChain的 HEAD 还没创建,就创建一个HEAD,然后创建一个 8 容量的ring buffer,把资源放进去 - 如果
poolChain的 HEAD 指向的ring buffer没满,则将资源放进ring buffer - 如果
poolChain的 HEAD 指向的ring buffer已经满了,就创建一个新的节点,并且创建一个两倍容量的ring buffer,把资源放进去
- 如果
Pool的使用
sync.Pool 的使用是非常简单的:
type MyPool struct {
p sync.Pool
}
func NewMyPool(fn func() any) *MyPool {
return &MyPool{
p: sync.Pool{
New: fn,
},
}
}
func (p *MyPool) Get() any {
return p.p.Get()
}
func (p *MyPool) Put(val any) {
p.p.Put(val)
}
sync.Pool 在大多数场景下是可以直接用的,但是在某些特定场景下,我们需要考虑以下的一些问题:
- 如果一个
buffer占据了很多内存,要不要放回去? - 怎么控制整个池的内存使用量?因为依托于 GC 是比较不可控的:
- 控制单个
buffer上限? - 控制
buffer数量? - 控制总体内存?
- 控制单个
WaitGroup
WaitGroup 是用于同步多个 goroutine 之间工作的,常见的场景就是将一个任务拆分成多个 goroutine 并行完成,等所有任务完成之后对结果进行聚合再进入下一步。
结构体
type WaitGroup struct {
noCopy noCopy
state1 uint64
state2 uint32
}
type noCopy struct{}
func (*noCopy) Lock() {}
func (*noCopy) Unlock() {}
sync.WaitGroup 主要有三个字段:
noCopy: 主要用于告诉编译器说这个东西不能复制(在不使用指针的情况下会触发值传递(copy))。在 sync 包里面很多结构体有这个字段。state1: 在64 位下,高 32 位记录了还有多少任务在运行;低 32 位记录了有多少 goroutine 在等 Wait() 方法返回state2: 信号量,用于挂起或者唤醒goroutine,约等于 Mutex 里面的 sema 字段
Add与Done方法
Add 与 Done 实际上是一个方法,Done 是内部调用的 Add(-1) ,所以,这里只对 Add 方法进行讨论。
Add 方法本质上就是利用原子操作在 state1 的高 32 位自增1
func (wg *WaitGroup) Add(delta int) {
statep, semap := wg.state()
if race.Enabled {
_ = *statep // trigger nil deref early
if delta < 0 {
race.ReleaseMerge(unsafe.Pointer(wg))
}
race.Disable()
defer race.Enable()
}
// 操作高32位,对delta左移32位进行加法
state := atomic.AddUint64(statep, uint64(delta)<<32)
v := int32(state >> 32)
w := uint32(state)
// 对状态的一些判断
...
// 当计数器降为 0 时,即所有任务都已经完成,唤醒已经休眠的 goroutine
*statep = 0
for ; w != 0; w-- {
runtime_Semrelease(semap, false, 0)
}
}
Wait方法
Wait 看上去就是 state1 的低 32 位自增 1, 同时利用 state2 和 runtime_Semacquire 调用把当前 goroutine 挂起。
func (wg *WaitGroup) Wait() {
statep, semap := wg.state()
if race.Enabled {
_ = *statep // trigger nil deref early
race.Disable()
}
for {
state := atomic.LoadUint64(statep)
v := int32(state >> 32)
w := uint32(state)
if v == 0 {
if race.Enabled {
race.Enable()
race.Acquire(unsafe.Pointer(wg))
}
return
}
if atomic.CompareAndSwapUint64(statep, state, state+1) {
if race.Enabled && w == 0 {
race.Write(unsafe.Pointer(semap))
}
runtime_Semacquire(semap)
if *statep != 0 {
panic("sync: WaitGroup is reused before previous Wait has returned")
}
if race.Enabled {
race.Enable()
race.Acquire(unsafe.Pointer(wg))
}
return
}
}
}
WaitGroup的使用
sync.WaitGroup 的使用也非常简单,只需要在启动一个新的 goroutine 之前调用 Add 方法,完成时调用 Done 即可,最后在需要等待的地方调用 Wait 方法。
wg := sync.WaitGroup{}
var result int64 = 0
for i := 0; i < 10; i++ {
wg.Add(1)
go func(delta int) {
defer wg.Done()
atomic.AddInt64(&result, int64(delta))
}(i)
}
wg.Wait()