Go sync.Pool

介绍

sync.Pool 是 sync 包提供的一个数据类型,是 go语言中的 临时对象池,它的值是用来存储一组可以独立访问的临时对象,它通过池化减少申请新对象,提升程序的性能。

go 复制代码
type Pool struct {

 New func() interface{}

}

// Put save x

func (p *Pool) Put(x interface{}) {}

// Get fetch x from pool

func (p *Pool) Get() interface{} {}
区分一下 Cache 和 Pool:
  • 缓存解决的是一个资源的获取代价(通常是网络延迟或复杂计算)比较昂贵,利用 localCache 或 远程缓存来减少 CPU 的开销
  • Pool 的场景是有相同内存结构的数据,被频繁创建和销毁,更多的是减少内存分配和 GC 的开销。

sync.Pool 使用场景

sync.Pool 的定位不是做类似连接池的东西,仅仅是增加对象重用的几率,减少 gc 的负担。

  • gin框架在 gin.go:L144/L346 中,通过 sync.Pool 对 Context 做了缓存
  • fmt.Printf 中通过 sync.Pool 缓存了 pp struct(newPrinter 函数中)
  • logrus 框架中缓存了 logger entry 对象
scss 复制代码
// https://github.com/gin-gonic/gin/blob/master/gin.go
// New init a new gin engine
func New() *Engine {
        debugPrintWARNINGNew()
        engine := &Engine{
            // init some fields
        }
        engine.pool = sync.Pool{}
        engine.pool.New = func() any {
            return engine.allocateContext()
        }
        return engine
}

// ServeHTTP conforms to the http.Handler interface.

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
        c := engine.pool.Get().(*Context)
        c.writermem.reset(w)
        c.Request = req
        c.reset()
        engine.handleHTTPRequest(c)
        engine.pool.Put(c)
}

Pool 设计思路

设计目标:

  • 内存复用,避免相同结构在并发场景被频繁构建和销毁
  • 减小竞争,尽可能减小锁粒度,甚至无锁

介绍官方实现之前,先想想如果我们自己实现,大概的思路吧:

  1. 由于大量相同结构,所以可以用 slice 去存储(当然链表也可以),用 data 表示
  2. 需要支持并发,那么就需要对 data 加锁,来保证并发安全
  3. 当 goroutine 足够多,都来竞争同一把锁,场面必定相当血腥。可以考虑拆分成多把小锁,即把 data 分成多个 shard,每个 shard 有一把锁
  4. 每个 shard 上可以进一步采取方案降低锁的竞争,甚至可以采用无锁编程的思想
  5. Goroutine 按照一定的规则去不同的 shard 取数据,锁的粒度减小了,竞争也减小了。

按照上面这几步,已经能够实现一个开销相对较小的 Pool 了,但是比较粗糙,还是有一些细节问题没有被解决,比如

  • 那么分配多少个 shard 合适呢?
  • Goroutine 去哪个 shard 取数据呢,按 gouroutine ID 取模?
  • 如果 shard 上请求分布不均,旱的旱死,涝的涝死,如何解决?
  • shard 如何扩缩容?
  • 有没有办法进一步减少竞争?
  • 如何回收?

Go 官方的实现思路大致和上面一致,不过有非常多巧妙的设计和实现细节,下面就来详细探索。

sync.Pool overview

下面是 sync.Pool 结构的概览:

大致描述下:

  • Local 是一个指针,指向一个全局的数组,也就是数组的每一个元素,都是一个 shard 分片,在代码的结构体是 poolLocal
    • poolLocal 指向一个链表,存储了表头和表尾,其中链表是双向链表,tail 代表最老的数据,head 代表最新的数据
    • 链表每个元素是一个由 slice 实现的 ring buffer,长度是 2 的 n 次幂,默认从 8 开始。如果放满了元素,那么从链表的 head 插入一个新的 ring buffer,长度是上一个的两倍。
  • localSize 代表 shard 的个数
  • Victim/VictimSize 结构 和 Local/LocalSize一样,用于淘汰策略

接下来对几个有意思的点讨论下:

  • GPM Processor 缓存
  • 双端队列
  • Ring buffer
  • nocopy
  • Pad

Go Processor 缓存

先看下 golang 的 GPM 调度模型 左边是 go 1.13 之前的调度版本,右边是增加了【Processor】概念的调度版本。

