Go同步原语之sync/Pool

19 篇文章 0 订阅
4 篇文章 0 订阅

基本介绍与用法

在高并发场景下,我们会遇到很多问题,垃圾回收(GC)就是其中之一。

Go 语言中的垃圾回收是自动执行的,这有利有弊,好处是避免了程序员手动对垃圾进行回收,简化了代码编写和维护,坏处是垃圾回收的时机无处不在,这在无形之中增加了系统运行时开销。在对系统性能要求较高的高并发场景下,这是我们应该主动去避免的,因此这需要对对象进行重复利用,以避免产生太多垃圾,而这也就引入了我们今天要讨论的主题 —— sync 包提供的 Pool 类型。

sync.Pool 数据类型用来保存一组可独立访问的临时对象。请注意这里加粗的“临时”这两个字,它说明了 sync.Pool 这个数据类型的特点,也就是说,它池化的对象会在未来的某个时候被毫无预兆地移除掉。而且,如果没有别的对象引用这个被移除的对象的话,这个被移除的对象就会被垃圾回收掉。

来看看sync.Pool使用的一般场景:

  1. 临时对象池:当你的程序需要频繁创建和销毁临时对象时,可以使用 sync.Pool 来管理这些对象。这可以帮助减少内存分配和垃圾回收的负担,提高程序性能。
  2. 减少内存分配:通过在 sync.Pool 中缓存对象,可以避免频繁地进行内存分配。这对于内存敏感的应用程序特别有用,如高性能服务器或实时应用。
  3. 短生命周期对象sync.Pool 特别适用于那些对象生命周期非常短暂的情况,因为它们可以被有效地重用而不必等待垃圾回收。
  4. 连接池sync.Pool 可以用于管理连接池,比如数据库连接、网络连接等。这可以减少每次请求时创建和销毁连接的开销,提高连接的重用性。
  5. 临时数据缓存:如果你需要在某个计算中保存临时数据,但不希望它们一直占用内存,可以将这些临时数据存放在 sync.Pool 中,当计算完成后释放这些数据。

因为 Pool 可以有效地减少新对象的申请,从而提高程序性能,所以 Go 内部库也用到了 sync.Pool,比如 fmt 包,它会使用一个动态大小的 buffer 池做输出缓存,当大量的 goroutine 并发输出的时候,就会创建比较多的 buffer,并且在不需要的时候回收掉。

sync.Pool 还有以下特点:

  1. 线程安全sync.Pool 内部使用了同步机制,因此可以安全地在多个 goroutine 中使用。
  2. 临时对象的缓存sync.Pool 通常用于缓存临时对象,这些对象在某个阶段被频繁创建和销毁,但其生命周期很短。
  3. 自动回收sync.Pool 并不保证缓存的对象一定会一直保留,它有可能随时清理缓存中的对象,因此不能将其用于长期持有对象。
  4. 零值sync.Pool 的零值是一个可用的空池,因此在不初始化的情况下可以安全地使用。
  5. 性能提升:通过缓存和复用对象,sync.Pool 可以有效地减少内存分配和垃圾回收的开销,从而提高程序的性能。

sync.Pool 主要对外暴露三个接口:

func (p *Pool) Put(x interface{}) {...}
func (p *Pool) Get() interface{} {...}
New func() interface{}
  • Get 方法会返回 Pool 已经存在的对象;
  • Put 将一个元素返还给 PoolPool 会把这个元素保存到池中,并且可以复用。但如果 Put 一个 nil 值,Pool 就会忽略这个值;
  • New 创建一个 Pool 实例,关键一点是配置 New 方法, 当Pool池子没有可Get对象,则会返回New方法的返回值。

下面是一个实例基本代码:

package main

import (
	"fmt"
	"sync"
)

type MyObject struct {
	value int
}

func main() {
	// 创建一个对象池
	objectPool := sync.Pool{
		New: func() interface{} {
			fmt.Println("Creating a new object")
			return &MyObject{}
		},
	}

	// 从池中获取对象
	obj1 := objectPool.Get().(*MyObject)
	obj1.value = 42
	fmt.Println("Object 1:", obj1)

	// 归还对象到池中
	objectPool.Put(obj1)

	// 再次从池中获取对象,应该是同一个对象
	obj2 := objectPool.Get().(*MyObject)
	fmt.Println("Object 2:", obj2)
}

在上面的示例中,我们创建了一个 sync.Pool,并使用 New 函数指定了如何创建新的对象。通过调用 Get 方法可以从池中获取对象,而通过 Put 方法可以将对象放回池中。

需要注意的是,池中的对象并不保证一直可用,可以被随时清理,因此不能依赖于对象的持久性。此外,sync.Pool 并不适用于所有场景,主要用于管理具有短生命周期的对象。

