来自 Go sync.Pool and the Mechanics Behind It(VictoriaMetrics Blog,2024-08-23)

文章深入剖析了 sync.Pool 的设计初衷、使用陷阱(如分配陷阱)、底层实现(PMG 模型、本地池、伪共享问题)以及独特的 Victim Cache 清理机制。对于理解 Go 高性能编程和内存优化非常有帮助。

引子

VictoriaMetrics 的源码中,我们大量使用了 sync.Pool。对于处理临时对象(尤其是字节缓冲区或切片),它简直是天作之合。

标准库示例

encoding/json

标准库中也广泛使用了它。例如在 encoding/json 包中:

在这里,sync.Pool 被用来复用 *encodeState 对象,这些对象负责将 JSON 编码处理到 bytes.Buffer 中。

package json
 
var encodeStatePool sync.Pool
 
// encodeState 将 JSON 编码到 bytes.Buffer 中。
type encodeState struct {
    bytes.Buffer // 累积的输出
    ptrLevel uint
    ptrSeen  map[any]struct{}
}

我们没有在每次使用后就丢弃这些对象(这会给垃圾回收器增加更多工作),而是将它们存放在池中(sync.Pool)。下次我们需要类似对象时,直接从池中获取,而不是从头创建一个新的。

net/http

你还会在 net/http 包中发现多个 sync.Pool 实例,用于优化 I/O 操作:

package http
 
var (
    bufioReaderPool   sync.Pool
    bufioWriter2kPool sync.Pool
    bufioWriter4kPool sync.Pool
)

当服务器读取请求体或写入响应时,它可以快速从这些池中拉取预分配的 reader 或 writer,跳过额外的分配。此外,两个 writer 池 *bufioWriter2kPool*bufioWriter4kPool 被设置为处理不同的写入需求。

func bufioWriterPool(size int) *sync.Pool {
    switch size {
    case 2 << 10:
        return &bufioWriter2kPool
    case 4 << 10:
        return &bufioWriter4kPool
    }
    return nil
}

好了,介绍到此为止。

今天,我们将深入探讨 sync.Pool 到底是什么、它的定义、如何使用、底层发生了什么,以及你可能想知道的其他一切。

顺便说一句,如果你想要更实用的内容,我们的 Go 专家有一篇很好的文章,展示了我们在 VictoriaMetrics 中如何使用 sync.PoolPerformance optimization techniques in time series databases: sync.Pool for CPU-bound operations

什么是 sync.Pool?

简单来说,Go 中的 sync.Pool一个存放临时对象以供后续复用的地方

但有一点要注意:你无法控制池中有多少对象保留,而且放入其中的任何东西都可能在没有任何警告的情况下被随时移除。读到最后一节你会明白原因。

好消息是,池是线程安全的,所以多个 Goroutine 可以同时使用它。考虑到它是 sync 包的一部分,这并不令人惊讶。

为什么要复用对象?

当你有很多 Goroutine 同时运行时,它们通常需要相似的对象。想象一下并发运行多次 go f()

如果每个 Goroutine 都创建自己的对象,内存使用量会迅速增加,这会给垃圾回收器(GC)带来压力,因为它必须在这些对象不再需要时清理它们。

这种情况造成了一个循环:高并发导致高内存使用,进而拖慢垃圾回收器。sync.Pool 旨在帮助打破这个循环。

示例:一个简单的对象池

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

New() 与空池行为

要创建一个池,你可以提供一个 New() 函数,当池为空时,它会返回一个新的对象。这个函数是可选的,如果你不提供,池在为空时只会返回 nil

重置对象状态

在上面的代码片段中,目标是复用 Object 结构体实例,特别是它内部的切片。

复用切片有助于减少不必要的增长。例如,如果切片在使用过程中增长到 8192 字节,你可以在将其放回池之前将其长度重置为零。底层数组仍然有 8192 的容量,所以下次你需要它时,这 8192 字节已经准备好被复用了。

func (o *Object) Reset() {
    o.Data = o.Data[:0]
}
 