每个 P 上维护了一个 goroutine 的队列,这样在 goroutine 调度的时候,先争取粒度较小的 P 的锁,失败了再去争取全局的锁,达到减少竞争的作用。并且 P 的设计思想本身也是作为一层缓存,所以可以利用 P 去承载不同的 shard。

P 和 G (goroutine) 还有一层关系,那就是同一时间只会有一个 G 被挂在 P 上去执行,这方面语言层面做了保证,也就是说,如果 shard 放在 P 对象上面,那么当 G 被一组 P 和 M 执行到的时候,相当于已经独占了 P 的资源,对于 shard 的读取不用额外加锁了

双端队列

Go 官方大致思路和第一节一样,不过 sync.Pool 处理了不同 shard 分配不均的情况,这就要提到 sync.Pool 里用到的双端队列

G 会优先从当前关联的 P 上获取数据,当 G 在 当前 P 上的数据用完了,会优先去其他 P 上"偷取",示意如下:

这里有个需要强调的点是,每个 shard (双端队列) 同一时间只会有一个生产者和多个消费者,且生产者可以从队列的 head push 和 pop,消费者只能从队列的 tail pop 数据。

并且这里生产者和消费者的身份有限制,生产者只能是挂在 shard 所在 P 上的 G,消费者只能是其他 P 上的 G。

这样设计的原因也比较好理解,大多数情况下每个 shard 上的数据是够用的,不用去"借用"其他 shard 的数据,毕竟距离远,开销大,并且可能出现竞争

Ring buffer

到这里你可能会问:

  • 为什么每个 shard 上既用了链表,又用了数组,解决了什么问题?

pool 要解决的问题是,避免内存的频繁申请和回收,如果只使用一个链表,那么链表的新增和删除也是需要内存申请和回收的。如果只使用数组,那么数组长度应该是多少呢,扩容的时候会导致大量内存被拷贝,老数组的内存被回收。所以采用了综合的方式,减少了动态内存的分配和回收,又保证了可扩容。

  • 为什么链表元素是一个 ring buffer,本质上也是个 slice,和普通 slice 的区别?

为了减少竞争,前面说了为了减小竞争,把大锁拆成了几个小锁,但小锁的竞争也可能很大,官方在这里采用了无锁编程的思想。

无锁编程

首先要声明的是,无锁,不代表没有竞争。有锁,指的是有互斥,golang 里通常是 mutex。 最常用的无锁的方式是,采用 CAS 原子指令,去保证并行执行的原子性。伪代码如下:

ini 复制代码
for {
    oldValue = AtomLoad(&pointer)
    newValue = oldValue + 1
    ok := CAS(&pointer, oldValue, newValue)
    if ok {
        break
    }
}

这不就是乐观锁吗!没错,它的粒度很小,不会主动交出 CPU 执行权,goroutine 在 mutex 阻塞等待的时候,会被调度器置为休眠状态,从而交出 CPU 执行权。

虽然 原子操作粒度小,但不意味着性能一定强于互斥锁 ,还是要分场景!更多参考这里

无锁数据结构

无锁编程的精髓就是,把复杂的互斥操作,变成对少数关键字段的原子操作 Ring buffer 是一个典型的无锁数据结构,限制了只能对数组的头和尾进行操作,通过对头和尾的两个下标做原子操作,保证了并发安全和性能。

Nocopy

sync.Pool 里有这么个字段,在标准库其他包里也比较常见,意义很明确,就是防止结构体被复制。为什么会有这样的需求呢?其实关键在于,go 的传参传值都是值传递,参考如下case:

scss 复制代码
type X struct {
    data    []int
    lock    mutex
    ...
}

func (x X)Do(){
    x.lock.Lock()
    // do sth with data
}

func Test() {
    a := X{}
    Dosth(a)
}

很明显,a 和 b 两个对象的底层数据是同一份,但是本应该限制 data 操作的锁,变成了两个不同的锁,两个锁不知道对方的存在,导致对 data 的操作不再是并发安全的。

Golang 里的 nocopy 只是作为一个标识,并不会影响执行,只是用 govet 工具做静态检查的时候可以检查出包含 nocopy 对象的操作是否 ok。

Pad