几个问题思考

  • 问题一: 为什么用 Pool,而不是在运行的时候直接实例化对象呢?

    示例:

    import (
    	"fmt"
    	"sync"
    	"sync/atomic"
    )
    
    //用来存放createBuffer创建次数
    var numCalcsCreated int32
    
    //创建一个大小为1024的buffer对象
    func createBuffer() interface{} {
    	atomic.AddInt32(&numCalcsCreated, 1) //计数+1
    	buffer := make([]byte, 1024)
    	return buffer
    }
    
    func main() {
    
    	//创建 buffPool 对象
    	buffPool := &sync.Pool{
    		New: createBuffer,
    	}
    
    	//goroutine 数量
    	numWorks := 1024 * 1024
    	var wg sync.WaitGroup
    	wg.Add(numWorks)
      
    	//多goroutine并发测试
    	for i := 0; i < numWorks; i++ {
    		go func() {
    			defer wg.Done()
    			//申请一个buffer实例
    			buffer := buffPool.Get()
    			_ = buffer.([]byte)
    			//返还一个buffer实例
    			defer buffPool.Put(buffer)
    		}()
    	}
    	wg.Wait()
    	fmt.Printf("%d buffer objects were created.\n", numCalcsCreated)
    }
    

    输出结果:

    root# go run main.go
    4 buffer objects were created.
    root#go run main.go
    3 buffer objects were created.
    

    程序 go run 运行了两次,一次结果是 3 ,一次是 4 。这个是什么原因呢?

    首先,这个是正常的情况,不知道你有没有注意到,创建 Pool 实例的时候,只要求填充了 New 函数,而根本没有声明或者限制这个 Pool 的大小。所以,记住一点,程序员作为使用方不能对 Pool 里面的元素个数做假定

    我们再来更改以下代码:

    buffer := buffPool.Get()    =>   buffer := createBuffer()
    

    这个时候,我们再执行程序 , 结果:

    root# go run .\main.go
    1048576  buffer objects were created.
    root#go run .\main.go
    1048576  buffer objects were created.
    

    注意到,和之前有两个不同点

    1. 同样也是运行两次,两次结果相同。
    2. 对象创建的数量和并发 Worker 数量相同,数量等于 1048576 (这个就是 1024*1024);

    原因很简单,因为每次都是直接调用 createBuffer 函数申请 buffer,有 1048576 个并发 Worker 调用,所以跑多少次结果都会是 1048576

    实际上还有一个不同点,就是程序跑的过程中,该进程分配消耗的内存很大。因为 Go 申请内存是程序员触发的,回收却是 Go 内部 runtime GC 回收器来执行的,这是一个异步的操作。这种业务不负责任的内存使用会对 GC 带来非常大的负担,进而影响整体程序的性能。

    类比现实的例子,一个程序猿喝奶茶,需要一个吸管(吸管类比就是我们代码里的 buffer 对象喽),奶茶喝完吸管就扔了,那就是塑料垃圾了( Garbage )。清洁工老李( GC 回收器 )需要紧跟在后面打扫卫生,现在 1048576 个程序猿同时喝奶茶,每个人都现场要一根新吸管,喝完就扔,马上地上有 1048576 个塑料吸管垃圾。清洁工老李估计要累个半死。

    那如果,现在在某个隐秘的角落放一个回收箱 ( 类比成 sync.Pool ) ,程序员喝完奶茶之后,吸管就丢到回收箱里,下一个程序员要用吸管的话,伸手进箱子摸一下,看下有管子吗?有的话,就拿来用了。没有的话,就再找人要一根新吸管。这样新吸管的使用数量就大大减少了呀,地上也没垃圾了,老李也轻松了,多好呀。

    并且,极限情况下,如果大家喝奶茶足够快,保证箱子里每时每刻都至少有一根用过的吸管,那 1048576 个程序员估计用一根吸管都够了。。。。(有点想吐。。。)

    回归正题,这就也解释了,为什么使用 sync.Pool 之后数量只有 34 个。但是进一步思考:为什么 sync.Pool 的两次使用结果输出不不一样呢?

    因为复用的速度不一样我们不能对 Pool 池里的 cache 的元素个数做任何假设。不过还是那句话,如果速度足够快,其实里面可以只有一个元素就可以服务 1048576 个并发的 Goroutine

  • 问题二sync.Pool 是并发安全的吗?

    sync.Pool 当然是并发安全的。官方文档里明确说了:

    A Pool is safe for use by multiple goroutines simultaneously.

    sync.Pool 只是本身的 Pool 数据结构是并发安全的,并不是说 Pool.New 函数一定是线程安全的。Pool.New 函数可能会被并发调用 ,如果 New 函数里面的实现是非并发安全的,那就会有问题。

    上面的代码例子里,关于 createBuffer 函数的实现里,对于 numCalcsCreated 的计数加是用原子操作的:atomic.AddInt32(&numCalcsCreated, 1) 。因为 numCalcsCreated 是个全局变量,Pool.New( 也就是 createBuffer ) 并发调用的时候,会导致 data race ,所以只有用原子操作才能保证数据的正确性。

    我们可以尝试下,把 atomic.AddInt32(&numCalcsCreated, 1) 这样代码改成 numCalcsCreated++ ,然后用 go run -race main.go 命令检查一下,肯定会报告告警的,类似如下:

    root # go run -race main.go
    ==================
    WARNING: DATA RACE
    Read at 0x000000d61e20 by goroutine 8:
      main.createBuffer()
    ............................
    

    本质原因:Pool.New 函数可能会被并发调用。

  • 问题三: 为什么 sync.Pool 不适合用于像 socket 长连接或数据库连接池?

    因为,我们不能对 sync.Pool 中保存的元素做任何假设,以下事情是都可以发生的:

    1. Pool 池里的元素随时可能释放掉,释放策略完全由 runtime 内部管理;
    2. Get 获取到的元素对象可能是刚创建的,也可能是之前创建好 cache 住的。使用者无法区分;
    3. Pool 池里面的元素个数你无法知道;

    所以,只有的你的场景满足以上的假定,才能正确的使用 Poolsync.Pool 本质用途是增加临时对象的重用率,减少 GC 负担。划重点:临时对象。所以说,像 socket 这种带状态的,长期有效的资源是不适合 Pool 的。