func main() {
    testObject := pool.Get().(*Object)
    // 对 testObject 执行某些操作
    testObject.Reset()
    pool.Put(testObject)
}

流程很清晰:你从池中获取一个对象,使用它,重置它,然后将其放回池中。重置对象可以在放回之前做,也可以在刚从池中取出后做,这不是强制的,但这是一个常见的做法。

类型断言 vs. 泛型包装器

如果你不喜欢使用类型断言 pool.Get().(*Object),有几种方法可以避免:

1. 使用专用函数从池中获取对象
func getObjectFromPool() *Object {
    obj := pool.Get().(*Object)
    return obj
}
2. 创建你自己的泛型版 sync.Pool
type Pool[T any] struct {
    sync.Pool
}
 
func (p *Pool[T]) Get() T {
    return p.Pool.Get().(T)
}
 
func (p *Pool[T]) Put(x T) {
    p.Pool.Put(x)
}
 
func NewPool[T any](newF func() T) *Pool[T] {
    return &Pool[T]{
        Pool: sync.Pool{
            New: func() interface{} {
                return newF()
            },
        },
    }
}

泛型包装器为你提供了一种更类型安全的方式来使用池,避免了类型断言。

只需注意,由于额外的间接层,它会增加一点点开销。在大多数情况下,这种开销是微不足道的,但如果你处于对 CPU 高度敏感的环境中,最好运行基准测试看看是否值得。

但等等,还有更多内容。

sync.Pool 与分配陷阱 (Allocation Trap)

如果你注意到前面的许多例子,包括标准库中的例子,我们在池中存储的通常不是对象本身,而是对象的指针

现象:传值 Put() 可能触发额外分配

让我用一个例子来解释原因:

var pool = sync.Pool{
    New: func() any {
        return []byte{}
    },
}
 
func main() {
    bytes := pool.Get().([]byte)
    // 对 bytes 执行某些操作
    _ = bytes
    pool.Put(bytes)
}

我们正在使用一个 []byte 的池。通常(虽然不总是),当你将一个传递给接口(interface{}/any)时,可能会导致该值被放置在堆上。这里也是如此,不仅是切片,任何你传递给 pool.Put() 的非指针内容都可能发生这种情况。

如果你使用逃逸分析进行检查:

$ go build -gcflags=-m
bytes escapes to heap

原因:接口装箱与逃逸

现在,我不会说我们的变量 bytes 移动到了堆上,我会说“bytes 的值通过接口逃逸到了堆上”。

规避:在 Pool 里存指针

要真正理解为什么会发生这种情况,我们需要深入研究逃逸分析的工作原理(我们可能会在另一篇文章中讨论)。但是,如果我们传递一个指针pool.Put(),就没有额外的分配:

var pool = sync.Pool{
    New: func() any {
        return new([]byte)
    },
}
 
func main() {
    bytes := pool.Get().(*[]byte)
    // 对 bytes 执行某些操作
    _ = bytes
    pool.Put(bytes)
}

再次运行逃逸分析,你会看到它不再逃逸到堆上。如果你想了解更多,Go 源代码中有一个例子

sync.Pool 内部实现

PMG 背景

在深入了解 sync.Pool 实际如何工作之前,有必要掌握 Go 的 PMG 调度模型的基础知识,这真的是 sync.Pool 如此高效的支柱。

有一篇很好的文章通过一些视觉效果分解了 PMG 模型:PMG models in Go

如果你今天想偷懒,想要一个简化的总结,我来帮你:

PMG 代表 P (Logical Processors, 逻辑处理器),M (Machine Threads, 机器线程),和 G (Goroutines, 协程)。关键点是,每个逻辑处理器 (P) 在任何时候只能有一个机器线程 (M) 在其上运行。而对于一个 Goroutine (G) 要运行,它需要被附加到一个线程 (M) 上。

