【Go并发编程】sync.Pool源码阅读

18 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 22 天,点击查看活动详情

sync.Pool

sync.Pool是Go语言提供的一个对象池实现,它可以用来缓存一些可重用的对象,以提高内存分配和垃圾回收的效率。

使用sync.Pool可以避免一些反复分配和回收的操作,提高程序的性能和效率。使用sync.Pool时需要注意以下几点:

  1. 对象池不是用来做对象缓存的,对象池里的对象会随时被删除,无法长时间存储。
  2. 对象池不能替代传统的对象缓存方案,例如Redis、Memcached等缓存方案。
  3. 对象池不能代替GC,它只是用来减少分配和回收操作的次数,以提高程序的性能和效率。

下面是一个使用sync.Pool的例子,该例子创建一个对象池,用于缓存字符串切片对象,以提高字符串切片对象的分配和回收效率:

func main() {
    // 创建一个对象池,用于缓存字符串切片对象
    pool := &sync.Pool{
        New: func() interface{} {
            return make([]string, 0, 10)
        },
    }

    // 从对象池中获取一个字符串切片对象
    strs := pool.Get().([]string)
    fmt.Printf("len=%d, cap=%d\n", len(strs), cap(strs))

    // 往字符串切片中添加一些数据
    strs = append(strs, "hello", "world")
    fmt.Printf("len=%d, cap=%d, strs=%v\n", len(strs), cap(strs), strs)

    // 把字符串切片放回对象池中
    pool.Put(strs)

    // 再次从对象池中获取一个字符串切片对象
    strs = pool.Get().([]string)
    fmt.Printf("len=%d, cap=%d, strs=%v\n", len(strs), cap(strs), strs)
}

pool字符串缓存的效率对比

func Benchmark_without_pool(b *testing.B) {
   for i := 0; i < b.N; i++ {
      // 新建字符串切片对象
      strs := make([]string, 0, StrSize)
      // 往字符串切片中添加一些数据
      strs = append(strs, "hello", "world")
   }
}

func Benchmark_pool(b *testing.B) {
   // 创建一个对象池,用于缓存字符串切片对象
   pool := &sync.Pool{
      New: func() interface{} {
         return make([]string, 0, StrSize)
      },
   }
   for i := 0; i < b.N; i++ {
      // 从对象池中获取一个字符串切片对象
      strs := pool.Get().([]string)
      // 往字符串切片中添加一些数据
      strs = append(strs, "hello", "world")
      // 把字符串切片放回对象池中
      pool.Put(strs)
   }
}

结果:

lengthpool ns/opwithout pool ns/op
10139.10.2341
100158.90.2374
1000120.80.2295
10000121.611716
100000119.985328
1000000103.21279465

可以看到字符串比较短时,go 运行时的string缓冲可以保证速度,但是当长度达到5000后,pool的优势就体现出来了,同时也可以看到,pool的基本调用,时间消耗约100ns。

源码阅读

type Pool struct {
   noCopy noCopy
   local     unsafe.Pointer 
   localSize uintptr
   victim     unsafe.Pointer 
   victimSize uintptr
   New func() any
}
  • noCopy:用于检测Pool是否被复制
  • local是每个P(处理器)的固定大小池的指针,实际类型为[P]poolLocal。
  • localSize是本地数组的大小。
  • victim是上一个周期的本地池的指针。
  • victimSize是victim数组的大小。
  • New可选地指定一个函数,以在Get返回nil时生成一个值。 它不能与调用Get并发地更改。

每次垃圾回收的时候,Pool 会把 victim 中的对象移除,然后把 local 的数据给 victim,这样的话,local 就会被清空,而 victim 就像一个垃圾分拣站,里面的东西可能会被当做垃圾丢弃了,但是里面有用的东西也可能被捡回来重新使用。

victim 中的元素如果被 Get 取走,那么这个元素就很幸运,因为它又“活”过来了。但是,如果这个时候 Get 的并发不是很大,元素没有被 Get 取走,那么就会被移除掉,因为没有别人引用它的话,就会被垃圾回收掉。

get

func (p *Pool) Get() any {
   // 把当前goroutine固定在当前的P上
   l, pid := p.pin()
   // 优先从local的private字段取,快速
   x := l.private
   l.private = nil
   if x == nil {
      // 从当前的local.shared弹出一个,注意是从head读取并移除
      x, _ = l.shared.popHead()
      if x == nil {
         // 如果没有,则去偷一个
         x = p.getSlow(pid)
      }
   }
   runtime_procUnpin()
   // 如果没有获取到,尝试使用New函数生成一个新的
   if x == nil && p.New != nil {
      x = p.New()
   }
   return x
}