结构体

sync.Pool 是一个临时对象池。一句话来概括,sync.Pool 管理了一组临时对象,当需要时从池中获取,使用完毕后从再放回池中,以供他人使用。

sync.Pool 结构体定义如下:

//go 1.20.3 path:src/sync/pool.go

type Pool struct {
	noCopy noCopy
	local     unsafe.Pointer 
	localSize uintptr
	victim     unsafe.Pointer 
	victimSize uintptr
	New func() any  //当缓存池无对应对象时调用
}
  • noCopy 用于检测 Pool 池是否被 copyPool 不希望被 copy,这个阻止不了编译,只能通过 go vet 检查出来;

  • local 一个指向poolLocal 数组的指针;

  • localSize local指针指向的poolLocal 数组长度,其数值等于等于P的个数,即 GOMAXPROCES 设置数量poolLocal 数组的长度;

  • victim victim会在一轮GC到来的时候做两件事: 1. 一个是释放自己占用的内存 , 2. 另外一个是接管local

    victim 的目的是为了减少 GC 后冷启动导致的性能抖动,让分配对象更平滑;

    victim 机制是把 Pool 池的清理由一轮 GC 改成两轮 GC,进而提高对象的复用率,减少抖动;

  • victimSize GC时候会接管 localSize,与victim配合使用;

  • **New ** 使用方只能赋值 New 字段,定义对象初始化构造行为。

再来看看 sync.Pool.local所指向的poolLocal 结构:

//go 1.20.3 path:src/sync/pool.go
type poolLocal struct {
	poolLocalInternal  //内嵌poolLocalInternal
	pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}
  • pad

    pad数组就是用来防止false sharing的, 将 poolLocal 补齐至两个缓存行的倍数, 一般CPU读取缓存的单位为:cache line,一个cache line大小为 64 byte, 所以补足2个缓存行需要 128 byte,也是为了提升频繁存取的性能。

    关于false sharing相关内容可以参考:http://ifeve.com/falsesharing/,此处不做详细说明。

  • poolLocalInternalpoolLocal的内部表示。主要是为了避免在池中的所有对象上分配一个指针。

    其结构体代码为:

    //go 1.20.3 path:src/sync/pool.go
    type poolLocalInternal struct {
    	private any
    	shared  poolChain
    }
    
    • private 一个是私有对象,只能被局部调度器P使用;

    • shared 共享列表对象, 可以被任何P访问 , 双链表结构,用于挂接 cache 元素。

      shared字段定义为poolChain类型,该结构体定义如下:

      //go 1.20.3 path:src/sync/poolqueue.go
      type poolChain struct {
      	head *poolChainElt
      	tail *poolChainElt
      }
      
      type poolChainElt struct {
      	poolDequeue
      	next, prev *poolChainElt
      }
      
      type poolDequeue struct {
      	headTail uint64
      	vals []eface
      }
      
      type eface struct {
      	typ, val unsafe.Pointer
      }
      
      • poolChain 一个双向链表,tail指向最后一个poolChainElt元素,head指向第一个poolChainElt元素;

      • poolChainElt 包含一个poolDequeue环形队列,用于存储对象,而next, prev用于链接poolChainElt元素。

      • poolDequeue 算是个逻辑上的环形数组,管理成 ring buffer 的模式。

        • vals 存储着实际的值

        • headTail headTail字段将首尾索引融合在一起,高32位用来记录环head的位置,低32位用来记录环tail的位置, 而且head代表的是当前要写入的位置,所以实际的存储区间是[tail, head),当headtail指向同一位置则表示环形数组为空。

          headTail示意图如下:

          image-20230913120811437

        再用一张图来看看环形队列,即ring buffer

        image-20230913151403054

        poolDequeue使用ring buffer+headTail结合的方法有什么好处呢?

        其实都是为了实现lock free,解决对于一个 poolDequeue 来说,可能会被多个 P 同时访问就会出现并发问题。

        例如:当 ring buffer 空间仅剩一个的时候,即 head - tail = 1 。 如果多个 P 同时访问 ring buffer,在没有任何并发措施的情况下,两个 P 都可能会拿到对象, 这样就容易引起问题。

        为了不引入Mutex锁,sync.Pool是使用 atomic 包中的 CAS 操作完成的。

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

        在更新 headtail 的时候,也是通过原子变量 + 位运算进行操作的。例如,当实现 head++ 的时候,需要通过以下代码实现:

        const dequeueBits = 32
        atomic.AddUint64(&d.headTail, 1<<dequeueBits)
        