这归结为 2 个关键点:

  1. 如果你有 n 个逻辑处理器 (P),你可以并行运行最多 n 个 Goroutine,只要你至少有 n 个机器线程 (M) 可用。
  2. 在任何时候,只有一个 Goroutine (G) 可以在单个处理器 (P) 上运行。因此,当 P1 忙于处理一个 G 时,没有其他 G 可以在那个 P1 上运行,直到当前的 G 被阻塞、完成或发生其他事情将其释放。

但问题是,Go 中的 sync.Pool 不仅仅是一个大池子,它实际上是由几个“本地”池组成的,每个池都绑定到一个特定的处理器上下文(P),Go 的运行时在任何给定时间都在管理这个上下文。

当在一个处理器 (P) 上运行的 Goroutine 需要池中的对象时,它会首先检查自己的 P-local pool(P 本地池),然后再去其他地方寻找。

这是一个明智的设计选择,因为这意味着每个逻辑处理器 (P) 都有自己的一套对象可以使用。这减少了 Goroutine 之间的争用,因为一次只有一个 Goroutine 可以访问其 P-local pool。

所以,这个过程超级快,因为不可能有两个 Goroutine 试图同时从同一个本地池中抓取同一个对象。

本地池结构

我们提到 “一次只有一个 Goroutine 可以访问 P-local pool”,但现实要微妙得多。

看下面的图,每个 P-local pool 实际上有两个主要部分:共享池链 (shared) 和私有对象 (private)。

这是 Go 源代码中本地池的定义:

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

private 字段存储单个对象,只有拥有此 P-local pool 的 P 才能访问它,我们称之为私有对象

它的设计使得 Goroutine 可以快速抓取一个可复用的对象(即私有对象),而无需处理任何互斥锁或同步技巧。换句话说,只有当前 Goroutine 可以访问它自己的私有对象,没有其他 Goroutine 可以与它竞争。

但如果私有对象不可用,那就是共享池链 (shared) 介入的时候了。

“为什么它会不可用?我以为只有一个 Goroutine 可以获取和放回私有对象。那么,谁是竞争对手?”

好问题。

虽然确实一次只有一个 Goroutine 可以访问 P 的私有对象,但有一个陷阱。如果 Goroutine A 抓取了私有对象,然后被阻塞或抢占,Goroutine B 可能会开始在同一个 P 上运行。当这种情况发生时,Goroutine B 将无法访问私有对象,因为 Goroutine A 仍然拥有它(或者说该位置为空)。

现在,与简单的私有对象不同,共享池链 (shared) 要复杂一些。

所以 Get() 流程可以简单地想象成这样:

上面的图并不完全准确,因为它没有考虑到 victim pool(受害者池)。

如果共享池链也是空的,sync.Pool 将创建一个新对象(假设你提供了 New() 函数)或者直接返回 nil。顺便说一句,共享池内部还有一个 victim 机制,我们将在最后介绍。

伪共享(False Sharing)

“等等,我看到 P-local pool 中有一个 pad 字段。那是怎么回事?”

当你查看 P-local pool 结构时,有一件事会跳出来,那就是这个 pad 属性。这是 VictoriaMetrics 的 CTO Aliaksandr Valialkin这个提交中调整的:

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

这个 pad 看起来可能有点奇怪,因为它不增加任何直接的功能,但它实际上是为了防止现代多核处理器上可能出现的一个问题,称为伪共享 (False Sharing)

共享池链

poolChain

sync.Pool 中的共享池链由一个名为 poolChain 的类型表示。

从名字上看,你可能猜到它是一个双向链表,你是对的。但这里有个转折:这个列表中的每个节点不仅仅是一个可复用的对象。相反,它是另一个称为池双端队列 (poolDequeue) 的结构。

type poolChain struct {
    head *poolChainElt
    tail atomic.Pointer[poolChainElt]
}
 
type poolChainElt struct {
    poolDequeue
    next, prev atomic.Pointer[poolChainElt]
}

poolChain 的设计非常具有策略性。它看起来只有 head 和 tail 两个字段,但它们指向的是一条链的两端:链上的每个节点(poolChainElt)都内嵌了一个 poolDequeue(你可以把它理解为一段“共享池段”)。因此图里画出多个 shared pool,实际对应的是链上的多个 poolDequeue 段。

