在 VictoriaMetrics 的内部实现中,大量使用了 Go 语言标准库中的sync.Pool
数据结构。sync.Pool
的主要作用是存储临时的、可替换的对象,以供重复使用,从而减轻垃圾回收器的负担。你可以将sync.Pool
视为一种线程安全的实现空闲列表的数据结构。
sync.Pool
特别有用的一个例子是重用bytes.Buffer
对象。bytes.Buffer
对象特别适用于需要读取原始数据,并暂时将其存储在内存中的场景。例如,VictoriaMetrics 在从数据库解压缩数据以及在抓取时解析指标元数据时,大量使用了bytes.Buffer
对象。
重要的是,bytes.Buffer
对象本质上是分配了一部分内存并内置了一些容量和可用性跟踪的临时辅助对象。由于它们是临时的辅助对象,一个bytes.Buffer
可以很容易地被另一个替换。
为了避免不必要的bytes.Buffer
对象分配,并减轻 GC 的压力,VictoriaMetrics 内部使用了一种数据结构,利用sync.Pool
来管理已经分配的bytes.Buffer
对象的生命周期。
type ByteBufferPool struct {
p sync.Pool
}
func (bbp *ByteBufferPool) Get() *ByteBuffer {
bbv := bbp.p.Get()
if bbv == nil {
return &ByteBuffer{}
}
return bbv.(*ByteBuffer)
}
func (bbp *ByteBufferPool) Put(bb *ByteBuffer) {
bb.Reset()
bbp.p.Put(bb)
}
上面的代码定义了一个名为ByteBufferPool
的类型,其中包含一个sync.Pool
,用于存储bytes.Buffer
对象。该类型还定义了两个方法:
Get
方法要么从池中返回一个bytes.Buffer
,要么分配并返回一个新的对象。Put
方法重置一个bytes.Buffer
,然后将其放回池中,以便稍后可以通过Get
方法重用。
ByteBufferPool
在 VictoriaMetrics 的代码库中被广泛使用,它显著减少了需要执行的新分配次数。不过在使用时需要注意以下问题:
对象窃取
sync.Pool
的原理是是通过每个 Proccesser 的 localPool 实现的。当 goroutine 被调度到与特定 processer(P)相关联的特定 thread(M)上运行,并尝试从池中检索对象时,sync.Pool
首先会在当前P的 localPool 中查找。如果在 localPool 中找不到对象,它会尝试从另一个 P 的 Pool 中“窃取”一个对象。由于需要跨 CPU 同步,窃取另一个池中的对象需要时间。如果窃取失败,则会分配一个新的对象。
由于 localPool 的存在,使用 sync.Pool
的最佳场景是在同一个 goroutine 中检索和释放对象, 这样这些对象将属于 goroutine 运行的同一个 P 的 localPool。这大大减少了从 pool 中检索对象和将其返回 Pool 的上下文切换。它防止了对象窃取,并减少了总体分配的对象数量,进一步减轻了垃圾回收器的压力。
一个次优但仍然可以使用 sync.Pool
的场景是,通过加锁,让对象由单个 goroutine 处理。例如,一个 goroutine 从 Pool 中检索一个对象,然后将其传递给另一个 goroutine,后者使用该对象并将其返回 Pool 中。
在同步处理的情况下,问题在于不同的 goroutine 更有可能被调度到不同的 thread(M)上,这意味着对象将从一个 P 的 localPool 中检索出来,并返回到另一个处理器的 pool 中。这增加了 sync.Pool
窃取对象的机会,从而降低了性能。
I/O密集型任务 在 I/O 密集型任务中,使用sync.Pool
来重用对象的效率要远低于在 CPU 密集型任务中重用对象。
I/O 操作可能会比较慢并且是分散的,这意味着 I/O 操作所需时间具有很大的随机性。这可能导致 Get
和 Put
调用的次数不均衡,从而导致对象重用效果不佳:
- 如果 I/O 操作被挂起,
sync.Pool
中的对象就会一直占用内存; - 当 I/O 操作返回结果时,
sync.Pool
中的对象可能已经被GC,从而需要重新分配。
由于这些原因,在 I/O 密集型任务中,依赖 sync.Pool
来管理临时对象可能无法获得预期的性能提升。在 I/O 操作期间,内存中的对象可能会因为GC而丢失,这使得 sync.Pool
减少内存分配和垃圾回收压力的优势变得不再明显。此外,I/O 操作的不可预测性可能会导致 sync.Pool
中的资源闲置或频繁地进行对象分配,从而降低了其效率。
因此,在设计系统时,应根据任务的特性(I/O 密集型还是 CPU 密集型)来决定是否以及如何在这些场景中使用 sync.Pool
。对于 I/O 密集型任务,可能需要考虑其他优化策略,例如使用异步 I/O、缓冲区管理或其他减少 I/O 延迟的方法。
ByteBufferPool使用示例
在 VictoriaMetrics 中,ByteBufferPool
的一个使用场景是对存储数据进行解压缩。解压缩操作是 CPU 密集型的,它会分配一些临时内存,因此非常适合使用ByteBufferPool
。下面的代码示例说明了在解压缩操作期间如何使用 ByteBufferPool
:
bb := bbPool.Get() // acquire buffer from pool
// perform decompressing in acquired buffer
bb.B, err = DecompressZSTD(bb.B[:0], src)
if err != nil {
return nil, fmt.Errorf("cannot decompress: %w", err)
}
// unmarshal from temporary buffer to destination buffer
dst, err = unmarshalInt64NearestDelta(dst, bb.B)
bbPool.Put(bb) // release buffer to the pool, so it can be reused
上述代码从bbPool
(ByteBufferPool
类型)中获取了一个bytes.Buffer
对象。接着,它对已经从磁盘读取的一个数据块进行解压缩,并将结果存入这个bytes.Buffer
中。需要注意的是,数据块已经从磁盘读取,因此解压缩操作完全是 CPU 密集型的,同时也保持了Get
和Put
调用之间的平衡。
代码在解压缩数据块后,会将其写入目标dst
,然后将buffer
返回到池pool
中,以供重用。需要注意的是,不要返回bb
或者保留对它的引用,因为在返回给pool
之后,它可能会在任何时候被另一个协程获取并进行修改。
带层级的ByteBuffer
到目前为止,本文中的例子都假设所有的bytes.Buffer
是可互换的。虽然从技术上讲这是正确的,但在实际应用中,缓冲区的大小各不相同。如果一个使用少量内存的代码从池中接收到一个大缓冲区,或者反之,这可能会导致内存使用效率低下。
例如,一个指标抓取的目标可能会公开 100 个指标,而另一个可能会公开 10,000 个。抓取每个 targets 的 vmagent 的 goroutine 需要不同大小的缓冲区。在上述的ByteBufferPool
实现中,调用代码无法控制它接收到的缓冲区的大小。因此,抓取 goroutine 可能会接收到一个比所需更小的缓冲区,并花费额外的时间进行扩展。这将逐渐用更大的缓冲区替换池中较小的缓冲区,从而增加了整体的内存使用量。
你可以通过将ByteBufferPool
分割成多个级别或“桶”来提高其效率。
每个级别包含不同范围的缓冲区大小,对池的请求可以根据预期需求请求特定大小的缓冲区。这种设计允许更精细地管理内存,通过确保每个任务或操作获得合适大小的缓冲区来优化内存使用和性能。
// pools contains pools for byte slices of various capacities.
//
// pools[0] is for capacities from 0 to 8
// pools[1] is for capacities from 9 to 16
// pools[2] is for capacities from 17 to 32
// ...
// pools[n] is for capacities from 2^(n+2)+1 to 2^(n+3)
//
// Limit the maximum capacity to 2^18, since there are no performance benefits
// in caching byte slices with bigger capacities.
var pools [17]sync.Pool
上面的代码片段展示了在 VictoriaMetrics 的 leveledbytebufferpool 包中这些级别是如何表示的。缓存池的最大容量被限制为2^18
字节,因为我们发现存储大于这个限制的缓冲区的 RAM 成本比重新创建这些缓冲区的成本要高。
为 pool 分桶之后,修改Get
方法使其能够提供预期大小。这样,池就能够返回适当大小的缓冲区。以下是在vmagent
抓取示例中如何使用此功能的代码片段:
func (sw *scrapeWork) scrape() {
body := leveledbytebufferpool.Get(sw.previousResponseBodyLength)
body.B = sw.ReadData(body.B[:0])
sw.processScrapedData(body)
leveledbytebufferpool.Put(body)
}
上面的代码片段根据最后一次请求抓取目标所需的大小来获取一个缓冲区。由于抓取目标暴露的指标数量在每次抓取时变化不大,因此这是一个关于本次需要多大缓冲区的合理猜测。由于函数本身可以确定缓冲区的大小,所以Put
函数的签名自上次以来没有改变。
源码解读
Get
func Get(capacity int) *bytesutil.ByteBuffer {
id, capacityNeeded := getPoolIDAndCapacity(capacity)
for i := 0; i < 2; i++ {
if id < 0 || id >= len(pools) {
break
}
if v := pools[id].Get(); v != nil {
return v.(*bytesutil.ByteBuffer)
}
id++
}
return &bytesutil.ByteBuffer{
B: make([]byte, 0, capacityNeeded),
}
}
func getPoolIDAndCapacity(size int) (int, int) {
// 1
size--
if size < 0 {
size = 0
}
// 2
size >>= 3
// 3
id := bits.Len(uint(size))
// 4
if id >= len(pools) {
id = len(pools) - 1
}
// 5
return id, (1 << (id + 3))
}
-
size--
的操作是为了调整输入的size
值,使其适应池大小的分配策略。这种策略通常基于容量的2的幂次,并且每个池分配的容量范围是连续的2的幂次之间。- 例如,
pools[0]
负责管理 0 到 8 字节的缓冲区。假设用户请求一个 8 字节的缓冲区,按照正常的逻辑(不减 1),计算出的id
将可能指向下一个容量区间,因为计算过程中会有一个位右移的操作,这会使得实际可用的缓冲区大小大于用户所需。
- 例如,
-
size >>= 3
:将size
右移 3 位,相当于除以 8。这是因为池的容量范围是以 8 的倍数来划分的。 -
使用
bits.Len
函数计算size
的二进制表示中 1 的位数。该函数返回一个整数,表示size
的二进制表示中最高位 1 的位置(从 0 开始计数)。例如,如果size
是 8(二进制 1000),bits.Len
将返回 3,因为最高位 1 在第 3 位。 -
检查计算出的
id
是否大于或等于pools
数组的长度。如果是,将id
设置为数组长度减 1,以确保它在数组的索引范围内。 -
最后,函数返回两个值:
id
(Pool 的索引)和2^(n+3)
(ByteBuffer 的最大容量)。
Put
func Put(bb *bytesutil.ByteBuffer) {
// 1
capacity := cap(bb.B)
// 2
id, poolCapacity := getPoolIDAndCapacity(capacity)
// 3
if capacity <= poolCapacity {
bb.Reset()
pools[id].Put(bb)
}
}
- 首先获取
bb
内部字节切片bb.B
的容量。 - 接着,根据容量调用
getPoolIDAndCapacity
函数,以确定应使用哪个 Pool 以及该池中 ByteBuffer 的最大容量(poolCapacity
)。 - 然后,检查
bb.B
的容量是否小于或等于对应池的最大容量。这一步是必要的,因为如果bb.B
的容量超过了池的最大容量,它就不应该被放回到池中,否则会违反池的设计原则。