了解完sync.Pool所涉及到的几个结构体,我们用一张关系图来理清他们之间相互相关,如下图:

img

源码分析

Pool.Put

Pool.Put的过程就是将临时对象放进 Pool 里面。

源码如下:

//go 1.20.3 path:src/sync/pool.go

func (p *Pool) Put(x any) {
	// 如果x为nil,直接返回
	if x == nil {
		return
	}
	......
	// 获取当前P的本地pool,并且锁定当前P
	l, _ := p.pin()

	/**
	如果当前P的本地pool的private字段为nil,将x赋值给private字段
	否则将x插入到当前P的本地pool的shared字段的头部
	*/
	if l.private == nil {
		l.private = x
	} else {
		l.shared.pushHead(x)
	}
	// 解锁当前P
	runtime_procUnpin()
	......
}

Pool.Put的策略相对简单:

  • 如果放入对象为空 直接返回;
  • 调用p.pin 将当前goroutineP绑定,获取poolLocal,即获取当前goroutine所运行的P持有的localPool;
  • 优先将缓存对象放入 private(私有空间)中;
  • 如果 private 已经有值,则插入 shared(共享空间)头部;

接下来看一下goroutine 是怎么跟P绑定的,实现该功能是调用函数 Pool.pin

//go 1.20.3 path:src/sync/pool.go

// pin函数会将当前 goroutine绑定的P, 禁止抢占(preemption) 并从 poolLocal 池中返回 P 对应的 poolLocal
func (p *Pool) pin() (*poolLocal, int) {
	//锁定当前P,绑定P和G, 禁止抢占
	pid := runtime_procPin()
	// 获取p.localSize的值
	s := runtime_LoadAcquintptr(&p.localSize)
	// 获取p.local的值
	l := p.local
	// 如果pid小于p.localSize的值,则返回p.local[pid]和pid
	if uintptr(pid) < s {
		return indexLocal(l, pid), pid
	}
	// 如果pid越界时,会涉及全局加锁,重新分配poolLocal,添加到全局列表
	return p.pinSlow()
}

Pool.pin的大致逻辑是:

  • 调用runtime_procPin方法: 会先获取当前G,然后绑定到对应的M上,然后返回M目前绑定的Pid

    runtime_procPin主要实现如下代码:

    //go 1.20.3 path:src/runtime/proc.go
    func sync_runtime_procPin() int {
    	return procPin()
    }
    
    func procPin() int {
      _g_ := getg()
      mp := _g_.m
      mp.locks++
      return int(mp.p.ptr().id)
    }
    

    procPin 函数的目的是为了当前 G 被抢占了执行权限(也就是说,当前 G 就在当前 M 上不走了),这里的核心实现是对 mp.locks++ 操作,在 newstack 里会对此条件做判断,如下:

    if preempt {
      // 已经打了抢占标识了,但是还需要判断条件满足才能让出执行权;
      if thisg.m.locks != 0 || thisg.m.mallocing != 0 || thisg.m.preemptoff != "" || thisg.m.p.ptr().status != _Prunning {
        gp.stackguard0 = gp.stack.lo + _StackGuard
        gogo(&gp.sched) // never return
      }
    }
    
  • 调用 runtime_LoadAcquintptr ,原子操作取出localSize

  • 如果pid小于localSize,调用indexLocal函数取出pid对应的poolLocal返回;

    在这里需要先解释下poolLocal的索引与P(这个P是指Processor,是 golang 中的调度器,后续会详细单独来讲)的关系:
    sync.Pool中,Pool.localSize的大小等于P的数量,如果当前Processor.ID(后面简称pid)大于localSize,那么就表示Pool还没创建对应的poolLocal,否则每个P都有自己的localPoolpid将会是poolLocal的索引,以pid作为数组偏移,就可以从locallocalPool数组)中定位到localPool了。

    接下来看下indexLocal函数:

    //根据偏移获取poolLocal
    func indexLocal(l unsafe.Pointer, i int) *poolLocal {
    	lp := unsafe.Pointer(uintptr(l) + uintptr(i)*unsafe.Sizeof(poolLocal{}))
    	return (*poolLocal)(lp)
    }
    

    该函数主要功能就是取出对应索引的poolLocal数据。

  • 如果pid大于或者等于localSize,就表示Pool还没创建对应的poolLocal,调用pinSlow进行创建。

    为什么要判断是否pid越界呢?这里简单解释一下。大部分时间里P的数量是不会被动态调整的,而runtime.GOMAXPROCS(n int)能够在运行时动态调整P的数量。如果P数量增加,多出来的部分pid并没有分配localPool,所以访问就会越界了。

    下面看看pinSlow源码:

    //go 1.20.3 path:src/sync/pool.go
    
    var (
      // 全局锁
    	allPoolsMu Mutex
      // 存储所有的pool池
    	allPools   []*Pool
      // 需要被GC回收的pool池
    	oldPools   []*Pool
    )
    
    func (p *Pool) pinSlow() (*poolLocal, int) {
    	//解锁当前P
    	runtime_procUnpin()
    	// 加锁全局变量allPools
    	allPoolsMu.Lock()
    	defer allPoolsMu.Unlock()
    	//锁定当前P.绑定P和G,禁止抢占
    	pid := runtime_procPin()
    	// 获取p.localSize的值
    	s := p.localSize
    	// 获取p.local的值
    	l := p.local
    
    	//如果pid小于p.localSize的值,则返回p.local[pid]和pid
    	if uintptr(pid) < s {
    		return indexLocal(l, pid), pid
    	}
    	// 如果p.local为nil,则将p添加到全局列表
    	if p.local == nil {
    		allPools = append(allPools, p)
    	}
    	// 获取当前P的数量
    	size := runtime.GOMAXPROCS(0)
    	// 重新分配poolLocal,创建一个大小为size的poolLocal数组
    	local := make([]poolLocal, size)
    	// 将local数组的地址赋值给p.local
    	atomic.StorePointer(&p.local, unsafe.Pointer(&local[0])) // store-release
    	// 将size赋值给p.localSize
    	runtime_StoreReluintptr(&p.localSize, uintptr(size))     // store-release
    	// 返回local[pid]和pid
    	return &local[pid], pid
    }
    

    pinSlow()的主要运行步骤为:

    1. 释放P禁用抢占,然后尝试获取全局排他锁allPoolsMu Mutex;

    2. 拿到allPoolsMu Mutex之后又开启了禁止抢占P获取pid, 再获取Pool.local地址以及Pool.localSize大小;

    3. 如果pid小于p.localSize的值,则直接返回p.local[pid]pid

    4. 否则根据runtime.GOMAXPROCS(0)获取CPU数量,创建跟CPU数据一致的poolLocal类型数组进行重新分配poolLocal

    该函数除了相关流程外,还有一个全部变量需要注意:allPools,在清理Pool环节会重点使用到,在pinSlow函数中,有句代码为allPools起了赋值作用:

    	if p.local == nil {
    		allPools = append(allPools, p)
    	}
    

    判断p.local是否为空,如果为空,说明这将是一个新的pool,我们需要把它加入allPools里头。至于为什么要加进去呢,当然是为了GCGC时可以通过allPools获取所有pool实例,进行垃圾清理。

