Go sync.Pool 及其背后的机制

banq


我们使用了sync.Pool很多,老实说,它非常适合我们处理临时对象的方式,尤其是字节缓冲区或切片。

它在标准库中很常用。例如,在encoding/json包中:

package json

var encodeStatePool sync.Pool

// An encodeState encodes JSON into a bytes.Buffer.
type encodeState struct {
    bytes.Buffer
// accumulated output

    ptrLevel uint
    ptrSeen  map[any]struct{}
}

在本例中,sync.Pool 用于重复使用 *encodeState 对象,这些对象用于处理将 JSON 编码到 bytes.Buffer 中的过程。

与其在每次使用后丢弃这些对象(这只会给垃圾收集器带来更多工作),我们不如将它们存储到一个池(sync.Pool)中。

下一次我们需要类似的对象时,只需从池中抓取即可,而无需从头开始创建一个新的对象。 在 net/http 包中还可以找到多个 sync.Pool 实例,用于优化 I/O 操作:

package http

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

当服务器读取请求体或写入响应时,它可以快速从这些池中提取一个预分配的阅读器或写入器,从而跳过额外的分配。 此外,*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.Pool: 时间序列数据库中的性能优化技术:用于 CPU 绑定操作的 sync.Pool


什么是 sync.Pool?
简单来说,Go 中的 sync.Pool 是一个存放临时对象以供日后重用的地方。 但问题是,你无法控制有多少对象会留在池中,而且你放在池中的任何东西都可能随时被移除,没有任何警告,读完上一节你就会知道为什么了。 好在这个池是线程安全的,因此多个 goroutines 可以同时使用它。

考虑到它是同步包的一部分,这并不令人感到意外。

"但我们为什么要重复使用对象呢?"当你同时运行多个程序时,它们往往需要类似的对象。 试想一下,如果同时多次运行 go f(),那么每个 goroutine 都会创建自己的对象,内存使用量就会迅速增加,这就会给垃圾回收器带来压力,因为一旦不再需要这些对象,它就必须将其清理干净。 这种情况会形成一个循环,即高并发会导致高内存使用量,进而拖慢垃圾回收器的运行速度。sync.Pool 就是为了打破这种循环而设计的。

type Object struct {
    Data []byte
}

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

要创建一个池,可以提供一个 New() 函数,当池为空时返回一个新对象。 该函数是可选的,如果不提供该函数,池为空时将返回 nil。 在上面的代码段中,目标是重复使用 Object 结构实例,特别是其中的片段。 重复使用片段有助于减少不必要的增长。

例如,如果片段slice在使用过程中增长到 8192 字节,可以在将其放回池之前将其长度重置为零。 底层数组的容量仍为 8192,因此下次需要时,这 8192 字节就可以重复使用了。

func (o *Object) Reset() {
    o.Data = o.Data[:0]
}

func main() {
    testObject := pool.Get().(*Object)

    // do something with testObject

    testObject.Reset()
    pool.Put(testObject)
}

流程非常清晰:从池中获取一个对象,使用它,重置它,然后把它放回池里。 重置对象可以在放回之前进行,也可以在从池中获取对象后立即进行,但这不是强制性的,而是一种常见的做法。

如果你不喜欢使用类型断言 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 内部原理
在我们了解sync.Pool实际工作原理之前,值得先了解 Go 的 PMG 调度模型的基础知识,这实际上是其sync.Pool如此高效的支柱。

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

如果你今天感觉很懒,想要一个简化的总结,我可以帮你:

PMG 代表 P(逻辑处理器)、M(机器线程)和 G( goroutine)。关键点在于,每个逻辑处理器 (P) 上任何时候只能运行一个机器线程 (M)。而要运行 goroutine ( G ),需要将其附加到线程 (M)。

可以归结为两个关键点:

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

但问题是,sync.PoolGo 中的 不仅仅是一个大池,它实际上是由几个“本地”池组成的,每个池都与 Go 的运行时在任何给定时间管理的特定处理器上下文或 P 相关联。

当在处理器(P)上运行的 goroutine 需要来自池的对象时,它会首先检查自己的 P 本地池,然后再查找其他地方。
这是一个明智的设计选择,因为这意味着每个逻辑处理器 (P) 都有自己的一组对象可供使用。这减少了 goroutine 之间的争用,因为一次只有一个 goroutine 可以访问其 P-local 池。
因此,该过程非常快,因为两个 goroutine 不可能同时尝试从同一个本地池中获取同一个对象。

更多点击标题