这两个端点在运行过程中各自会发生不同的“移动/变动”:

  • head 侧的扩容与前移:当当前 head 指向的 poolDequeue 被写满时,会创建一个新的、更大的 poolDequeue(通常是前一个的 2 倍容量),把它链接到链表头部,并让 head 指向这个新段。由于只有“拥有当前 P-local pool 的 P”会往 head 侧写入,所以 head 可以是普通指针,走无锁快路径。
  • tail 侧的竞争读取与前移/移除:其他 P 作为消费者(steal)会从 tail 侧读取对象;多个消费者可能并发竞争同一个 tail 段,所以 tail 使用原子指针来同步。当 tail 指向的 poolDequeue 被完全取空后,该段会从链上移除,tail 原子地前移到下一个仍可能有数据的段。

和图里的两个细节对应:

  1. 当 tail 处的池双端队列完全被清空时,它会从列表中移除,下一个排队的池双端队列成为新的 tail。
  2. 当 head 处的池双端队列用完项目时,它不会被移除。相反,它留在原地,准备在添加新项目时被重新填充。

poolDequeue

现在,让我们看看 poolDequeue 是如何定义的。正如“dequeue”(双端队列)这个名字所暗示的,它是一个双端队列。

与只能在后面添加元素并从前面移除元素的常规队列不同,双端队列允许你在前面和后面插入和删除元素。

它的机制实际上与 pool chain 非常相似。它的设计使得一个生产者可以从头部添加或移除项目,而多个消费者可以从尾部获取项目。

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

生产者(即当前的 P)可以将新项目添加到队列的前端或从中获取项目。

同时,消费者只能从队列的尾部获取项目。这个队列是 Lock-Free(无锁) 的,这意味着它不使用锁来管理生产者和消费者之间的协调,只使用原子操作。

你可以把这个队列想象成一种环形缓冲区 (Ring Buffer)

简而言之,pool chain 结合了链表和每个节点的环形缓冲区。当一个 dequeue 填满时,一个新的、更大的 dequeue 被创建并链接到链的头部。这种设置有助于有效地管理大量的对象。

Put() 流程

让我们从 Put() 流程开始,因为它比 Get() 稍微直接一点,而且它与另一个过程有关:将 Goroutine 绑定(Pin)到 P。

当一个 Goroutine 在 sync.Pool 上调用 Put() 时,它尝试做的第一件事是将对象存储在当前 P 的 P-local pool 的私有位置。如果那个私有位置已经被占用,对象就会被推送到共享池链的头部

func (p *Pool) Put(x interface{}) {
    // 如果对象为 nil,则不执行任何操作
    if x == nil {
        return
    }
    // 固定当前 P 的 P-local pool
    l, _ := p.pin()
    // 如果私有缓存为空,则直接将对象存入
    if l.private == nil {
        l.private = x
        x = nil
    }
    // 如果私有缓存已占用,则将对象推入共享链表的头部
    if x != nil {
        l.shared.pushHead(x)
    }
    // 解除当前 P 的固定状态
    runtime_procUnpin()
}

pin()

我们还没有谈到 pin()runtime_procUnpin() 函数,但它们对于 Get()Put() 操作都很重要,因为它们确保 Goroutine 保持“绑定”到当前的 P。

从 Go 1.14 开始,Go 引入了抢占式调度,这意味着如果一个 Goroutine 在处理器 P 上运行时间过长(通常约为 10ms),运行时可以暂停它,给其他 Goroutine 运行的机会。

这通常有利于保持公平和响应性,但在处理 sync.Pool 时可能会导致问题。

sync.Pool 中的 Put()Get() 等操作假设 Goroutine 在整个操作过程中停留在同一个处理器(比如 P1)上。如果 Goroutine 在这些操作中间被抢占,然后在不同的处理器(P2)上恢复,它正在处理的本地数据可能最终来自错误的处理器。

那么,pin() 函数做什么呢?