最后一张图来理清Pool.Put的整体流程:

image-20230914112841669

Pool.Get

Pool.Get作用就是在从池中获取对象,其源码如下:

//go 1.20.3 path:src/sync/pool.go
func (p *Pool) Get() any {
	......
	// 获取当前P的本地缓存poolLocal
	l, pid := p.pin()
	//获取poolLocal 私有缓存
	x := l.private
	//清空私有缓存
	l.private = nil

	if x == nil {
		//如果私有缓存为空,则从共享缓存中获取
		x, _ = l.shared.popHead() // 从本地共享缓存从头部获取
		//如果共享缓存为空,则从其他P的共享缓存中获取
		if x == nil {
			x = p.getSlow(pid)
		}
	}
	//解锁p
	runtime_procUnpin()
	......
	//如果获取的x为空,且p.New不为空,则调用p.New创建一个新的poolLocal对象
	if x == nil && p.New != nil {
		x = p.New()
	}
	return x
}

Pool.Get 基本步骤梳理下如下:

  1. 获取当前P的本地缓存poolLocal
  2. 优先从poolLocal的私有对象 private 中选择对象;若private 中取不到数据,则尝试从poolLocalshared队列的头部弹出一个元素;
  3. 如果poolLocalshared中也没有元素了,则从其他P对应的shared中偷取一个,我们称为 steal;如果其他Pshared中也没有元素,再检查victim中是否有元素获取;这步流程是在函数p.getSlow函数中完成的,具体查看对该函数的解析;
  4. 如果此时还是没有取到数据,则如果Pool对象设置了New方法,将调用New方法产生一个;如果没有设置New方法,将返回nil

来看看 getSlow源码来分析,分析下如何从其他Psteal元素以及如何从victim中查找元素,其代码如下:

