优秀的编程知识分享平台

网站首页 > 技术文章 正文

深入探究 Go 语言中的 sync.Pool(go语言reflect)

nanyue 2024-09-01 20:31:12 技术文章 4 ℃


在 Go 语言的编程领域中,sync.Pool 是一个强大却又充满复杂性的工具,对优化资源管理和提升程序性能起着关键作用。本文将全方位剖析 sync.Pool,涵盖其定义、使用方法、内部机制以及相关的注意要点。

一、sync.Pool? 的定义与作用

?sync.Pool? 实质上是用于存储临时对象以实现后续重复利用的容器。它旨在应对高并发场景下,频繁创建和销毁相似对象所引发的内存消耗与垃圾回收压力。然而,需留意的是,开发者无法精准把控池中对象的数量,且放入池中的对象可能在任何时刻被毫无预兆地移除。

其优势在于它具备线程安全性,多个 goroutine 能够同时进行访问和操作。通过复用对象,sync.Pool? 有助于打破高并发导致高内存使用进而影响垃圾回收器效率的不良循环。

例如,通过如下方式定义一个 sync.Pool? 来复用特定类型的对象:

type Object struct {
    Data []byte
}

var pool sync.Pool = sync.Pool{
    New: func() any {
        return &Object{
            Data: make([]byte, 0, 1024),
        }
    },
}

在使用时,从池中获取对象、执行操作,然后再放回池中。

二、sync.Pool? 与分配陷阱

通过标准库中的实例能够发现,sync.Pool? 在处理临时对象时极为有用,能够规避频繁创建和销毁对象带来的性能损耗与内存压力。

然而,在使用 sync.Pool? 时存在一个潜在的分配陷阱。比如:

var pool = sync.Pool{
    New: func() any {
        return []byte{}
    },
}

func main() {
    bytes := pool.Get().([]byte)

    // 操作

    pool.Put(bytes)
}

在此例中,我们使用了一个 []byte? 的 sync.Pool? 。当通过 pool.Get().([]byte)? 获取对象时,通常情况下(并非绝对),当向接口传递值时,可能会导致值被放置在堆上。通过逃逸分析能够得知,bytes? 可能会逃逸到堆上。

但倘若传递的是指针,情况则有所不同:

var pool = sync.Pool{
    New: func() any {
        return new([]byte)
    },
}

func main() {
    bytes := pool.Get().(*[]byte)

    // 操作

    pool.Put(bytes)
}

再次进行逃逸分析,会发现此时不会逃逸到堆上。

这是由于直接传递对象值到接口可能会引发额外的堆分配,而传递指针则能够避免此类情况。在实际编程中,理解并正确处理这种分配差异对于优化性能和避免不必要的内存开销至关重要。

三、sync.Pool? 的内部机制

PMG 调度模型基础

在深入探索 sync.Pool? 的实际运作原理之前,熟悉 Go 的 PMG 调度模型的基础知识是很有裨益的,这是 sync.Pool? 高效运行的基石。

PMG 分别代表逻辑处理器(P)、机器线程(M)和 goroutine(G)。每个逻辑处理器(P)在任何时刻仅能有一个机器线程(M)运行,而 goroutine 要运行就得依附于一个线程(M)。

关键要点如下:

  1. 若有 n 个逻辑处理器(P),且至少有 n 个机器线程(M),则能够并行运行多达 n 个 goroutine。
  2. 任何时候,单个处理器(P)上只能运行一个 goroutine(G)。所以当 P1 上的一个 goroutine 未结束或未释放时,其他 goroutine 无法在 P1 上运行。

本地池与伪共享问题

?sync.Pool? 由多个与特定处理器上下文相关的本地池构成。每个本地池包含私有对象和共享池链两部分。私有对象只有所属的 P 能访问,而当私有对象不可用时,共享池链就会发挥作用。但要注意的是,在现代多核处理器环境中可能存在伪共享问题,通过在本地池结构中添加 pad? 字段来解决,以保证每个本地池能独立占用缓存行,从而提升性能。

然而,Go 中的 sync.Pool? 并非单纯的单一池,而是由多个与特定处理器上下文相关联的“本地”池组成。当处理器(P)上的 goroutine 需从池中获取对象时,会先检查自己所属的 P 本地池。

每个 P 本地池包含共享池链(shared?)和私有对象(private?)两部分。

type poolLocalInternal struct {
    private any
    shared  poolChain
}