在前面图里 poolLocal 结构中出现的 pad,是一段字节数组,但是没有任何代码用到了这个变量。那么为什么要放一个看起来毫无用处的变量呢?

这里涉及到伪共享 的问题,具体和 CPU 三级缓存有关: 简化示意图: CPU 读取内存的最小单位是一个缓存行,大小通常是 64 byte(也有 128 的),如果两个变量的内存地址正好在同一个缓存行,且这两个变量分别同时被两个 core 修改,那么各自 cache line 的数据都不是最新的,会导致数据不一致的问题。实际上不同 core 在修改缓存行的时候,会先和其他 core 确认当前缓存行的所有权,core 直接会发送消息去同步 cache line 的状态。

像这样因为缓存带来的额外的竞争,被称作伪共享。平时的业务代码一般不会去考虑这个问题,但在一些高性能的组件或框架设计的时候,需要考虑这个问题。解决方案比较简单粗暴,就是把可能被大量计算的,且内存紧密排布的变量,用一些额外的内存隔开,保证他们不在同一个 cache line。

关于伪共享的更多细节,可以参考这里

小结

sync.Pool 减少竞争的手段

  • 正常把数据分片,增加了锁的个数,减少了每把锁的竞争压力。sync.Pool 借助了 GMP 调度级别的策略,使得大多数情况下只有 1 个 goroutine访问当前分片,大大减少了竞争
  • 双端队列,当前 goroutine 读写 head 节点,其他 goroutine 读写 tail 节点,天然隔离,不需要锁
  • 使用 ringBuffer,即使当前 goroutine 和 其他 goroutine 竞争同一个节点的数据(在前面几点的作用下,走到这里的概率已经非常小了),在竞争较小时,ringbuffer cas 机制效率远远高于锁

源码解析

Pool

go 复制代码
type Pool struct {
 noCopy noCopy
 local     unsafe.Pointer // local fixed-size per-P pool, actual type is [P]poolLocal
   
 localSize uintptr        // size of the local array

 victim     unsafe.Pointer // local from previous cycle

 victimSize uintptr        // size of victims array

 // New optionally specifies a function to generate
 // a value when Get would otherwise return nil.
 // It may not be changed concurrently with calls to Get.
 New func() interface{}

}

 // Local per-P Pool appendix.
type poolLocalInternal struct {
 private interface{} // Can be used only by the respective P.
 shared  poolChain   // Local P can pushHead/popHead; any P can popTail.
}