//go 1.20.3 path:src/sync/pool.go
func (p *Pool) getSlow(pid int) any {
	// 获取本地缓存poolLocal的大小
	size := runtime_LoadAcquintptr(&p.localSize)
	// 获取本地缓存poolLocal
	locals := p.local

	/**
	  循环遍历本地缓存poolLocal,从其他P的共享缓存中获取
	  尝试P的顺序是从当前pid+1个索引位置开始对应的,在shared中的查询方式是从它的尾部弹出一个元素
	  如果shared队列中没有,继续尝试下一个位置,直到循环检查一圈都没有
	 */
	for i := 0; i < int(size); i++ {
		// 获取其他P的poolLocal
		l := indexLocal(locals, (pid+i+1)%int(size))
		// 从l的共享缓存中队列尾部获取一个对象,如果获取对象不为空,则返回该对象
		if x, _ := l.shared.popTail(); x != nil {
			return x
		}
	}

	/**
		如果从其他P的共享缓存中获取失败,则从victim缓存中获取看看
		查找的位置是从pid索引位置开始的poolLocal产生从它的shared尾部弹出一个元素
		如果有就返回,如果没有就尝试下一个位置的poolLocal
	 */

	// 获取victim缓存的大小
	size = atomic.LoadUintptr(&p.victimSize)
	// 如果当前P的pid大于等于victim缓存的大小,则返回nil
	if uintptr(pid) >= size {
		return nil
	}
	// 获取victim缓存
	locals = p.victim
	// 获取当前P的poolLocal
	l := indexLocal(locals, pid)
	// 检查victim.private是否有元素,有就返回
	if x := l.private; x != nil {
		l.private = nil
		return x
	}
	// 循环遍历victim对应的poolLocal,从其他P的local缓存shared中尾部获取一个对象,如果获取对象不为空,则返回该对象
	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缓存的大小为0; 这里将p.victimSize设置为0,是为了减少后序调用getSlow,不用再一次遍历
	atomic.StoreUintptr(&p.victimSize, 0)
	//尝试了所有的local.shared和victim.private, victim.shared都没有找到,返回nil
	return nil
}

Pool.getSlow函数的代码逻辑如下:

  1. 从当前pid+1个索引位置开始,遍历其他PpoolLocalshared共享缓存,从它的尾部弹出一个元素,如果获取到了元素则返回该元素;
  2. 如果没有获取到相关元素,则获取victim缓存,尝试从victim 中取数据:
    • 首先从victimprivate中查找元素,如果有则返回该元素;
    • 如果没找到元素,则从当前pid开始,遍历其他Pshared尾部弹出一个元素,如果有元素则返回,如果没有就尝试下一个位置的poolLocal,直接循环结束;
  3. 如果还是获取不到相关元素,则将victim缓存的大小设置为0,以便减少后序调用getSlow,最后返回nil

最后一张图来概括Pool.Get的整体流程:

image-20230914162353627

poolCleanUp

poolCleanup()函数是负责清理Pool的,会在STW阶段被调用,在 sync package init 的时候注册,由 runtime 后台执行,该函数调用链如下:

gcTrigger->gcStart()->clearpools()->poolCleanup()

poolCleanup()函数注册相关源码:

//在程序启动时,会注册一个清理函数poolCleanup(),每次GC开始前,都会被调用
func init() {
	runtime_registerPoolCleanup(poolCleanup)
}

func sync_runtime_registerPoolCleanup(f func()) {
 poolcleanup = f
}

具体来看下 poolCleanup 源码:

//go 1.20.3 path:src/sync/pool.go
func poolCleanup() {

	/**
	遍历oldPools,将其中的victim置为nil,victimSize置为0
	*/
	for _, p := range oldPools {
		p.victim = nil
		p.victimSize = 0
	}

	/**
	  遍历allPools,将其中的local置为victim,localSize置为victimSize,将local置为nil,localSize置为0
	*/
	for _, p := range allPools {
		p.victim = p.local
		p.victimSize = p.localSize
		p.local = nil
		p.localSize = 0
	}
	// 将allPools置为nil,将oldPools置为allPools
	oldPools, allPools = allPools, nil 
}

代码逻辑很简单:

  • 清空oldPoolsvictim 的对象;
  • allPools对象池中,local对象迁移到 victim上;
  • allPools迁移到oldPools,并清空allPools

runtime中实现,每次gc的时候都会进行清理和复制,其实也就是清理local的信息,间接证明sync.Pool减小GC压力是避免频繁的申请内存和释放内存。

从代码中也可以看出,需要经过两轮GC才能完全清理Pool,第一轮GCvictim cache接管local cache;第二轮GC就是全部清除victim cache,为什么这么设计?

因为假如第一轮就全部清除,那么之后再获取对象的时候性能就会下降,因为很有可能在大量创建对象了,所以它GC第一轮先放到victim cache中,等到第二轮在处理。

victim的设计也间接提升了GC的性能,因为相对来说Sync.Pool池化的对象是long-live的,而GC的主要对象是short-live的,所以会减少GC的执行。

poolDequeue

poolDequeue数据结构:

// 环形队列
type poolDequeue struct {
    // 64位的整型,高32位用来记录环head的位置,低32位用来记录环tail的位置,而且head代表的是当前要写入的位置,所以实际的存储区间是[tail, head)
	headTail uint64
    // 环,这里用eface这个结构体也是有妙用的,后面具体会说
	vals []eface
}

type eface struct {
    typ, val unsafe.Pointer
}

poolDequeue 被实现为单生产者多消费者的固定大小的无锁Ring式队列。生产者可以从head插入删除,而消费者仅可从tail删除。headTail指向了队列的头和尾,通过位运算将headtail位置存入poolDequeue.headTail变量中。

const dequeueBits = 32

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)
}