Get:

  • 从本地的 private 字段中获取可用元素,因为没有锁,获取元素的过程会非常快
  • 如果没有获取到,就尝试从本地的 shared 获取一个
  • 如果还没有,会使用 getSlow 方法去其它的 shared 中“偷”一个。
  • 最后,如果没有获取到,就尝试使用 New 函数创建一个新的
func (p *Pool) getSlow(pid int) any {
   size := runtime_LoadAcquintptr(&p.localSize) // load-acquire
   locals := p.local                            // load-consume
   // 从其它proc中尝试偷取一个元素
   for i := 0; i < int(size); i++ {
      l := indexLocal(locals, (pid+i+1)%int(size))
      if x, _ := l.shared.popTail(); x != nil {
         return x
      }
   }

   // 如果其它proc也没有可用元素,那么尝试从vintim中获取
   size = atomic.LoadUintptr(&p.victimSize)
   if uintptr(pid) >= size {
      return nil
   }
   locals = p.victim
   l := indexLocal(locals, pid)
   // 同样的逻辑,先从vintim中的local private获取
   if x := l.private; x != nil {
      l.private = nil
      return x
   }
   // 从vintim其它proc尝试偷取
   for i := 0; i < int(size); i++ {
      l := indexLocal(locals, (pid+i)%int(size))
      if x, _ := l.shared.popTail(); x != nil {
         return x
      }
   }

   // 如果victim中都没有,则把这个victim标记为空,以后的查找可以快速跳过了
   atomic.StoreUintptr(&p.victimSize, 0)

   return nil
}
func (p *Pool) pin() (*poolLocal, int) {
   pid := runtime_procPin()
   // 在 pinSlow 函数中我们先将数据存储到 local 内存指针数组,再存储到 localSize 变量中,而这里我们则是相反的顺序进行加载。
   // 由于我们已经禁用了抢占,因此垃圾回收器无法在其中发生。
   // 因此,在这里我们必须至少观察到 local 大小不小于 localSize。
   // 我们可以观察到一个更新/更大的 local,这是可以的(我们必须观察到它被初始化为零)。
   s := runtime_LoadAcquintptr(&p.localSize) // load-acquire
   l := p.local                              // load-consume
   if uintptr(pid) < s {
      return indexLocal(l, pid), pid
   }
   return p.pinSlow()
}

Put

func (p *Pool) Put(x any) {
   // nil值直接丢弃
   if x == nil {
      return
   }

   l, _ := p.pin()
   // 如果本地private没有值,直接设置这个值即可
   if l.private == nil {
      l.private = x
   } else {
      // 否则加入到本地队列中
      l.shared.pushHead(x)
   }
   runtime_procUnpin()
}

Put:

  1. 优先放到本地private
  2. 如果 private 字段已经有值了,那么就把此元素 push 到本地队列中

clean

func poolCleanup() {
   // 丢弃当前victim, STW所以不用加锁
   for _, p := range oldPools {
      p.victim = nil
      p.victimSize = 0
   }

   // 将local复制给victim, 并将原local置为nil
   for _, p := range allPools {
      p.victim = p.local
      p.victimSize = p.localSize
      p.local = nil
      p.localSize = 0
   }

   oldPools, allPools = allPools, nil
}

cleanup:

  • victim清空,local放到victim
  • local清空

总结

sync.Pool内部使用了两个数据结构:localvictimlocal是一个指针数组,每个元素都是一个临时对象池,其中存放了多个可重用的临时对象。victim是一个指针,它指向上一次GC时没有被清理的临时对象,这些对象有可能被其他的sync.Pool实例重复利用。

sync.Pool实现了两个方法:GetPut,用于获取和释放临时对象。

当调用Get方法时,sync.Pool会首先尝试从当前goroutine对应的local对象池中获取一个可重用的临时对象,如果获取成功,则直接返回该对象;否则会尝试从victim指向的对象池中获取一个可重用的临时对象,如果获取成功,则直接返回该对象;最后,如果没有可重用的对象,则根据New方法的设置,创建一个新的对象返回。

当调用Put方法时,sync.Pool会将临时对象放回当前goroutine对应的local对象池中,如果local对象池已经满了,则会将其中一些对象放入victim指向的对象池中,等待下次被重用。

在每次GC之前,sync.Pool会调用release方法来释放所有被临时对象池持有的对象,以便这些对象可以被垃圾回收器回收。

sync.Pool通过使用对象池技术来避免频繁创建和销毁临时对象,从而提高应用程序的性能。其实现原理主要包括了两个数据结构localvictim,以及GetPutrelease等方法,通过这些方法实现了临时对象的获取、释放和清理等功