type poolLocal struct {
 poolLocalInternal
 // Prevents false sharing on widespread platforms with
 // 128 mod (cache line size) = 0 .
 
 pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

理解上节的逻辑,这里的代码也很好理解。需要补充的细节就是,每个 shard 上都会有一个私有的对象 private,因为同一时刻只有一个 G 会占有当前 P,所以这个 private 对象是不需要竞争的,获取的成本非常小。private 为空,再去 share 的 ring buffer 里取获取数据。

Get

根据 overview 和 双端队列两个图,其实已经能大致推出 pool.Get 的流程逻辑了:

  1. 根据当前 G 获取当前 P,获取到 P 上的 shard(poolLocal)
  2. 若当前 shard 上有私有的数据(private),则获取并返回,若如,从 shard 链表的 head 上获取
  3. 找到 head,用 CAS 的方式无锁地从 ring buffer 获取数据
  4. Head 上获取不到数据,则依次从链表的前一个节点取
  5. 若当前 P 上的链表上都没有数据,那么从其他 P 上偷取,遍历其他所有 shard,不一样的是,从链表的 tail 到 head 的方向遍历
  6. 若其他 shard 上也没有,那么用 new 方法新生成一个对象返回

看看代码的更多细节吧:

go 复制代码
func (p *Pool) Get() interface{} {
   // 这段可以忽略
   if race.Enabled {
      race.Disable()
   }

   // 获取当前 P 的 ID 和 shard,并且会把当前 G 和 P 做一个强绑定,避免 P 被其他 G 抢占调度
   l, pid := p.pin() 
   x := l.private // 先检查 private 有没有,如果有,返回 private 指向的对象,并把 private 指针清空
   l.private = nil
   if x == nil {
      // Try to pop the head of the local shard. We prefer
      // the head over the tail for temporal locality of
      // reuse.
      // 如果 private 获取不到,就去当前 shard 的链表上依次取数据
      x, _ = l.shared.popHead() 
      if x == nil {
         // 当前 P 的 shard 没有数据,那么去其他 P 上'偷取'
         x = p.getSlow(pid) 
      }
   }

   runtime_procUnpin() // 取消 G 和 P 的强绑定,其他 G 可以抢占当前 P 了

   if race.Enabled {
      race.Enable()
      if x != nil {
         race.Acquire(poolRaceAddr(x))
      }
   }

   if x == nil && p.New != nil {
      x = p.New() // 所有 P 上都没有数据,新生成一个
   }
   return x
}

func (c *poolChain) popHead() (interface{}, bool) {
   // 遍历链表,从每个链表元素里的 ring buffer 取数据
   d := c.head
   for d != nil {
      if val, ok := d.popHead(); ok {
         return val, ok
      }
      // 获取链表下一个元素
      d = loadPoolChainElt(&d.prev)
   }
   return nil, false
}

Ring buffer

go 复制代码
type poolDequeue struct {

   // head 和 tail 放到同一个字段里,各占 32 bit,操作的时候通过原子操作读写

 headTail uint64

   // 每个 ring buffer 一旦生成,长度固定,不会扩容,是 sync.Pool 的底层存储

 vals []eface

}



func (d *poolDequeue) unpack(ptrs uint64) (head, tail uint32) {

   const mask = 1<<dequeueBits - 1

   head = uint32((ptrs >> dequeueBits) & mask)

   tail = uint32(ptrs & mask)

   return

}



func (d *poolDequeue) pack(head, tail uint32) uint64 {

   const mask = 1<<dequeueBits - 1

   return (uint64(head) << dequeueBits) |

      uint64(tail&mask)

}



func (d *poolDequeue) popHead() (interface{}, bool) {

   var slot *eface

   for {

      ptrs := atomic.LoadUint64(&d.headTail)

      head, tail := d.unpack(ptrs)

      if tail == head {

         // Queue is empty.

 return nil, false

      }



      // Confirm tail and decrement head. We do this before

 // reading the value to take back ownership of this

 // slot.

 head--

      ptrs2 := d.pack(head, tail)

      if atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) {

         // We successfully took back slot.

 slot = &d.vals[head&uint32(len(d.vals)-1)]

         break

      }

   }



   val := *(*interface{})(unsafe.Pointer(slot))

   if val == dequeueNil(nil) {

      val = nil

   }

   // Zero the slot. Unlike popTail, this isn't racing with

 // pushHead, so we don't need to be careful here.

 *slot = eface{}

   return val, true

}

作业:为什么要把 head 和 tail 放到同一个字段里,用的时候还需要打包和解包,为何要多此一举呢?

pin

特别关注一下 pin 这个方法,这里本来需要一把锁来保证并发安全,但是很巧妙的利用了 G 在 P 上的调度,确保了在 get 方法里,不会有其他 G 来竞争。

go 复制代码
func (p *Pool) pin() (*poolLocal, int) {

   pid := runtime_procPin() // runtime 实现的方法,绑定当前 G 和 P, 不会发生抢占

   // 获取当前 P 的数量

 s := runtime_LoadAcquintptr(&p.localSize) // load-acquire

 l := p.local                              // load-consume

 if uintptr(pid) < s {

      // 正常情况下会走到这里,indexLocal 只是对 unsafePointer 的转换,从 [P]poolLocal 数组获取 pid 下标对应的数据

      return indexLocal(l, pid), pid

   }

   // 创建 sync.Pool 对象的时候,并没有立即分配底层各个 shard 的内存,而是在第一次 Get/Put 的时候,发现 localSize==0,

   // 这时候会走到这一步,(如果 P 的数量发生了动态调整,也会走到这里),做的事情是,

   // 按照最新的 P 的数量重新分配一个 poolLocal 数组,然后返回当前 P 对应的 poolLocal.

   return p.pinSlow()

}



