为什么需要对象复用(Sync.pool)?
GC 的全称是 Garbage Collection(垃圾回收)。它是编程语言中的一种自动内存管理机制。Golang 作为一门编译型语言内置了GC机制,让开发者从繁琐且容易出错的“手动申请和释放”中解脱出来。Go 的 GC 虽然极大地方便了开发者,但它确实不是“免费”的。它的性能损耗主要来自 CPU 占用、内存压力和短暂的停顿 (STW)。
当大量 goroutine 同时运行时,它们常常需要相似的对象。想象一下同时多次运行 go f() 的场景。如果每个 goroutine 都创建自己的对象,内存使用量会迅速增加,这会给垃圾回收器带来压力,因为它必须在这些对象不再需要时清理它们。这种情况形成了一个循环:高并发导致高内存使用,进而拖慢垃圾回收器的速度。
另外每次分配新对象时,都会消耗CPU时间和内存资源,而当这些对象变得不可达时,GC又需要花费资源来识别和回收它们。在高并发场景下,这种开销会被成倍放大,最终导致:
- 内存使用呈锯齿状波动
- 程序响应时间不稳定
- CPU使用率周期性飙升
- 整体吞吐量下降(单位时间程序处理速度变慢)
这时候 sync.Pool就可以大显身手了,sync.Pool的设计初衷很简单:提供一种复用临时对象的机制,减少GC压力,提升程序性能。它特别适合那些创建成本较高、使用频率高但只需短暂使用的对象。
常见应用场景
当你的代码中有这样的模式:创建对象 → 短暂使用 → 丢弃,并且这个过程高频发生时,sync.Pool能显著提升性能。
| |
要创建一个池,你可以提供一个 New() 函数,当池为空时返回一个新对象。这个函数是可选的,如果你不提供它,池为空时就只会返回 nil 。
在上面的代码片段中,目标是重用 Object 结构体实例,特别是其中的切片。重用切片有助于减少不必要的扩容。
例如,如果切片在使用过程中增长到 8192 字节,你可以在将其放回池中之前将其长度重置为零。底层数组的容量仍然是 8192,因此下次需要时,这 8192 字节就可以直接复用。
| |
如上代码:从池中获取对象,使用它,重置它,然后将其放回池中。重置对象可以在放回池之前进行,也可以在从池中取出后立即进行,但这并非强制要求,而是一种常见的做法。
如果你不喜欢使用类型断言 pool.Get().(*Object) ,有几种方法可以避免它:
1.使用专门的函数从池中获取对象
| |
2.创建你自己的 sync.Pool 泛型版本:
| |
泛型包装器为你提供了一种更类型安全的方式来使用池,避免了类型断言。
只需注意,由于额外的间接层,它会带来一点微小的开销。在大多数情况下,这种开销可以忽略不计,但如果你处于对 CPU 高度敏感的环境中,最好运行基准测试来判断是否值得。
Benchmark 对比
| |
可以看到使用sync.Pool内存分配为 0,操作也更加快!!!
sync.Pool与内存分配
我们存储在池中的通常不是对象本身,而是指向对象的指针。
| |
我们正在使用一个 []byte 的池。通常(尽管并非总是如此),当你将一个值传递给接口时,可能会导致该值被分配到堆上。这里也是如此,不仅对于切片,对于任何传递给 pool.Put() 的非指针值都会发生这种情况。
如果通过逃逸分析检查:
| |
现在,我不会说我们的变量 bytes 移动到了堆上,我会说“bytes 的值通过接口逃逸到了堆上”。
然而,如果我们传递一个指向 pool.Put() 的指针,就不会有额外的分配:
| |
sync.Pool原理
1. 核心设计:分层的存储结构
为了在高并发下减少锁争用,sync.Pool 为每个 P(调度器中的处理器)分配了私有存储。它的结构如下:
localPool (Per-P 存储):
private (私有缓存): 每个 P 独有的一个对象。存取不需要加锁,速度极快。
shared (共享链表): 一个双端队列。本 P 存取需要加锁,其他 P 如果没东西了,可以来这里“偷”数据。
池本地与伪共享问题
**private (私有缓存):**这是一个巧妙的设计选择,因为这意味着每个逻辑处理器(P)都拥有自己的一组对象可供使用。这减少了 goroutine 之间的争用,因为每次只有一个 goroutine 可以访问其 P 本地池。
因此,这个过程非常迅速,因为两个 goroutine 不可能同时从同一个本地池中获取同一个对象。
本地池子的数据结构:
| |
private 字段用于存储单个对象,只有拥有此 P 本地池的 P 才能访问它,我们称之为私有对象。
它的设计使得一个 goroutine 能够快速获取一个可重用的对象(即私有对象),而无需涉及任何互斥锁或同步技巧。换句话说,只有一个 goroutine 可以访问其自身的私有对象,其他 goroutine 无法与之竞争。
但如果私有对象不可用,共享池链( shared )就会介入。
虽然确实每次只有一个 goroutine 能访问 P 的私有对象,但这里有个问题。如果 goroutine A 获取了私有对象后发生阻塞或被抢占,goroutine B 可能会在同一个 P 上开始运行。此时 goroutine B 将无法访问该私有对象,因为 goroutine A 仍然持有它。
与简单的私有对象不同,共享池链( shared )则略显复杂。
查看 P 本地池结构时,一个引人注目的细节是 pad 属性。这个 pad 看起来可能有点奇怪,因为它没有添加任何直接功能,但实际上它是为了防止在现代多核处理器上可能出现的一个问题,即伪共享。
现代 CPU 使用一种称为 CPU 缓存的组件来加速内存访问,这种缓存被划分为称为缓存行的单元,通常容纳 64 或 128 字节的数据。当 CPU 需要访问内存时,它不会只抓取单个字节或字——而是加载整个缓存行。
这意味着如果两块数据在内存中位置相近,即使它们在逻辑上是独立的,最终也可能位于同一缓存行上。
在 Go 的 sync.Pool 机制中,每个逻辑处理器(P)都拥有独立的 poolLocal ,这些结构存储在一个数组中。若 poolLocal 结构的尺寸小于缓存行容量,来自不同 P 的多个 poolLocal 实例就可能被分配到同一缓存行。此时问题便会产生:当运行在不同 CPU 核心的两个 P 同时访问各自的 poolLocal 时,它们可能会无意间产生资源冲突。
尽管每个 P 仅操作自身的 poolLocal ,但这些结构仍可能共享同一缓存行。
当某个处理器修改缓存行中的数据时,即使其他处理器仅操作该行内的不同变量,其缓存中的整条缓存行也会失效。这种机制会导致不必要的缓存失效和额外的内存流量,从而造成严重的性能损耗。
这就是 128 - unsafe.Sizeof(poolLocalInternal{})%128 发挥作用的地方。它会计算需要填充的字节数,以使 P 本地池的总大小成为 128 字节的倍数。这种填充有助于每个 poolLocal 获得自己的缓存行,从而防止伪共享并保持运行速度更快,实现无冲突。
池链与池双端队列
sync.Pool 中的共享池链由一个名为 poolChain 的类型表示。
从名称来看,你可能会猜测它是一个双向链表,而你的猜测是正确的。但这里有个转折:这个链表中的每个节点不仅仅是一个可重用的对象。相反,它是另一个被称为池双端队列( poolDequeue )的结构。
| |
当当前池队列(链表头部的那个)已满时,会创建一个新的池队列,其大小是前一个的两倍。这个新的、更大的池随后被添加到链表中。
如果你查看 poolChain 结构体,会发现它有两个字段:一个指针 head *poolChainElt 和一个原子指针 tail atomic.Pointer[poolChainElt] 。
- 生产者(拥有当前 P 本地池的 P)只向最新的池双端队列(我们称之为头部)添加新项。由于只有生产者会操作头部,因此无需锁或任何复杂的同步技巧,所以速度非常快。
- 消费者(其他 P)从池队列的尾部取出项目。由于多个消费者可能同时尝试弹出项目,因此通过原子操作同步对尾部的访问,以保持顺序。
关键在于:
- 当尾部的池队列完全清空时,它会被从列表中移除,而队列中的下一个池队列将成为新的尾部。但头部的情况则略有不同。
- 当头部池队列耗尽项目时,它不会被移除。相反,它会留在原地,准备在添加新项目时重新填充。
现在,让我们来看看池双端队列是如何定义的。顾名思义,“dequeue” 就是一个双端队列。与普通队列只能在尾部添加元素、从头部移除元素不同,双端队列允许你在头部和尾部两端插入和删除元素。
其机制实际上与池链颇为相似。它被设计成允许一个生产者从头部添加或移除项目,而多个消费者则可以从尾部获取项目。
| |
生产者(即当前的 P)可以向队列前端添加新项目或从中取出项目。与此同时,消费者仅从队列尾部获取项目。这个队列是无锁的,意味着它不使用锁来管理生产者和消费者之间的协调,仅通过原子操作实现。
你可以将这个队列视为一种环形缓冲区。
环形缓冲区,或称循环缓冲区,是一种使用固定大小数组以循环方式存储元素的数据结构。它之所以被称为“环形”,是因为缓冲区的末尾会回绕到开头,使其看起来像一个圆圈。
在我们讨论的池双端队列上下文中, headTail 字段是一个 64 位整数,它将两个 32 位索引打包成一个值。这些索引是队列的头和尾,有助于追踪数据在缓冲区中的存储和访问位置
- 尾索引指向缓冲区中最旧的项目所在位置,当消费者(如其他 goroutine)从缓冲区读取时,它们从这里开始并向前移动。
- 头索引是下一个数据将被写入的位置。当新数据到来时,它被放置在这个头索引处,然后索引移动到下一个可用槽位。
通过将头部和尾部索引打包成一个 64 位值,代码可以一次性更新两个索引,使操作具有原子性。当两个消费者(或一个消费者与一个生产者)同时尝试从队列中弹出项目时,这一点尤其有用。比较并交换(CAS)操作, d.headTail.CompareAndSwap(ptrs, ptrs2) ,确保其中只有一个能够成功。另一个则会失败并重试,从而保持秩序,无需任何复杂的锁机制。
队列中的实际数据存储在一个名为 vals 的环形缓冲区中,其大小必须是 2 的幂。这种设计选择使得在队列到达缓冲区末尾时更容易处理循环回绕。缓冲区中的每个槽位都是一个 eface 值,这是 Go 在底层表示空接口(interface{})的方式。
| |
缓冲区中的一个槽位会保持“使用中”状态,直到满足两个条件:
- 尾部索引移过该槽位,意味着该槽位中的数据已被某个消费者消费。
- 访问该槽位的消费者将其设置为 nil,表示生产者现在可以使用该槽位来存储新数据。
简而言之,池链为每个节点结合了链表和环形缓冲区。当一个出队操作填满时,会创建一个新的、更大的缓冲区,并将其链接到链表的头部。这种设置有助于高效管理大量对象。
现在,让我们深入探讨其流程:对象是如何被取出、放回以及自动释放的。这将阐明 Go 关于 sync.Pool 的声明:“存储在池中的任何项目都可能在任何时候被自动移除,且不会发出通知。”
2. 存取流程:先私有,再共享,最后偷
当你调用 Get() 获取对象时,优先级如下:
- 尝试私有区: 检查当前 P 的
private是否有对象,有就直接拿走。 - 尝试共享区: 检查当前 P 的
shared队列是否有对象。 - 去别人家“偷”: 如果自己这没东西了,会随机去其他 P 的
shared队列尾部偷一个。 - 最后兜底: 如果全都没有,就执行你定义的
New()函数创建一个新对象。
Put() 对象的逻辑正好相反:优先放 private,如果满了再推入 shared 队列。
Pool.Put()
接下来让我们从 Put() 流程开始,因为它比 Get() 稍微直接一些,而且它还涉及另一个过程:将 goroutine 固定到 P 上。
当一个 goroutine 在 sync.Pool 上调用 Put() 时,它首先尝试将对象存储在当前 P 的 P 本地池的私有槽中。如果该私有槽已被占用,对象就会被推入池链的头部,即共享部分。
| |
我们还没有讨论过 pin() 或 runtime_procUnpin() 函数,但它们对于 Get() 和 Put() 操作都很重要,因为它们确保 goroutine 保持“固定”在当前 P 上。我的意思是:
从 Go 1.14 开始,Go 引入了抢占式调度,这意味着如果某个 goroutine 在处理器 P 上运行时间过长(通常约为 10 毫秒),运行时可以暂停它,以便给其他 goroutine 运行的机会。这通常有助于保持公平性和响应性,但在处理 sync.Pool 时可能会引发问题。sync.Pool 中的 Put() 和 Get() 等操作假设 goroutine 在整个操作过程中都保持在同一个处理器(例如 P1)上。如果 goroutine 在这些操作过程中被抢占,然后在不同的处理器(P2)上恢复执行,那么它正在处理的本地数据最终可能来自错误的处理器。
那么, pin() 函数具体是做什么的呢?以下是 Go 源代码中的一段注释,对此进行了解释:
| |
基本上, pin() 在将对象放入池中时,会暂时禁用调度器抢占该 goroutine 的能力。
尽管它说的是"将当前 goroutine 固定到 P 上",但实际上发生的是当前线程(M)被锁定到处理器(P),这防止了它被抢占。因此,在该线程上运行的 goroutine 也不会被抢占。
作为副作用, pin() 还会在运行时更改 GOMAXPROCS(n)(它控制着 P 的数量)时更新处理器(P)的数量。不过,这不是这里的主要关注点。
共享池链的情况
当你需要向链中添加一个项目时,操作首先会检查链的头部。还记得 head *poolChainElt 指针吗?那是链表中最近使用的池出队。
根据具体情况,可能会发生以下情况:
- 如果链的头部缓冲区是
nil,意味着链中还没有池出队,那么会创建一个初始缓冲区大小为 8 的新池出队。然后,该项目会被放入这个全新的池出队中。 - 如果链表的头部缓冲区不是
nil且该缓冲区未满,则直接将项目添加到缓冲区的头部位置。 - 如果链表的头部缓冲区不是
nil,但该缓冲区已满,意味着头部索引已回绕并追上了尾部索引,此时会创建一个新的池双端队列。这个新池的缓冲区大小是当前头部缓冲区大小的两倍。项目被放入这个新的池双端队列中,并且池链表的头部被更新为指向这个新池。
以上就是 Put() 流程的主要内容。这是一个相对简单的过程,因为它不涉及与其他处理器(Ps)的本地池交互;所有操作都在池链表的当前头部内完成。
sync.Pool.Get()
乍一看, Get() 函数似乎与 Put() 非常相似。它首先将当前 goroutine 固定到其 P 上以防止被抢占,然后检查并从其 P 本地池中获取私有对象,无需任何同步。如果私有对象不存在,它会检查共享池链并弹出链的头部。
只有运行在当前 P 本地池上的 goroutine 才能访问链的头部,这就是我们使用 popHead() 的原因:
| |
与 Put() 中的 p.pin() 不同,这里我们还获取了 pid ,即当前 goroutine 正在运行的 P 的 ID。我们需要这个用于窃取过程,该过程在快速路径失败时发挥作用。
快速路径是指对象在当前 P 的缓存中可用的情况。但如果这行不通,意味着私有对象和共享链的头部都为空,慢速路径( getSlow )就会接管。在慢速路径中,我们尝试从其他处理器(P)的缓存池中窃取对象。
窃取背后的理念是重用可能闲置在其他处理器缓存中的对象,而不是从头开始创建新对象。如果另一个 P 的缓存池中有额外的对象,当前 P 可以获取这些对象并加以利用。
窃取过程基本上会遍历除当前 P( pid )之外的所有 P,并尝试从每个 P 的共享池链中获取对象:
| |
正如我们之前讨论过的,在 poolChain 中,提供者(当前 P)在头部进行推送和弹出操作,而多个消费者(其他 P)则从尾部弹出。因此, popTail 会查看链表中的最后一个池双端队列,并尝试从该池双端队列的末尾获取数据。
- 如果找到数据,窃取成功,数据将被返回。
- 如果在该池队列中找不到任何数据,尾索引会增加,该池队列会从链中被移除。
这个过程会持续进行,直到它要么成功窃取到一些数据,要么在所有池链中耗尽选项。如果经过所有窃取尝试后仍然找不到任何数据,该函数会尝试从所谓的"牺牲者(victim)“缓存中获取数据。这是一个与 sync.Pool 清理对象机制相关的新概念.
3. GC 清理机制(为什么它是“临时”的)
这就像是给垃圾桶加了一个“撤回”键:你扔掉的东西并不会立刻被焚烧,如果你突然又要用,还能从桶里捡回来,从而省去了重新买个新的开销。
这是 sync.Pool 最特殊的地方。在 Go 1.13 之后的版本中:
- victim 机制: 当 GC 发生时,
sync.Pool不会立刻清空所有对象,而是把当前活跃的对象移动到victim(受害者)区域。 - 两轮清理: 如果下一轮 GC 还没被用到,这些
victim对象才会被真正回收。 - 效果: 这种设计平滑了 GC 时的对象清理,防止 GC 完瞬间池子全空导致程序性能剧烈抖动。
victim Pool
还记得我们之前讨论过的 pin() 吗?原来 pin() 还有另一个作用。每当一个 sync.Pool 首次调用 pin() 时(或者在通过 GOMAXPROCS 改变 P 的数量之后),它会被添加到一个名为 allPools 的全局切片中,这个切片位于 sync 包内:
| |
这个 allPools []*Pool 切片会追踪你应用程序中所有活跃的 sync.Pool 实例。在每次垃圾回收(GC)周期开始之前,Go 的运行时都会触发一个清理过程,清空 allPools 切片。具体工作原理如下:
- 在 GC 启动之前,它会调用
clearPool,将sync.Pool中的所有对象(包括私有对象和共享池链)转移到所谓的“受害者(victim)区域”。 - 这些对象并不会立即被丢弃,它们暂时被保留在这个受害者区域中。
- 与此同时,在上一个垃圾回收周期中已经存在于受害者区域的对象,在当前垃圾回收周期中会被完全清除。
| |
在 sync.Pool 中使用受害者机制的原因是为了避免在 GC 周期后突然完全清空池。如果池被一次性清空,可能会导致性能问题,因为任何新的对象请求都需要从头开始重新创建。因此,我们先将对象移动到受害者区域, sync.Pool 确保在对象被完全丢弃之前,仍有一个缓冲期可以重用它们。总之, sync.Pool 中的一个对象至少需要 2 个 GC 周期才能被完全移除。这对于 GOGC 值较低的程序来说可能是个问题,该值控制 GC 运行以清理未使用对象的频率。如果 GOGC 设置得太低,清理过程可能会过快移除未使用的对象,导致更多的缓存未命中。
4. 为什么它能优化性能?
- 减少分配: 对象被复用了,堆上产生的新对象就少了。
- 减轻扫描压力: GC 只扫描活跃指针。如果对象在
sync.Pool里被复用,而不是频繁创建和销毁,GC 标记的工作量会大幅下降。
使用 sync.Pool 的注意点
不要复用连接: 池子里的对象随时会被 GC 清理,不适合放数据库连接或 TCP 连接。
状态重置: 取出的对象可能带有旧数据,必须手动重置(例如
slice要set length to 0),否则会导致业务逻辑出错。内存浪费: 如果放进去的对象太大且不再使用,可能会导致内存占用长时间下不来(直到 GC 清理)。
即使使用了
sync.Pool,如果你正在处理极高的并发和缓慢的垃圾回收,你可能会遇到更多的开销。在这种情况下,一个不错的解决方案可能是在sync.Pool的使用上实施速率限制。
参考文章
https://victoriametrics.com/blog/go-sync-pool/
https://victoriametrics.com/blog/tsdb-performance-techniques-sync-pool/
https://blog.csdn.net/sinat_27016095/article/details/147962376