headTail存储的是64位的整型,高32位用来记录环head的位置,低32位用来记录环tail的位置,通过 pack 函数来结合headtail位置得出 headTail,而通过对 headTail解析( 使用unpack函数 )得出 headtail值。

poolDequeue 提供了三个方法,源码如下:

//入队
func (d *poolDequeue) pushHead(val interface{}) bool {
	//加载headTail值
	ptrs := atomic.LoadUint64(&d.headTail)
	//解析出 head 和 tail
	head, tail := d.unpack(ptrs)
	//判断poolDequeue环形队列是否满,如果满了则返回false
	if (tail+uint32(len(d.vals)))&(1<<dequeueBits-1) == head {
		// Queue is full.
		return false
	}

	//获取当前head
	slot := &d.vals[head&uint32(len(d.vals)-1)]

	// 检测当前head slot是否被popTail清理,如果另外一个goroutine正在清理尾部,则该队列实际上已经为满
	typ := atomic.LoadPointer(&slot.typ)
	if typ != nil {
		return false
	}

	// The head slot is free, so we own it.
	if val == nil {
		val = dequeueNil(nil)
	}
	//设置当前head slot值
	*(*interface{})(unsafe.Pointer(slot)) = val
	//当前head值+1
	atomic.AddUint64(&d.headTail, 1<<dequeueBits)
	return true
}


//从头部pop出值
func (d *poolDequeue) popHead() (interface{}, bool) {
	var slot *eface
	for {
		//加载headTail值
		ptrs := atomic.LoadUint64(&d.headTail)
		//解析出 head 和 tail值
		head, tail := d.unpack(ptrs)
		//如果tail == head 代表该队列为空,直接返回
		if tail == head {
			return nil, false
		}

		//head值-1
		head--
		//利用新的head值和tail组合成新的headTail值
		ptrs2 := d.pack(head, tail)
		//利用CSA原子操作,更新headTail值
		if atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) {
			// 成功更新值后,取出当前head slot信息
			slot = &d.vals[head&uint32(len(d.vals)-1)]
			break
		}
	}

	//将slot设置为空值,并设置当前head slot为空结构体
	val := *(*interface{})(unsafe.Pointer(slot))
	if val == dequeueNil(nil) {
		val = nil
	}
	*slot = eface{}
	//返回信息
	return val, true
}

//出队
func (d *poolDequeue) popTail() (interface{}, bool) {
	var slot *eface
	for {
		//加载headTail值
		ptrs := atomic.LoadUint64(&d.headTail)
		//解析出 head 和 tail值
		head, tail := d.unpack(ptrs)
		//如果tail == head 代表该队列为空,直接返回
		if tail == head {
			return nil, false
		}
		//将tail+1和head值重新组合成headTail值
		ptrs2 := d.pack(head, tail+1)
		//利用cas更新 headTail值
		if atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) {
			// 成功更新值后,取出当前tail slot信息
			slot = &d.vals[tail&uint32(len(d.vals)-1)]
			break
		}
	}

	// 将tail slot值信息设置为空,以及重置slot为空结构
	val := *(*interface{})(unsafe.Pointer(slot))
	if val == dequeueNil(nil) {
		val = nil
	}

	slot.val = nil
	atomic.StorePointer(&slot.typ, nil)
	return val, true
}


  • poolDequeue 作为一个 ring buffer,需要记录下其 headtail 的值。但在 poolDequeue 的定义中,headtail 并不是独立的两个变量,只有一个 uint64headTail 变量。这是因为 headTail 变量将 headtail 打包在了一起:其中高 32 位是 head 变量,低 32 位是 tail 变量

  • 对于一个 poolDequeue 来说,可能会被多个 P 同时访问,如果多个 P 同时访问 ring buffer,在没有任何并发措施的情况下,两个 P 都可能会拿到对象,这肯定是不符合预期的。在不引入 Mutex 锁的前提下,sync.Pool 利用了 atomic 包中的 CAS 操作。两个 P 都可能会拿到对象,但在最终设置 headTail 的时候,只会有一个 P 调用 CAS 成功,另外一个 CAS 失败。

    sync/poolqueue.go:popTail

    ptrs2 := d.pack(head, tail+1)
    if atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) {
        slot = &d.vals[tail&uint32(len(d.vals)-1)]
        break
    }
    

    sync/poolqueue.go:popHead

    head--
    ptrs2 := d.pack(head, tail)
    if atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2) {
        slot = &d.vals[head&uint32(len(d.vals)-1)]
        break
    }
    
  • 使用 ring buffer 是因为它有以下优点:

    • 预先分配好内存,且分配的内存项可不断复
    • 由于ring buffer 本质上是个数组,是连续内存结构,非常利于 CPU Cache。在访问poolDequeue 某一项时,其附近的数据项都有可能加载到统一 Cache Line 中,访问速度更快

poolChain

poolChain是一个双端队列,里面的headtail分别指向队列头尾;poolDequeue里面存放真正的数据,是一个单生产者、多消费者的固定大小的无锁的环状队列,headTail是环状队列的首位位置的指针,可以通过位运算解析出首尾的位置,生产者可以从 head 插入、head 删除,而消费者仅可从 tail 删除。