func (p *Pool) pinSlow() (*poolLocal, int) {

   // 因为重新生成 pool 的底层存储数组,会涉及到修改全局的 allPool,这里会有一把全局的大锁

   // 因为要加锁,所以必须先临时取消 G 和 P 的绑定,原因是:如果这把锁已经被当前 P 的其他 G 获取到了,

   // 当前 G 会阻塞,但是当前 G 又和 P 做了强绑定,不允许被抢占调度,那么实际上已经死锁了

 runtime_procUnpin()

   allPoolsMu.Lock()

   defer allPoolsMu.Unlock()

   pid := runtime_procPin()

   // poolCleanup won't be called while we are pinned.

 s := p.localSize

   l := p.local

   if uintptr(pid) < s {

      return indexLocal(l, pid), pid

   }

   if p.local == nil {

      allPools = append(allPools, p)

   }

   // If GOMAXPROCS changes between GCs, we re-allocate the array and lose the old one.

 size := runtime.GOMAXPROCS(0)

   local := make([]poolLocal, size)

   atomic.StorePointer(&p.local, unsafe.Pointer(&local[0])) // store-release

 runtime_StoreReluintptr(&p.localSize, uintptr(size))     // store-release

 return &local[pid], pid

}

可以看出 pinSlow 的开销不小,而由 pinSlow 执行的条件我们可以得到一些使用 sync.Pool 的建议:

  • 尽量不要在运行时动态修改 P 的数量(当然不管有没有用到 sync.Pool,这条建议都适用)
  • 不要大量创建 sync.Pool,尤其是不要对每个请求都生成一个,因为会操作全局锁,并发量一大,就会引起严重的阻塞

还有个细节是,pin 里 runtime_procPin 方法的调用,会强绑定 G 和 P,防止其他 G 抢占当前 P,这会导致 GC 无法执行。因为 GC 的逻辑是,先抢占所有 P 的执行权,再进行垃圾回收。

Put

知道了 get 了逻辑,pool.Put 的流程也都能推出来了:

  1. 根据当前 G 获取当前 P,获取到 P 上的 shard(poolLocal)
  2. 若当前 shard 上没有私有的数据(private),则把当前数据存到 private;若有,给 shard 链表的 head 节点插入数据
  3. 找到 head,用 CAS 的方式无锁地从 ring buffer 获取数据
  4. Head 上获取不到数据,则依次从链表的前一个节点取
  5. 若当前 P 上的链表上都没有数据,那么从其他 P 上偷取,遍历其他所有 shard,不一样的是,从链表的 tail 到 head 的方向遍历
  6. 若其他 shard 上也没有,那么用 new 方法新生成一个对象返回
scss 复制代码
 // Put adds x to the pool.

func (p *Pool) Put(x interface{}) {

   if x == nil {

      return

   }

   if race.Enabled {

      if fastrand()%4 == 0 {

         // Randomly drop x on floor.

 return

      }

      race.ReleaseMerge(poolRaceAddr(x))

      race.Disable()

   }

   // 获取当前 P 的 ID 和 shard,并且会把当前 G 和 P 做一个强绑定,避免 P 被其他 G 抢占调度

   l, _ := p.pin() 

   if l.private == nil { // get fast instance in current P

      l.private = x

      x = nil

   }

   if x != nil {

      l.shared.pushHead(x) 

   }

   runtime_procUnpin()

   if race.Enabled {

      race.Enable()

   }

}



// 找到链表上合适的节点,插入数据。

func (c *poolChain) pushHead(val interface{}) {

   d := c.head

   if d == nil { // 第一次插入,新建链表

      // Initialize the chain.

 const initSize = 8 // Must be a power of 2

 d = new(poolChainElt)

      d.vals = make([]eface, initSize)

      c.head = d

      storePoolChainElt(&c.tail, d)

   }



   if d.pushHead(val) { // 给当前节点的 ringbuffer 插入数据

      return

   }



   // The current dequeue is full. Allocate a new one of twice

 // the size.

 newSize := len(d.vals) * 2

   if newSize >= dequeueLimit {

      // Can't make it any bigger.

 newSize = dequeueLimit

   }



   d2 := &poolChainElt{prev: d}

   d2.vals = make([]eface, newSize)

   c.head = d2

   storePoolChainElt(&d.next, d2)

   d2.pushHead(val)

}
相关推荐
研究司马懿5 小时前
【云原生】Gateway API高级功能
云原生·go·gateway·k8s·gateway api
梦想很大很大19 小时前
使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)
前端·后端·go
lekami_兰1 天前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘1 天前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤1 天前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt112 天前
AI DDD重构实践
go
Grassto3 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto5 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室6 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题6 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo