为什么需要对象复用(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能显著提升性能。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
type Object struct {
	Data []byte
}

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

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

​ 在上面的代码片段中,目标是重用 Object 结构体实例,特别是其中的切片。重用切片有助于减少不必要的扩容

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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.使用专门的函数从池中获取对象

1
2
3
4
func getObjectFromPool() *Object {
	obj := pool.Get().(*Object)
	return obj
}

​ 2.创建你自己的 sync.Pool 泛型版本:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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 高度敏感的环境中,最好运行基准测试来判断是否值得。

Benchmark 对比

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package main

import (
	"sync"
)

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

func main() {

}

func noPoolExample() {
	var bytes []byte

	// do something with bytes
	bytes = append(bytes, 1, 2, 3, 4, 5)
}

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

	// do something with bytes
	*bytes = append(*bytes, 1, 2, 3, 4, 5)

	// reset bytes before putting it back to the pool
	*bytes = (*bytes)[:0]

	pool.Put(bytes)
}

-----------------------

package main

import "testing"

func BenchmarkPool(b *testing.B) {
	for n := 0; n < b.N; n++ {
		poolExample()
	}
}

func BenchmarkBufferPool(b *testing.B) {
	for n := 0; n < b.N; n++ {
		noPoolExample()
	}
}

-----------------------------


 go test -bench=^Benchmark -benchmem .        

goos: darwin
goarch: arm64
pkg: github.com/Luenci
cpu: Apple M4
BenchmarkPool-10                169598262                7.024 ns/op           0 B/op          0 allocs/op
BenchmarkBufferPool-10          125689365                8.957 ns/op           8 B/op          1 allocs/op
PASS
ok      github.com/Luenci      4.171s

​ 可以看到使用sync.Pool内存分配为 0,操作也更加快!!!

sync.Pool与内存分配

​ 我们存储在池中的通常不是对象本身,而是指向对象的指针。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var pool = sync.Pool{
	New: func() any {
		return []byte{}
	},
}

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

	// do something with bytes
	_ = bytes

	pool.Put(bytes)
}

​ 我们正在使用一个 []byte 的池。通常(尽管并非总是如此),当你将一个值传递给接口时,可能会导致该值被分配到堆上。这里也是如此,不仅对于切片,对于任何传递给 pool.Put() 的非指针值都会发生这种情况

​ 如果通过逃逸分析检查:

1
2
3
4
5
$ go build -gcflags=-m
....
./main.go:7:16: []byte{} escapes to heap
./main.go:7:16: []byte{} escapes to heap
./main.go:17:11: bytes escapes to heap

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

​ 然而,如果我们传递一个指向 pool.Put() 的指针,就不会有额外的分配:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
var pool = sync.Pool{
	New: func() any {
		return []byte{}
	},
}

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

	// do something with bytes
	_ = bytes

	pool.Put(bytes)
}

sync.Pool原理

1. 核心设计:分层的存储结构

为了在高并发下减少锁争用,sync.Pool 为每个 P(调度器中的处理器)分配了私有存储。它的结构如下:

  • localPool (Per-P 存储):

    • private (私有缓存): 每个 P 独有的一个对象。存取不需要加锁,速度极快。

    • shared (共享链表): 一个双端队列。本 P 存取需要加锁,其他 P 如果没东西了,可以来这里“偷”数据。

池本地与伪共享问题

​ **private (私有缓存):**这是一个巧妙的设计选择,因为这意味着每个逻辑处理器(P)都拥有自己的一组对象可供使用。这减少了 goroutine 之间的争用,因为每次只有一个 goroutine 可以访问其 P 本地池。

因此,这个过程非常迅速,因为两个 goroutine 不可能同时从同一个本地池中获取同一个对象。

本地池子的数据结构:

1
2
3
4
5
6
7
8
9
type poolLocalInternal struct {
	private any       
	shared  poolChain 
}

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

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 )的结构。

1
2
3
4
5
6
7
8
9
type poolChain struct {
	head *poolChainElt
	tail atomic.Pointer[poolChainElt]
}

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

​ 当当前池队列(链表头部的那个)已满时,会创建一个新的池队列,其大小是前一个的两倍。这个新的、更大的池随后被添加到链表中。

​ 如果你查看 poolChain 结构体,会发现它有两个字段:一个指针 head *poolChainElt 和一个原子指针 tail atomic.Pointer[poolChainElt]

  • 生产者(拥有当前 P 本地池的 P)只向最新的池双端队列(我们称之为头部)添加新项。由于只有生产者会操作头部,因此无需锁或任何复杂的同步技巧,所以速度非常快。
  • 消费者(其他 P)从池队列的尾部取出项目。由于多个消费者可能同时尝试弹出项目,因此通过原子操作同步对尾部的访问,以保持顺序。

关键在于:

  1. 当尾部的池队列完全清空时,它会被从列表中移除,而队列中的下一个池队列将成为新的尾部。但头部的情况则略有不同。
  2. 当头部池队列耗尽项目时,它不会被移除。相反,它会留在原地,准备在添加新项目时重新填充。

​ 现在,让我们来看看池双端队列是如何定义的。顾名思义,“dequeue” 就是一个双端队列。与普通队列只能在尾部添加元素、从头部移除元素不同,双端队列允许你在头部和尾部两端插入和删除元素。

​ 其机制实际上与池链颇为相似。它被设计成允许一个生产者从头部添加或移除项目,而多个消费者则可以从尾部获取项目。

1
2
3
4
type poolDequeue struct {
	headTail atomic.Uint64
	vals []eface
}

​ 生产者(即当前的 P)可以向队列前端添加新项目或从中取出项目。与此同时,消费者仅从队列尾部获取项目。这个队列是无锁的,意味着它不使用锁来管理生产者和消费者之间的协调,仅通过原子操作实现。

​ 你可以将这个队列视为一种环形缓冲区。

​ 环形缓冲区,或称循环缓冲区,是一种使用固定大小数组以循环方式存储元素的数据结构。它之所以被称为“环形”,是因为缓冲区的末尾会回绕到开头,使其看起来像一个圆圈。

​ 在我们讨论的池双端队列上下文中, headTail 字段是一个 64 位整数,它将两个 32 位索引打包成一个值。这些索引是队列的头和尾,有助于追踪数据在缓冲区中的存储和访问位置

  • 尾索引指向缓冲区中最旧的项目所在位置,当消费者(如其他 goroutine)从缓冲区读取时,它们从这里开始并向前移动。
  • 头索引是下一个数据将被写入的位置。当新数据到来时,它被放置在这个头索引处,然后索引移动到下一个可用槽位。

通过将头部和尾部索引打包成一个 64 位值,代码可以一次性更新两个索引,使操作具有原子性。当两个消费者(或一个消费者与一个生产者)同时尝试从队列中弹出项目时,这一点尤其有用。比较并交换(CAS)操作, d.headTail.CompareAndSwap(ptrs, ptrs2) ,确保其中只有一个能够成功。另一个则会失败并重试,从而保持秩序,无需任何复杂的锁机制。

​ 队列中的实际数据存储在一个名为 vals 的环形缓冲区中,其大小必须是 2 的幂。这种设计选择使得在队列到达缓冲区末尾时更容易处理循环回绕。缓冲区中的每个槽位都是一个 eface 值,这是 Go 在底层表示空接口(interface{})的方式。

1
2
3
type eface struct {
	typ, val unsafe.Pointer
}

缓冲区中的一个槽位会保持“使用中”状态,直到满足两个条件:

  • 尾部索引移过该槽位,意味着该槽位中的数据已被某个消费者消费。
  • 访问该槽位的消费者将其设置为 nil,表示生产者现在可以使用该槽位来存储新数据。

简而言之,池链为每个节点结合了链表和环形缓冲区。当一个出队操作填满时,会创建一个新的、更大的缓冲区,并将其链接到链表的头部。这种设置有助于高效管理大量对象。

​ 现在,让我们深入探讨其流程:对象是如何被取出、放回以及自动释放的。这将阐明 Go 关于 sync.Pool 的声明:“存储在池中的任何项目都可能在任何时候被自动移除,且不会发出通知。”

2. 存取流程:先私有,再共享,最后偷

当你调用 Get() 获取对象时,优先级如下:

  1. 尝试私有区: 检查当前 P 的 private 是否有对象,有就直接拿走。
  2. 尝试共享区: 检查当前 P 的 shared 队列是否有对象。
  3. 去别人家“偷”: 如果自己这没东西了,会随机去其他 Pshared 队列尾部偷一个。
  4. 最后兜底: 如果全都没有,就执行你定义的 New() 函数创建一个新对象。

Put() 对象的逻辑正好相反:优先放 private,如果满了再推入 shared 队列。

Pool.Put()

​ 接下来让我们从 Put() 流程开始,因为它比 Get() 稍微直接一些,而且它还涉及另一个过程:将 goroutine 固定到 P 上。

​ 当一个 goroutine 在 sync.Pool 上调用 Put() 时,它首先尝试将对象存储在当前 P 的 P 本地池的私有槽中。如果该私有槽已被占用,对象就会被推入池链的头部,即共享部分。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (p *Pool) Put(x interface{}) {
	// If the object is nil, it will do nothing
	if x == nil {
		return
	}

	// Pin the current P's P-local pool
	l, _ := p.pin()

	// If the private pool is not there, create it and set the object to it
	if l.private == nil {
		l.private = x
		x = nil
	}

	// If the private object is there, push it to the head of the shared chain
	if x != nil {
		l.shared.pushHead(x)
	}

	// Unpin the current P
	runtime_procUnpin()
}

​ 我们还没有讨论过 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 源代码中的一段注释,对此进行了解释:

1
2
3
4
5
6
// 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 函数将当前 goroutine 绑定到指定的 P,禁用抢占,并返回该 P 的本地池 (poolLocal) 和 P 的 ID。
// 调用者在使用完该池后必须调用 runtime_procUnpin() 函数。
func (p *Pool) pin() (*poolLocal, int) { ... }

​ 基本上, 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() 的原因:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
func (p *Pool) Get() interface{} {
	// Pin the current P's P-local pool
	l, pid := p.pin()

	// Get the private object from the current P-local pool
	x := l.private
	l.private = nil

	// If the private object is not there, pop the head of the shared pool chain
	if x == nil {
		x, _ = l.shared.popHead()

		// Steal from other P's cache
		if x == nil {
			x = p.getSlow(pid)
		}
	}
	runtime_procUnpin()

	// If the object is still not there, create a new object from the factory function
	if x == nil && p.New != nil {
		x = p.New()
	}
	return x
}

​ 与 Put() 中的 p.pin() 不同,这里我们还获取了 pid ,即当前 goroutine 正在运行的 P 的 ID。我们需要这个用于窃取过程,该过程在快速路径失败时发挥作用。

​ 快速路径是指对象在当前 P 的缓存中可用的情况。但如果这行不通,意味着私有对象和共享链的头部都为空,慢速路径( getSlow )就会接管。在慢速路径中,我们尝试从其他处理器(P)的缓存池中窃取对象。

​ 窃取背后的理念是重用可能闲置在其他处理器缓存中的对象,而不是从头开始创建新对象。如果另一个 P 的缓存池中有额外的对象,当前 P 可以获取这些对象并加以利用。

​ 窃取过程基本上会遍历除当前 P( pid )之外的所有 P,并尝试从每个 P 的共享池链中获取对象:

1
2
3
4
5
6
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)在头部进行推送和弹出操作,而多个消费者(其他 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 包内:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package sync

var (
	allPoolsMu Mutex

	// allPools is the set of pools that have non-empty primary
	// caches. Protected by either 1) allPoolsMu and pinning or 2)
	// STW.
	allPools []*Pool

	// oldPools is the set of pools that may have non-empty victim
	// caches. Protected by STW.
	oldPools []*Pool
)

​ 这个 allPools []*Pool 切片会追踪你应用程序中所有活跃的 sync.Pool 实例。在每次垃圾回收(GC)周期开始之前,Go 的运行时都会触发一个清理过程,清空 allPools 切片。具体工作原理如下:

  • 在 GC 启动之前,它会调用 clearPool ,将 sync.Pool 中的所有对象(包括私有对象和共享池链)转移到所谓的“受害者(victim)区域”。
  • 这些对象并不会立即被丢弃,它们暂时被保留在这个受害者区域中。
  • 与此同时,在上一个垃圾回收周期中已经存在于受害者区域的对象,在当前垃圾回收周期中会被完全清除。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func poolCleanup() {
	// 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
}

​ 在 sync.Pool 中使用受害者机制的原因是为了避免在 GC 周期后突然完全清空池。如果池被一次性清空,可能会导致性能问题,因为任何新的对象请求都需要从头开始重新创建。因此,我们先将对象移动到受害者区域, sync.Pool 确保在对象被完全丢弃之前,仍有一个缓冲期可以重用它们。总之, sync.Pool 中的一个对象至少需要 2 个 GC 周期才能被完全移除。这对于 GOGC 值较低的程序来说可能是个问题,该值控制 GC 运行以清理未使用对象的频率。如果 GOGC 设置得太低,清理过程可能会过快移除未使用的对象,导致更多的缓存未命中。

4. 为什么它能优化性能?

  1. 减少分配: 对象被复用了,堆上产生的新对象就少了。
  2. 减轻扫描压力: GC 只扫描活跃指针。如果对象在 sync.Pool 里被复用,而不是频繁创建和销毁,GC 标记的工作量会大幅下降。

使用 sync.Pool 的注意点

  • 不要复用连接: 池子里的对象随时会被 GC 清理,不适合放数据库连接或 TCP 连接。

  • 状态重置: 取出的对象可能带有旧数据,必须手动重置(例如 sliceset 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