wdc 发表于 2025-2-5 17:50:38

Golang sync.pool源码解析

👋 大家好,我是思无邪,某go中厂开发工程师,也是OSPP2024的学生参与者!
🚀 如果你觉得我的文章有帮助,记得三连支持一下哦!
🍂 目前正在深入研究源码,与你们一起进步,共同攻克编程难关!
📝 欢迎关注我的公众号【小菜先生的编程随想】,一起学习、一起成长,勇敢面对互联网寒冬!💡
Golang sync.pool源码解析

- sync.pool        - 是什么        - 怎么用                - demo                - 真实世界的使用        - 源码解读-数据结构        - 源码解读-读写流程                - 写流程                - 读流程        - 源码解读-细节补充        - 总结引言

​sync.pool ​是 golang 语言提供的一种用于缓存对象的“池子”。
可以通过 sync.pool ​将对象放入“池”中缓存,在后续创建对象时避免真正申请内存创造对象的步骤,是一种典型的空间换时间的优化思路~

是什么 | 怎么用

sync.pool 是 golang 语言提供的一种对象缓存机制,通过将对象缓存在 pool 中,可以避免每次创建对象的时候都重新申请内存构造对象,而是直接从 pool 中取出对象使用即可,在频繁创建对象和销毁对象的时候极大的缓解了 gc 压力。

使用 demo 和注意事项

sync.pool的使用非常简单,创建池子之后put、get对象即可,全部代码可见:「我的github仓库」。
func NewStudent() *Student {        return &Student{}}type Student struct {        Namestring        Age   int        Right bool}func (s *Student) Clear() {        s.Name = ""        s.Age = 0        s.Right = false}var studentPool = sync.Pool{        New: func() interface{} {                return NewStudent()        },}func main() {        student := studentPool.Get().(*Student)        // 使用student        student.Clear() //返回给studentPool之前必须清空        studentPool.Put(student)}虽然使用非常简单,但是在使用的过程中,我们必须注意下面几个事项,以防使用出错。

[*]「非常重要」pool 在使用的时候需要在创建对象的时候或者是销毁的时候清空自己,如果不清空,产生的错误及其难排查错误。具体来说:对于基础数据类型,赋予零值,对于数组之类的,可以使用[:0]​来清空,避免重新申请内存(当然同时要注意数组长度过长还是直接make(T,0)​清空合适)。
[*]池子对外暴露的方法Put​和Get​,其执行顺序没有任何依赖。Put​后马上Get​,存取的对象没有任何保证是同一个。
[*]对大对象使用池优化效果明显:池子本身是对对象的复用,减少了重复创建对象和反复GC的开销,但是由于将对象放入池子中本身也存在一定的开销,因此一般来说对大对象才使用池进行优化,对于小对象可能还有反向优化。
真实世界的使用


[*]在基于 gin 启动的 http server 中,针对到来的 http 请求,会为之分配一个 gin.Context 实例,由于承载关于这次请求链路的上下文信息.
在这个场景中,gin.Context 就是一个可能被量产使用的工具类,其本身创建销毁成本不高,但随着 qps(Query Per-Second) 的增长,可能在短时间内被重复创建、销毁,因此很适合使用对象池技术进行缓存复用.
[*]​fmt.Printf​ ,来源于golang源码:
// go 1.13.6// pp is used to store a printer's state and is reused with sync.Pool to avoid allocations.type pp struct {    buf buffer    ...}var ppFree = sync.Pool{        New: func() interface{} { return new(pp) },}// newPrinter allocates a new pp struct or grabs a cached one.func newPrinter() *pp {        p := ppFree.Get().(*pp)        p.panicking = false        p.erroring = false        p.wrapErrs = false        p.fmt.init(&p.buf)        return p}源码解读-数据结构

结构总览

sync.pool设计的结构体的总览图如下:


[*]​Pool​是对外提供的结构体,作为go的使用方可以直接看到。
[*]​local​是一个数组,每个元素为poolLocal​。其类型是unsafe.pointer​也可以用来表示数组,后面单独总结,这先就看成数组即可。
[*]poolLocal里面就两个元素:poolLocalInternal​和pad​。poolLocalInternal​是核心,pad​只是为了字节对齐128而产生填充。
[*]​poolLocalInternal​中两个元素:

[*]​private​:每个p私有的元素,不会被其他p操作。
[*]​poolChain​:对外提供双向链表的能力。不同于普通双向链表,其每个节点是一个环形数组而非一个数据。并且提供“有限制的”并发能力。

为什么poolChain​中的每个节点是 环形数组 而非节点,大概原因是因为环形数组这样的结构是内存连续的,可以更好的利用cpu缓存的特性,这点更优于链表。
而既然环形数组这么优秀,那么为什么poolChain​为什么还是一个链表呢?为什么不把它直接做成一个环形数组?
因为环形数组虽然可以利用缓存,但是其必须要提前申请空间,在元素数量不多的情况下会对空间有比较多的浪费。
poolchain的设计

poolChain有如下几个特点:

[*]lock-free
[*]固定大小,ring形结构(底层存储使用数组,使用两个指针标记ehead、tail)
[*]单生产者
[*]多消费者
[*]生产者可以从head进行pushHead​、popHead​
[*]消费者可以从tail进行popTail​
上面提到【poolChain​:对外提供双向链表的能力。不同于普通双向链表,其每个节点是一个环形数组而非一个数据。并且提供有限制的并发能力。】,对于有限制的并发能力,指的是:单生产者,可以从head进行pushHead​生产、popHead​消费;多消费者,消费者可以从tail进行popTail​消费。
其具体设计细节虽然对pool的使用性能有很重要的影响,但是并不是本篇文章的重点,因此将其拆分在sync.pool中的“并发”“双向链表”poolChain的设计学习[^1]中。
源码解读-主要流程走读

sync.pool在创建之后,对外提供的操作入口之后两个:读(Get​)和写(Put​),两种操作在某种程度上是”逆反操作“。
归还对象流程

写流程的入口函数是Put​函数,其函数如下代码块。主要逻辑也是很简单:

核心源码如下:
// Put adds x to the pool.func (p *Pool) Put(x any) {        if x == nil {                return        }        l, _ := p.pin() //pin返回的两个元素:当前p对应的poolLocal,当前p的id        if l.private == nil { //private对象对p来说是私有的,存取效率更高,因此优先存取,这里发现没有,就存到这                l.private = x                x = nil        }        if x != nil { //private已经有了,就往shared里面放了,放入shared使用的是头插!                l.shared.pushHead(x)        }        runtime_procUnpin() //接触当前g独占p的状态}其中pin​元素是比较有意思的,其主要功能是:让当前的g独占当前的p,并获取当前p对应的​​poolLocal​​,其源码如下:
// pin pins the current goroutine to P, disables preemption and// returns poolLocal pool for the P and the P's id.// Caller must call runtime_procUnpin() when done with the pool.// pin函数让当前的g独占p,并且返回当前p的poolLocal和P的id// 调用方必须在使用pool完毕后调用runtime_procUnpin()函数func (p *Pool) pin() (*poolLocal, int) {        pid := runtime_procPin()        // In pinSlow we store to local and then to localSize, here we load in opposite order.        // Since we've disabled preemption, GC cannot happen in between.        // Thus here we must observe local at least as large localSize.        // We can observe a newer/larger local, it is fine (we must observe its zero-initialized-ness).        s := runtime_LoadAcquintptr(&p.localSize) // load-acquire        l := p.local                              // load-consume        if uintptr(pid) < s {                return indexLocal(l, pid), pid        }        return p.pinSlow()}按理来说没有太多特别的地方,因为按照预想的节奏,每个p与poolLocal​是一一对应的关系,因此按照当前p的pid去数组对应下标取poolLocal​就可以了!
但是有一点很重要也很有意思,这种p和​​poolLocal​​一一对应的关系是什么时候建立的?初始化的时候并没有这个操作! 其奥秘就在​​pinSlow​​函数中!
​pinSlow​的源码如下,简单易懂,主要逻辑是解锁后重新拿锁,并尝试重新分配local和localSize。
func (p *Pool) pinSlow() (*poolLocal, int) {        // Retry under the mutex.        // Can not lock the mutex while pinned.        runtime_procUnpin() //先解绑p与g        allPoolsMu.Lock() //所有pool共享这个锁        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 { // 在解绑p与g之后,加锁之前,可能已经有其他的goroutine执行了pinSlow函数,因此再校验一次                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.        // 如果全局(所有goroutine)第一次进入pinSlow函数,或者改变了runtime.GOMAXPROCS导致进入pinSlow函数        // 就会触发local和localSize的重新分配。(原来的直接全部舍弃掉)        size := runtime.GOMAXPROCS(0)        local := make([]poolLocal, size)        atomic.StorePointer(&p.local, unsafe.Pointer(&local)) // store-release        runtime_StoreReluintptr(&p.localSize, uintptr(size))   // store-release        return &local, pid}下面我们再来看下Put函数的最后一步:l.shared.pushHead(x)​,函数的定义如下,其主要功能是向共享的双向链表poolChain头插入一个节点。
func (c *poolChain) pushHead(val any) {        d := c.head   //对于头的操作是当前p特有的,因此不用考虑并发安全        if d == nil { //头为nil,即链表中没有元素(没有环形数组),建立环形数组                // Initialize the chain.                const initSize = 8 // 环形数组大小必须是2的次方                d = new(poolChainElt)                d.vals = make([]eface, initSize)                c.head = d                storePoolChainElt(&c.tail, d) //对于尾的操作不是当前p特有的,因此需要用atom相关函数保证并发安全        }        if d.pushHead(val) { //对于环形数组的操作并不是p特有的,因此里面需要考虑并发安全                return        }        // 满了就新建一个元素(环形数组),大小为2倍,最大大小为dequeueLimit(32)        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)}这里面涉及了一些很有意思的设计:

[*]​poolChain.head​不用考虑并发,poolChain.tail​需要考虑并发:因为当前p是头插头取,而p操作的时候会使用pin使得当前g独占当前p,因此操作head不会涉及并发;对于其他p的操作是尾取,因此需要考虑并发安全。
拿取对象流程

拿取对象入口是Get​函数,Get​流程相较于Put稍微复杂,因为Put​流程无论什么状态下只会涉及操作当前p绑定的local,但是Get​可能会跑到其他p对应的local中的shared的双向链表中“偷取”对象;如果偷不到的话还可能取Vctim中去偷。
​Get​核心流程图如下,link
​​
从Victim拿取的流程与从Local拿取相比,区别主要是不会优先从shared列表中拿取,原因在于Victim不会再有生产,因此也不用优先从当前的拿取了!
核心源码如下:
func (p *Pool) Get() any {        l, pid := p.pin()        x := l.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.                x, _ = l.shared.popHead() //private拿不到就自己的shared拿取,从头部拿取                if x == nil {                        x = p.getSlow(pid)//再拿不到就从其他p的local拿取,从尾部开始遍历; 再不行就走Victim拿取的流程                }        }        runtime_procUnpin()        if x == nil && p.New != nil { //最后的兜底,如果还没有拿到,那么就new一个新的出来                x = p.New()        }        return x}‍
清理pool流程|poolCleanup​函数

主要涉及的是:poolCleanup​函数(GC的时候对池化对象的释放)
与 清理pool的流程 强相关的有victim和victimSize两个变量。
        victim   unsafe.Pointer // local from previous cycle,上一轮的​local        victimSize uintptr      // size of victims array ,上一轮的​localSize在pool文件的init函数中,其将清理pool的函数poolCleanup​注册到gc的钩子中,在每次gc的时候都会执行poolCleanup函数清理sync.pool。
// pool.go文件 startfunc init() {        runtime_registerPoolCleanup(poolCleanup)}//pool.go文件 end//mgc.go文件startvar poolcleanup func()//go:linkname sync_runtime_registerPoolCleanup sync.runtime_registerPoolCleanupfunc sync_runtime_registerPoolCleanup(f func()) {        poolcleanup = f}func clearpools() {        // clear sync.Pools        if poolcleanup != nil {                poolcleanup()        }        ...}// gc入口func gcStart(trigger gcTrigger) {        //...        clearpools()        //...}在sync.pool​的中总览图中,对于victim​ 和victimSize​介绍其是上一轮的local​和localSize​变量,这里的“上一轮”指的是每一次清理sync.pool,因此在每一次gc的时候都会:会将local pool​中缓存对象移动到victim cache​中,然后在下一次GC时候,清空victim cache​对象。
这也是为什么有种说法是sync.pool​中的对象会保留两个gc的时间:第一个gc从local-->victim,第二个gc从victim-->内存释放。
再来看下poolCleanup​函数,因为其发生的实际一定是gc期间,因此不用考虑锁、pin​绑定之类的函数,一顿操作就行了。
func poolCleanup() {        // This function is called with the world stopped, at the beginning of a garbage collection.        // It must not allocate and probably should not call any runtime functions.        // Because the world is stopped, no pool user can be in a        // pinned section (in effect, this has all Ps pinned).        // Drop victim caches from all pools.        for _, p := range oldPools {                p.victim = nil                p.victimSize = 0        }        // Move primary cache to victim cache.        for _, p := range allPools {                p.victim = p.local                p.victimSize = p.localSize                p.local = nil                p.localSize = 0        }        // The pools with non-empty primary caches now have non-empty        // victim caches and no pools have primary caches.        oldPools, allPools = allPools, nil}源码解读-其它问题补充

victim数组

Q:在正文的「清理pool流程」部分,我们提到 sync.pool中的某个对象在第一轮gc的时候会从local-->victim,第二轮gc的时候才会从victim中被清理掉,那么为什么要有个victim这一步,而不直接被清理掉呢?
A:victim是“受害者”缓存,相当于是一个gc的缓冲。如果没有victim,那么一次gc之后,下次对象又需要完全重新申请,这时候会增加gc和内存申请的压力。显然victim也是一个空间换时间的做法,保留Victim优化性能的同时,也会带来额外的内存占用的开销!
因此这样的设计思想实际上是与gc类型的语言强绑定的,如果是非gc类的语言,也许需要一些其他类似思想机制代替victim数组。
软件开发领域很少有“银弹“。
unsafe.Pointer代表数组

Q1:localunsafe.Pointer​ 既然本质上是一个数组,那么为什么不直接按照数组来使用,要搞成 unsafe.Pointer​的形式来使用呢?
A1:这里使用unsafe.Pointer​的目的是为了性能,如果使用slice,那么为了保证原子性,不可避免的就需要引入Mutex来保证并发安全,而使用unsafe.Pointer之后,就可以使用atomic相关函数。
Q2:在sync.pool​中,分别用local​和localSize​维护数组和数组长度,怎么保证“数据的一致性”的?
A2:既然有两个变量,那么不适用Mutex的情况下肯定是没办法维护两个变量的一致性的。为了防止使用问题,因此只需要保证localSize小于等于数组实际大小即可。
可以从源码中看出,在重新分配的时候是先分配local然后才分配localSize。
// pinSlow函数 重新分配local和localSize,重新分配只会扩容,不会缩容atomic.StorePointer(&p.local, unsafe.Pointer(&local)) // store-releaseruntime_StoreReluintptr(&p.localSize, uintptr(size))   // store-release在读取的时候与分配的时候是相反,先读取localSize再读取local,这样保证不会出现使用问题。
// pin函数// In pinSlow we store to local and then to localSize, here we load in opposite order.// 在pinSlow函数中,先储存local,然后储存localSize,因此以反着顺序来转载。        s := runtime_LoadAcquintptr(&p.localSize) // load-acquire        l := p.local                              // load-consumeQ3:使用了unsafe.Pointer​来模拟一个array,那么增删改查操作应该如何完成?
A3:这是一个典型的通用问题,范例代码放在下方,如果刨去疯狂的类型转换,还是非常好理解的!
读取:
// indexLocal// @Description:读取对应的内存// @param l 为数组刚开始的位置转换成的unsafe.Pointer// @param i 偏移量,相当于中的i// @return *poolLocal 返回命中的元素func indexLocal(l unsafe.Pointer, i int) *poolLocal {        lp := unsafe.Pointer(uintptr(l) + uintptr(i)*unsafe.Sizeof(poolLocal{})) //为了能够进行+操作,因此转成uintptr进行操作        return (*poolLocal)(lp)}写入:创建一个slice,然后取第0个元素的地址即可。需要注意的是取的是第0个元素的地址,而非slice的地址!
// pinSlow 函数// @Description:初始化赋值就直接使用slice的方式来初始化,并且unsafe.Pointer(&local)来赋值。        size := xxx        local := make([]poolLocal, size)        atomic.StorePointer(&p.local, unsafe.Pointer(&local)) // store-release         runtime_StoreReluintptr(&p.localSize, uintptr(size))   // store-releasegolang既然自带gc,为什么官方不从需要被内存回收,但是还没有被内存回收的对象里面拿对象呢?

因为golang中gc的时候是会STW(stop the world)的,这个问题的前提是gc和pool.Get时间上是并行的,因此不存在这样的前提。
noCopy相关

Q:pool结构体相关部分中见到了noCopy相关的成员和注释,like:noCopy noCopy // nocopy机制,用于go vet命令检查是否复制后使用​,这个用处是什么?
A:作用就是禁止这个结构产生复制行为,可以禁止的复制行为包括但是不限于显式的复制、隐式的函数传参复制,like:
type User struct {        noCopy noCopy}func main() {        u1 := User{}        _ = u1        testFunc(u1)}func testFunc(u User){}这时候如果使用go vet {文件名}​的命令,就可以检测到是否存在不合理的复制:
(base) ➜test26 git:(main) ✗ go vet main.go# command-line-arguments# ./main.go:10:6: assignment copies lock value to _: command-line-arguments.User contains command-line-arguments.noCopy./main.go:11:11: call of testFunc copies lock value: command-line-arguments.User contains command-line-arguments.noCopy./main.go:13:17: testFunc passes lock by value: command-line-arguments.User contains command-line-arguments.noCopy需要注意的是:go vet检测不合理的复制 !=编译失败。
实际上,现代的IDE也会直接在编译之前提示,like:​​
Q2:noCopy在源码中是没有暴露出来的,我该怎么使用呢?
A2:没有太好的办法直接使用,最简单的办法就是把源码的视线部分搬到自己的代码中即可:其实就是实现Locker​即可,源码如下:
// noCopy may be embedded into structs which must not be copied// after the first use.//// See https://golang.org/issues/8005#issuecomment-190753527// for details.type noCopy struct{}// Lock is a no-op used by -copylocks checker from `go vet`.func (*noCopy) Lock()   {}func (*noCopy) Unlock() {}全部代码可见:我的代码仓库

总结

sync.pool为我们提供了一个并发安全的对象池,让我们放心的存取对象,在使用的时候,需要注意”清空“对象。
sync.pool中有很多精妙的设计思想值得我们学习,包括但不限于:poolChain、内存对齐、sync.pool与golang的gpm体系的结合等等。

[*]poolChain:无锁实现一定并发安全能力的poolChain、并且综合链表和环形数组的优劣势。
[*]底层原理中充分考虑到了cpu的缓存,128bit的padding对齐;localPool的private变量
[*]代码是逐步优化来的,就算是golang源码编写者这样级别的大佬们,也没法一步就编写出如此精妙的代码,包括但不限于poolChain环形链表和victim数组的设计都是不断演进来的,而不是一开始就全部设计好的,具体见:Go 1.13中 sync.Pool 是如何优化的?

参考:
https://geektutu.com/post/hpg-sync-pool.html#4-1-fmt-Printf
深度解密 Go 语言之 sync.Pool - Stefno - 博客园
Go 并发编程 — 深度剖析 sync.Pool 源码级原理_并发编程_奇伢云存储_InfoQ写作社区
Go 1.13中 sync.Pool 是如何优化的?
感谢大家阅读到这里!🎉
如果你有任何问题或想法,欢迎在评论区留言,我们一起讨论、一起进步!
如果你觉得这篇文章对你有所帮助,也请不吝点赞、分享,支持博主继续创作更多优质内容!💪
关注【小菜先生的编程随想】公众号,我们一起在编程的道路上越走越远,战胜一切挑战!💥
希望可以下次再见!👋
页: [1]
查看完整版本: Golang sync.pool源码解析