sync包(二)—— Once、Pool与WaitGroup

285 阅读2分钟

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)在运行。

image.png 简单了解Go的 GMP 模型之后,再来看 sync.Pool 的设计(如上图所示):

  • 每个 P 一个 poolLocal 对象以及一个 victim 对象
  • 每个 poolLocal 有一个 privateshared
  • shared 指向的是一个 poolChainpoolChain 的数据会被别的 P 给偷走
  • poolChain 是一个链表 + ring buffer 的双重结构
    • 从整体上来说,它是一个双向链表
    • 从单个节点来说,它指向了一个 ring buffer。后一个节点的 ring buffer 都是前一个节点的两倍

Pool与GC

sync.Pool 作为一个资源池,它的容量不可能无限大,这就涉及到了它的容量与淘汰问题。Go中的 sync.Pool 容量控制完全依赖于Go的GC,它的核心机制,就是上图中的 localvictim 两部分。

在触发GC的过程中,GC会将 victim 进行回收并将 sync.Pool 中的 local 变为 victim ,也就是说 sync.Pool 中的资源得进行两次GC才能完成回收。但是,为了避免GC资源回收后,Pool 又大量创建带来的性能抖动问题,sync.Pool 中的 victim 部分有所谓的 复活 机制,即如果 victim 中的资源又一次被用到,则该资源会被放到 local 中,使其逃过下次GC的回收。

Get方法

基于上图,sync.PoolGet 方法有如下步骤:

  1. private 可不可用,可用就直接返回
  2. 不可用则从自己的 poolChain 里面尝试获取一个
    • 从头开始找。注意,头指向的其实是最近创建的 ring buffer
    • 从队头往队尾找
  3. 找不到则尝试从别的 P 里面偷一个出来。偷的过程就是全局并发,因为理论上来说,其它 P 都可能恰好一起来偷了;偷是从队尾偷的
  4. 如果偷也偷不到,那么就会去找 victim
  5. victim 的也没有,那就去创建一个新的

Put方法

  1. private 中如果没有资源,就直接放 private
  2. 否则,准备放 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 在大多数场景下是可以直接用的,但是在某些特定场景下,我们需要考虑以下的一些问题:

  1. 如果一个 buffer 占据了很多内存,要不要放回去?
  2. 怎么控制整个池的内存使用量?因为依托于 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方法

AddDone 实际上是一个方法,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, 同时利用 state2runtime_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()