type poolLocal struct {
    poolLocalInternal
    pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

?private? 字段用于存储单个对象,且只有对应 P 能访问,这能让 goroutine 快速获取复用对象。但如果私有对象不可用,共享池链就会介入。

在实际情形中,存在一些微妙的问题。例如,虽然通常只有一个 goroutine 能访问 P 的私有对象,但如果某个获取了私有对象的 Goroutine A 被阻塞或抢占,此时 Goroutine B 在同一 P 上运行时就无法访问该私有对象。

同时,需关注 P 本地池中的 pad? 字段。

type poolLocal struct {
    poolLocalInternal
    pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte
}

这是为了防止在现代多核处理器上可能出现的伪共享问题。现代 CPU 利用缓存行加速内存访问,若 poolLocal? 结构小于缓存行大小,可能导致不同 poolLocal? 实例共享同一缓存行,从而引发性能问题。

?sync.Pool? 中的共享池链由 poolChain? 类型表示。

type poolChain struct {
    head *poolChainElt
    tail atomic.Pointer[poolChainElt]
}

type poolChainElt struct {
    poolDequeue
    next, prev atomic.Pointer[poolChainElt]
}

池链与池队列

?sync.Pool? 中的共享池链由 poolChain? 类型表示。

type poolChain struct {
    head *poolChainElt
    tail atomic.Pointer[poolChainElt]
}

type poolChainElt struct {
    poolDequeue
    next, prev atomic.Pointer[poolChainElt]
}

当当前的池出队已满时,会创建新的、更大的池出队并添加到链中。poolChain? 的 head? 指针用于生产者添加新项,tail? 原子指针用于多个消费者同步获取尾部项。

池出队(poolDequeue?)本质上是一个双链表,每个节点是一个 poolDequeue? 结构,即一个双端队列。

type poolDequeue struct {
    headTail atomic.Uint64
    vals []eface
}

生产者能在头部操作,消费者从尾部获取。headTail? 字段将头部和尾部索引打包为 64 位整数,方便原子性更新。队列中的数据存储在环形缓冲区 vals? 中,每个槽为 eface? 值。

四、sync.Pool? 的Put? 和Get? 操作

在 Go 语言中,sync.Pool? 的 Put? 和 Get? 操作是其核心功能。

?Put? 操作

当一个 goroutine 调用 Put? 方法向 sync.Pool? 中添加对象时,它首先会尝试将对象存储在当前 P 的本地池的私有位置。若该私有位置为空,就将对象放置在那里。若私有位置已被占用,那么对象会被推到共享池链的头部。

func (p *Pool) Put(x interface{}) {
    // 若对象为 nil,直接返回
    if x == nil {
        return
    }

    // 固定当前 P 的本地池
    l, _ := p.pin()

    // 若私有池为空,创建并设置对象
    if l.private == nil {
        l.private = x
        x = nil
    }

    // 若对象不为空(即私有池已满),将其推到共享链头部
    if x!= nil {
        l.shared.pushHead(x)
    }

    // 解除当前 P 的固定
    runtime_procUnpin()
}

其中,pin? 函数的作用是暂时禁用调度器对 goroutine 的抢占,确保操作的连贯性。

?Get? 操作

?Get? 操作首先会固定当前 goroutine 到其 P,然后尝试从当前 P 的本地池获取私有对象。若私有对象不存在,就从共享池链的头部获取。若仍未获取到对象,则会尝试从其他 P 的缓存中窃取。

func (p *Pool) Get() interface{} {
    // 固定当前 P 的本地池
    l, pid := p.pin()

    // 获取本地池的私有对象
    x := l.private
    l.private = nil

    // 若私有对象不存在,从共享池链头部获取
    if x == nil {
        x, _ = l.shared.popHead()

        // 从其他 P 的缓存中窃取
        if x == nil {
            x = p.getSlow(pid)
        }
    }
    runtime_procUnpin()

    // 若仍然未获取到对象且存在创建新对象的函数,则创建新对象
    if x == nil && p.New!= nil {
        x = p.New()
    }
    return x
}

在获取不到对象的情况下,会进入慢路径,尝试从其他 P 的缓存中窃取对象。

五、受害者池机制

尽管 sync.Pool? 旨在更出色地管理资源,但它并未为开发者提供直接清理或管理对象生命周期的手段。实际上,sync.Pool? 在幕后处理清理工作,以避免无限制的增长从而导致内存泄漏。

清理的主要方式是借助 Go 语言的垃圾回收器(GC)。每当 sync.Pool? 首次调用 pin()? 函数(或者通过 GOMAXPROCS? 改变了处理器数量),它就会被添加到 sync? 包中的全局切片 allPools? 中。

package sync

var (
    allPoolsMu Mutex

    // allPools 存储具有非空主缓存的池
    allPools []*Pool

    // oldPools 存储可能具有非空受害者缓存的池
    oldPools []*Pool
)

在每次垃圾回收周期开始前,Go 运行时会触发一个清理过程,先调用 clearPool? 函数,将 sync.Pool? 中的对象(包括私有对象和共享池链中的对象)转移到受害者区域。这些对象并非立即被丢弃,而是暂时存放在此。同时,上一轮垃圾回收周期中留在受害者区域的对象会在本轮被完全清除。

func poolCleanup() {
    // 清除旧受害者缓存
    for _, p := range oldPools {
        p.victim = nil
        p.victimSize = 0
    }

    // 将主缓存移至受害者缓存
    for _, p := range allPools {
        p.victim = p.local
        p.victimSize = p.localSize
        p.local = nil
        p.localSize = 0
    }

    // 更新 oldPools 和 allPools
    oldPools, allPools = allPools, nil
}

那么,为何需要受害者机制呢?因为倘若在一次 GC 周期后就立即完全清空 sync.Pool?,可能会引发性能问题。新的对象请求将需要重新创建,而通过先将对象移至受害者区域,sync.Pool? 确保了在完全丢弃之前有一个缓冲期,对象仍可能被复用。

Tags:

最近发表
标签列表