// 将当前 goroutine 固定到 P 上,禁用抢占并返回 P 的 poolLocal 池以及 P 的 id。
// 完成使用 pool 后,调用者必须调用 runtime_procUnpin()。
func (p *Pool) pin() (*poolLocal, int) { ... }

简而言之,pin() 的作用是在将对象存入池中时,暂时禁用调度器的抢占机制。

虽然文档描述为“将当前 Goroutine 绑定到 P”,但其实际效果是锁定当前线程(M)与处理器(P)的绑定关系,从而防止 Goroutine 被抢占。这样一来,运行在该线程上的 Goroutine 就能在不被干扰的情况下完成操作。

此外,pin() 还有一个副作用:如果你在运行时通过 GOMAXPROCS(n) 更改了处理器数量,它会同步更新池中记录的 P 的数量。

共享池链的处理

当你需要向链中添加一个项目时,操作首先检查链的头部。还记得 head *poolChainElt 指针吗?那是列表中最近的 pool dequeue。

根据情况,可能会发生以下事情:

  1. 如果链的 head buffer 为 nil,意味着链中还没有 pool dequeue,则创建一个初始缓冲区大小为 8 的新 pool dequeue。然后将项目放入这个全新的 pool dequeue 中。
  2. 如果链的 head buffer 不为 nil 且该缓冲区未满,则只需将项目添加到 head 位置的缓冲区中。
  3. 如果链的 head buffer 不为 nil,但该缓冲区已满(意味着 head index 已经回绕并赶上了 tail index),则创建一个新的 pool dequeue。这个新池的缓冲区大小是当前 head 的两倍。项目被放入这个新的 pool dequeue,并且 pool chain 的 head 被更新为指向这个新池。

这就是 Put() 流程。这是一个相对简单的过程,因为它不涉及与其他处理器的本地池交互;一切都发生在 pool chain 的当前 head 内。

Get() 流程

乍一看,Get() 函数似乎与 Put() 非常相似。

它首先将当前 Goroutine 绑定到其 P 以防止抢占,然后检查并从其 P-local pool 中抓取私有对象,无需任何同步。如果私有对象不在那里,它会检查共享池链并弹出链的头部。

只有运行在当前 P-local pool 上的 Goroutine 才能访问链的头部,这就是为什么我们使用 popHead()

func (p *Pool) Get() interface{} {
    // 固定当前 P 的 P-local pool
    l, pid := p.pin()
    // 从当前 P-local pool 获取私有对象
    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
}

Put() 中的 p.pin() 不同,这里我们也得到了 pid,即当前 Goroutine 正在运行的 P 的 ID。我们需要这个来进行窃取 (Stealing) 过程,如果快速路径失败,就会进入这个过程。

快速路径是指对象在当前 P 的缓存中可用。但如果那不起作用,意味着私有对象和共享链的头部都是空的,慢速路径 (getSlow) 就会接管。

慢速路径 / 窃取

在慢速路径中,我们尝试从其他处理器 (P) 的缓存池中窃取对象。

窃取背后的想法是复用可能闲置在其他处理器缓存中的对象,而不是从头创建新对象。如果另一个 P 在其缓存池中有额外的对象,当前 P 可以抓取这些对象并投入使用。

窃取过程基本上循环遍历所有 P(除了当前的 pid),并尝试从每个 P 的共享池链中抓取一个对象:

for i := 0; i < int(size); i++ {
    l := indexLocal(locals, (pid+i+1)%int(size))
    if x, _ := l.shared.popTail(); x != nil {
        return x
    }
}

正如我们之前谈到的,在 poolChain 中,提供者(当前 P)在头部 push 和 pop,而多个消费者(其他 P)从尾部 pop。

所以,popTail 查看链表中的最后一个 pool dequeue,并尝试从该 pool dequeue 的末尾抓取数据。

  • 如果它找到数据,窃取成功,数据被返回。
  • 如果它在该 pool dequeue 中没有找到任何数据,tail index 增加,并且该 pool dequeue 从链中移除。