poolChain数据结构:

type poolChain struct {
	head *poolChainElt
	tail *poolChainElt
}

type poolChainElt struct {
	poolDequeue
	next, prev *poolChainElt
}

这个双端队列的模型大概是这个样子:

img

poolChain也实现了pushHeadpopHeadpopTail方法:

//入队操作
func (c *poolChain) pushHead(val interface{}) {
	//获取head指针
	d := c.head
	/**
		如果 head指针为空,则说明队列未初始化,则进行初始化操作,poolDequeue初始长度为 8
	*/
	if d == nil {
		const initSize = 8
		d = new(poolChainElt)
		d.vals = make([]eface, initSize)
		c.head = d
		storePoolChainElt(&c.tail, d)
	}
	//调用poolDequeue.pushHead操作将val压入poolDequeue队列
	if d.pushHead(val) {
		return
	}
	//如果d.pushHead失败,则说明poolDequeue队列满了,这时候将长度*2进行扩容
	newSize := len(d.vals) * 2
	//扩容后的长度不允许超过 dequeueLimit值
	if newSize >= dequeueLimit {
		newSize = dequeueLimit
	}

	/**
		新建一个poolChainElt结构体,将d 和 d2 链接,并将c.head指向d2
	*/
	d2 := &poolChainElt{prev: d}
	//按着newSize容量创建vals数组
	d2.vals = make([]eface, newSize)
	c.head = d2
	storePoolChainElt(&d.next, d2)
	//再次调用poolDequeue.pushHead操作
	d2.pushHead(val)
}


//从头部弹出值
func (c *poolChain) popHead() (interface{}, bool) {
	//获取head指针
	d := c.head
	/**
		循环遍历poolChain中的各个poolChainElt,直到找到存在值的head信息
	 */
	for d != nil {
		if val, ok := d.popHead(); ok {
			return val, ok
		}
		d = loadPoolChainElt(&d.prev)
	}
	return nil, false
}


func (c *poolChain) popTail() (interface{}, bool) {
   d := loadPoolChainElt(&c.tail)
   // 队尾为空,返回
   if d == nil {
      return nil, false
   }

   for {
      // 先取出下一个节点,因为当前d节点获取的时候为空,执行的时候,另外一个p又插入的数据
      d2 := loadPoolChainElt(&d.next)
      // 获取vals切片数据 
      if val, ok := d.popTail(); ok {
         return val, ok
      }
      // 校验d2是否为空
      if d2 == nil {
         return nil, false
      }

      // 原子性cas对比d的值中途是否有改变
      if atomic.CompareAndSwapPointer((*unsafe.Pointer)(unsafe.Pointer(&c.tail)), unsafe.Pointer(d), unsafe.Pointer(d2)) {
         // popTail为空,并且d2非空。 则清空上一个数据
         storePoolChainElt(&d2.prev, nil)
      }
      d = d2
   }
}
  • popHead以队首向队尾为方向遍历链表,对每个poolDequeue执行popHead尝试取出存放的对象。
  • popTail以队尾向队队首为方向遍历链表,对每个poolDequeue执行popTail尝试从尾部取出存放的对象。
  • poolDequeue是在poolChainpushHead中创建的,且每次创建的长度都是前一个poolDequeue长度的2倍,初始长度为8

总结

image-20230915105942876

  1. Pool 本质是为了提高临时对象的复用率
  2. Pool 使用两层回收策略(local + victim)避免性能波动;
  3. Pool 本质是一个杂货铺属性,啥都可以放。把什么东西放进去,预期从里面拿出什么类型的东西都需要业务使用方把控,Pool 池本身不做限制;
  4. Pool 池里面 cache 对象也是分层的,一层层的 cache,取用方式从最热的数据到最冷的数据递进;
  5. Pool 是并发安全的,但是内部是无锁结构,原理是对每个 P 都分配 cache 数组( poolLocalInternal 数组),这样 cache 结构就不会导致并发;
  6. 永远不要 copy 一个 Pool,明确禁止,不然会导致内存泄露和程序并发逻辑错误;
  7. 代码编译之前用 go vet 做静态检查,能减少非常多的问题;
  8. 每轮 GC 开始都会清理一把 Pool 里面 cache 的对象,注意流程是分两步,当前 Poollocal 数组里的元素交给 victim 数组句柄,victim 里面 cache 的元素全部清理。换句话说,引入 victim 机制之后,对象的缓存时间变成两个 GC 周期;
  9. 不要对 Pool 里面的对象做任何假定,有两种方案:要么就归还的时候 memset 对象之后,再调用 Pool.Put ,要么就 Pool.Get 取出来的时候 memset 之后再使用;

参考资料:

  • 奇伢云存储 https://xie.infoq.cn/article/55f28d278cccf0d8195459263
  • 惜暮 https://blog.csdn.net/u010853261/article/details/90647884
  • (鸟窝) 极客时间 - Go 并发编程实战课 https://time.geekbang.org/column/intro/100061801
  • dz45693 https://blog.csdn.net/ma_jiang/article/details/111787239
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值