这个过程一直持续到它成功窃取一些数据或者在所有 pool chain 中用尽选项。

Q: “所以如果窃取过程失败,它会使用 New() 创建一个新对象吗?”

A: 不完全是。

如果在所有窃取尝试之后,它仍然找不到任何数据,该函数随后尝试从所谓的“Victim (受害者)”中获取数据。这是与 sync.Pool 如何清理对象有关的一个新概念,我们将在下一节详细介绍 victim 机制。

总结 Get() 流程

我们尝试以各种可能的方式抓取对象,如果什么都没找到,它最终使用 New() 创建一个新对象。但如果 New() 为 nil,那么它只是返回 nil。就这么简单。

现在,在尝试了 victim pool 之后,它被原子地标记为空(尽管并发访问可能仍然从中检索)。随后的 Get() 操作将跳过检查 victim cache,直到它再次被填充。

GC 与 Victim Pool

尽管 sync.Pool 是为了更好地管理资源而构建的,但它并没有给我们开发者直接的工具来清理或管理对象生命周期。相反,sync.Pool 在幕后处理清理工作,以避免不受控制的增长,这可能导致内存泄漏。

这种清理发生的主要方式是通过 Go 的垃圾回收器 (GC)。

还记得我们谈论 pin() 时吗?事实证明 pin() 还有另一个副作用。每当 sync.Pool 第一次调用 pin()(或通过 GOMAXPROCS 更改 P 的数量后),它会被添加到一个名为 allPools 的全局切片中:

package sync
 
var (
    allPoolsMu Mutex
 
    // allPools 是拥有一级缓存(primary cache)且不为空的池集合。
    // 受 1) allPoolsMu 锁和 pinning 机制,或 2) STW(Stop The World)保护。
    allPools []*Pool
 
    // oldPools 是可能拥有非空受害者缓存(victim cache)的池集合。
    // 受 STW 保护。
    oldPools []*Pool
)

这个 allPools []*Pool 切片跟踪应用程序中所有活动的 sync.Pool 实例。

在每个垃圾回收 (GC) 周期开始之前,Go 的运行时会触发一个清理过程,清除 allPools 切片。它是这样工作的:

  1. 在 GC 启动之前,它调用 clearPool,将 sync.Pool 中的所有对象(包括私有对象和共享池链)转移到所谓的 victim area(受害者区域)。
  2. 这些对象不会立即被丢弃,它们暂时保留在这个 victim area 中。
  3. 同时,来自上一个 GC 周期的已经在 victim area 中的对象在当前 GC 周期中被完全清除。

或者你可能有兴趣看看源代码:

func poolCleanup() {
    // 从所有池中丢弃受害者缓存(victim cache)。
    for _, p := range oldPools {
        p.victim = nil
        p.victimSize = 0
    }
 
    // 将一级缓存(primary cache)转移到受害者缓存。
    for _, p := range allPools {
        p.victim = p.local
        p.victimSize = p.localSize
        p.local = nil
        p.localSize = 0
    }
 
    // 之前拥有一级缓存的池现在拥有受害者缓存,并且所有池的一级缓存都已清空。
    oldPools, allPools = allPools, nil
}

为什么我们需要这个 victim 机制?

使用 victim 机制的原因是为了避免在 GC 周期之后立即突然完全清空池。如果池被一次性清空,可能会导致性能问题,因为任何新的对象请求都需要从头重新创建。所以我们先将对象移动到 victim area,sync.Pool 确保有一个缓冲期,对象在被完全丢弃之前仍然可以被复用。

总结一下,sync.Pool 中的对象至少需要 2 个 GC 周期才能被完全移除。

对于 GOGC 值较低的程序(控制 GC 运行频率),这可能是一个问题。如果 GOGC 设置得太低,清理过程可能会过快地移除未使用的对象,导致更多的缓存未命中。

最后的话:即使使用了 sync.Pool,如果你处理的是极高的并发和缓慢的 GC,你可能会遇到更多的开销。在这种情况下,一个好的解决方案可能是对 sync.Pool 的使用实施速率限制。