写点什么

golang 面试基础 -sync.pool

作者:Quincy
  • 2023-10-05
    湖南
  • 本文字数:6995 字

    阅读完需:约 23 分钟

golang面试基础-sync.pool

前言

由于近期在准备一些面试高频的 golang 的基础,发现网上很多博文良莠不齐,无效的查询过多,造成自己一些阅读困扰。特想做这个 golang 基础源码分析系列,通过官方文档阅读,以及自己在网上翻阅出比较优秀的博文,做一个理解结合,来首先用于自我后续温习回顾,其次,也方便提供给后续有需要的读者们一次高效的阅读体验。

基础概念

sync.Pool,它是 Go 标准库中提供的一个通用的 Pool 数据结构,可以使用它创建池化的对象。sync.Pool 数据类型的对象用来保存一组可独立访问的临时对象,注意它是临时的,也就是说 sync.Pool 中保存的对象会在将来的某个时间从 sync.Pool 中移除掉,如果也没有被其他对象引用的话,该对象会被垃圾回收掉。

作用效果

使用 sync.Pool 可以提升程序性能,Go 是一个自带 GC 的语言,使用起来没有心智负担,我们想使用一个对象就创建一个对象,想使用多个就创建多个,不用关心对象的释放问题。因为有 GC 程序会帮助我们自动将不再使用的对象的内存回收掉。但是在使用 Go 开发高性能的应用程序的时候,需要考虑 GC 执行垃圾回收给我们带来的影响。在 GC 执行垃圾回收的过程中需要 STW(stop-the-world)时间,如果创建有大量的对象,GC 程序在检查这些对象是不是垃圾的时候需要花费很多时间。进而对我们的业务功能造成性能影响。


上面说的对象默认指的是堆上的对象。在 Go 语言中对象按分配位置分为两种,一种是分配在栈上,另一种分配在堆上。分配在栈上的对象不需要 GC 回收,因为随着函数的调用和返回,栈上分配的对象也不在存在了。GC 程序处理的是分配在堆上的对象,因为分配在堆上的对象是长期存在的,如果不进行释放,会在程序的整个生命周期内都存在。所以需要把堆上不再使用的对象尽快释放,减少内存占用。


频繁的创建对象,GC 每次需要检查这些对象是否可以回收,然后释放对象。sync.Pool 采用对象池的形式,把不用的对象回收起来,而不是直接 GC 掉,下次再使用的时候不用重新创建对象,使用对象池已有的对象。这样做有两方面收益:

  • 减轻 GC 负担

  • 减少新对象的申请、复用对象,避免每次使用对象都要创建对象

使用方式

当我们了解到其基本组成后

面试官一般会问,那你讲下 sync.pool 的使用方式,这个时候,我们可以从下面这例子入手。

sync.Pool 数据类型比较简单,对外提供的方法只有 3 个:New、Get 和 Put。

案例:

type Test struct {    A    int    Name string}
func main() { pool := sync.Pool{ New: func() interface{} { return &Test{ A: 1, Name: "test", } }, }
testObject := pool.Get().(*Test) println(testObject.A) // print 1
pool.Put(testObject)}
复制代码

代码结构

sync.pool 代码结构如下:

type Pool struct {    noCopy noCopy    // local是一个指向[p]poolLocal的指针,它指向的是一个数组,此数组中的元素是poolLocal类型    // 数组的大小是固定的,数量与p的数量是相同的,也是每个p对应数组中的1个元素    // local fixed-size per-P pool, actual type is [P]poolLocal    local     unsafe.Pointer     // local数组的大小    // size of the local array    localSize uintptr            // victim类型与local是一样的,可以理解为local的中转站,过渡存储local用的    // local from previous cycle    victim     unsafe.Pointer     // size of victims array    victimSize uintptr           // New方法,返回的空接口类型,该字段也是Pool唯一暴露给外界的字段    // New方法可以赋值一个能够产生值的函数,在调用Get方法的时候可以用    // New方法来产生一个value,如果没有给New赋值,调用Get时将返回nil.    New func() interface{}}
复制代码
  • local 是个数组,长度为 P 的个数。其元素类型是 poolLocal。这里面存储着各个 P 对应的本地对象池。可以近似的看做 [P]poolLocal。

  • localSize。代表 local 数组的长度。因为 P 可以在运行时通过调用 runtime.GOMAXPROCS 进行修改, 因此我们还是得通过 localSize 来对应 local 数组的长度。

  • New 就是用户提供的创建对象的函数。这个选项也不是必需。当不填的时候,Get 有可能返回 nil。

  • victim 和 victimSize。这一对变量代表了上一轮清理前的对象池,其内容语义 local 和 localSize 一致,防止被 GC 的重要步骤就是这块。

  • noCopy 是 Golang 源码中禁止拷贝的检测方法。可以通过 go vet 命令检测出 sync.Pool 的拷贝。

由于每个 P 都有自己的一个本地对象池 poolLocal,Get 和 Put 操作都会优先存取本地对象池。由于 P 的特性,操作本地对象池的时候整个并发问题就简化了很多,可以尽量避免并发冲突。

整个结构图如下:

poolLocal 结构体如下

// 每个 P 都会有一个 poolLocal 的本地type poolLocal struct {    poolLocalInternal
pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte}
type poolLocalInternal struct { private interface{} shared poolChain}
复制代码

pad 变量主要用于伪共享。这里是为了避免 CPU Cache 的 false sharing 问题:CPU Cache Line 通常是以 64 byte 或 128 byte 为单位。在我们的场景中,各个 P 的 poolLocal 是以数组形式存在一起。

假设 CPU Cache Line 为 128 byte,而 poolLocal 不足 128 byte 时,那 cacheline 将会带上其他 P 的 poolLocal 的内存数据,以凑齐一整个 Cache Line。

如果这时,两个相邻的 P 同时在两个不同的 CPU 核上运行,将会同时去覆盖刷新 CacheLine,造成 Cacheline 的反复失效,那 CPU Cache 将失去了作用。

CPU Cache 是距离 CPU 最近的 cache,如果能将其运用好,会极大提升程序性能。

Golang 这里为了防止出现 false sharing 问题,主动使用 pad 的方式凑齐 128 个 byte,这样就不会和其他 P 的 poolLocal 共享一套 CacheLine。


我们看 poolLocalInternal 的定义,其中每个本地对象池,都会包含两项:

private 私有变量:Get 和 Put 操作都会优先存取 private 变量,如果 private 变量可以满足情况,则不再深入进行其他的复杂操作。

shared:其类型为 poolChain,从名字不难看出这个是链表结构,这个就是 P 的本地对象池了。

pool chain 结构如下:

poolChain 是个链表结构,其链表头 HEAD 指向最新分配的元素项。链表中的每一项是一个 poolDequeue 对象。poolDequeue 本质上是一个 ring buffer 结构。其对应的代码定义如下:

type poolChain struct {    head *poolChainElt    tail *poolChainElt}
type poolChainElt struct { poolDequeue next, prev *poolChainElt}
type poolDequeue struct { headTail uint64 vals []eface}
复制代码

为什么 poolChain 是这么一个链表 + ring buffer 的复杂结构呢?简单的每个链表项为单一元素不行吗?

使用 ring buffer 是因为它有以下优点:

  1. 预先分配好内存,且分配的内存项可不断复用。

  2. 由于 ring buffer 本质上是个数组,是连续内存结构,非常利于 CPU Cache。在访问 poolDequeue 某一项时,其附近的数据项都有可能加载到统一 Cache Line 中,访问速度更快。

ring buffer 的这两个特性,非常适合于 sync.Pool 的应用场景。

poolDequeue 作为一个 ring buffer,自然需要记录下其 head 和 tail 的值。但在 poolDequeue 的定义中,head 和 tail 并不是独立的两个变量,只有一个 uint64 的 headTail 变量。

这是因为 headTail 变量将 head 和 tail 打包在了一起:其中高 32 位是 head 变量,低 32 位是 tail 变量。如下图所示:

对于一个 poolDequeue 来说,可能会被多个 P 同时访问(由于 Get 函数中对象窃取逻辑),这个时候就会带来并发问题。

例如:当 ring buffer 空间仅剩一个的时候,即 head - tail = 1。 如果多个 P 同时访问 ring buffer,在没有任何并发措施的情况下,两个 P 都可能会拿到对象,这肯定是不符合预期的。

在不引入 Mutex 锁的前提下,sync.Pool 是怎么实现的呢?sync.Pool 利用了 atomic 包中的 CAS 操作。两个 P 都可能会拿到对象,但在最终设置 headTail 的时候,只会有一个 P 调用 CAS 成功,另外一个 CAS 失败。

atomic.CompareAndSwapUint64(&d.headTail, ptrs, ptrs2)
复制代码

在更新 head 和 tail 的时候,也是通过原子变量 + 位运算进行操作的。例如,当实现 head++ 的时候,需要通过以下代码实现:

const dequeueBits = 32
atomic.AddUint64(&d.headTail, 1<<dequeueBits)
复制代码

动作方法

sync.pool 公共方法有三个 new、put、get,这三个方法也是我们基本使用的函数。

面试官一般也会考察到,你对这三个方法,尤其 put、get 的源码理解。接下来,我们来分析下:

New 方法

sync.Pool 是一个结构体,它有一个可导出的字段 New,该字段的类型是 func() interface{}函数。当使用 Get 方法从对象池中获取一个元素的时候,如果池子中没有存储的元素,会调用 New 方法创建一个新的对象。New 字段不是必须的,如果没有设置 New 字段池子中也没有存储的元素时,调用 Get 方法会返回 nil 值。New 方法可以理解成创建一个对象的构造函数,用于创建对象。

Put 方法

Put 方法将一个元素返回给 Pool, Pool 会把这个元素保存起来,Get 的时候可以复用。如果 Put 一个 nil 值,Pool 会忽略这个值。代码结构如下:

func (p *Pool) Put(x interface{}) {    if x == nil {        return    }
l, _ := p.pin()
if l.private == nil { l.private = x x = nil }
if x != nil { l.shared.pushHead(x) }
runtime_procUnpin()}
复制代码

从以上代码可以看到,在 Put 函数中首先调用了 pin()。pin 函数非常重要,它有三个作用:

1. 初始化或者重新创建 local 数组。 当 local 数组为空,或者和当前的 runtime.GOMAXPROCS 不一致时,将触发重新创建 local 数组,以和 P 的个数保持一致。

2. 取当前 P 对应的本地缓存池 poolLocal。

其实代码逻辑很简单,就是从 local 数组中根据索引取元素。这段的逻辑如下:

func indexLocal(l unsafe.Pointer, i int) *poolLocal {    lp := unsafe.Pointer(uintptr(l) + uintptr(i)*unsafe.Sizeof(poolLocal{}))    return (*poolLocal)(lp)}
复制代码
  1. 防止当前 P 被抢占。 

在 Go 1.14 以后,Golang 实现了抢占式调度:一个 goroutine 占用 P 时间过长,将会被调度器强制挂起。如果一个 goroutine 在执行 Put 或者 Get 期间被挂起,有可能下次恢复时,绑定就不是上次的 P 了。那整个过程就会完全乱掉。因此,这里使用了 runtime 包里面的 procPin,暂时不允许 P 被抢占。

接着,Put 函数会优先设置当前 poolLocal 私有变量 private。如果设置私有变量成功,那么将不会往 shared 缓存池写了。这样操作效率会更高效。

如果私有变量之前已经设置过了,那就只能往当前 P 的本地缓存池 poolChain 里面写了。我们接下来看下,sync.Pool 的每个 P 的内部缓存池 poolChain 是怎么实现的。

在 Put 的时候,会去直接取 poolChain 的链表头元素 HEAD: 

  • 如果 HEAD 不存在 ,则新建一个 buffer 长度为 8 的 poolDequeue,并将对象放置在里面。 

  • 如果 HEAD 存在,且 buffer 尚未满,则将元素直接放置在 poolDequeue 中。

  • 如果 HEAD 存在,但 buffer 满了,则新建一个新的 poolDequeue,长度为上个 HEAD 的 2 倍。同时,将 poolChain 的 HEAD 指向新的元素。

Put 的过程比较简单,整个过程不需要和其他 P 的 poolLocal 进行交互。

Get 方法

调用 Get 方法,会从 Pool 中取走一个元素,这个元素会从 Pool 中移除,返回给调用者。如果 Pool 中没有元素了,Pool.New 字段设置有值,会调用 Pool.New 方法创建一个对象返回。如果也没有设置 Pool.New,将会返回一个 nil.

func (p *Pool) Get() interface{} {    l, pid := p.pin()
x := l.private l.private = nil
if x == nil { x, _ = l.shared.popHead()
if x == nil { x = p.getSlow(pid) } } runtime_procUnpin()
if x == nil && p.New != nil { x = p.New() } return x}
复制代码

其中 pin() 的作用和 private 对象的作用,和 PUT 操作中的一致,这里就不再赘述了。我们着重看一下其他方面的逻辑:

首先,Get 函数会尝试从当前 P 的 本地对象池 poolChain 中获取对象。从当前 P 的 poolChain 中取数据时,是从链表头部开始取数据。 具体来说,先取位于链表头的 poolDequeue,然后从 poolDequeue 的头部开始取数据。

如果从当前 P 的 poolChain 取不到数据,意味着当前 P 的缓存池为空,那么将尝试从其他 P 的缓存池中 窃取对象。这也对应 getSlow 函数的内部实现。

在 getSlow 函数,会将当前 P 的索引值不断递增,逐个尝试从其他 P 的 poolChain 中取数据。注意,当尝试从其他 P 的 poolChain 中取数据时,是从链表尾部开始取的。


在对其他 P 的 poolChain 调用 popTail,会先取位于链表尾部的 poolDequeue,然后从 poolDequeue 的尾部开始取数据。如果从这个 poolDequeue 中取不到数据,则意味着该 poolDequeue 为空,则直接从该 poolDequeue 从 poolChain 中移除,同时尝试下一个 poolDequeue。

如果从其他 P 的本地对象池,也拿不到数据。接下来会尝试从 victim 中取数据。上文讲到 victim 是上一轮被清理的对象池, 从 victim 取对象也是 popTail 的方式。

最后,如果所有的缓存池都都没有数据了,这个时候会调用用户设置的 New 函数,创建一个新的对象。

func (p *Pool) getSlow(pid int) interface{} {	// See the comment in pin regarding ordering of the loads.	size := atomic.LoadUintptr(&p.localSize) // load-acquire	locals := p.local                        // load-consume	// Try to steal one element from other procs.	// 尝试从其他P中偷一个元素,尝试P的顺序是从当前pid+1个索引位置开始对应的	// poolLocal中的shared开始查找是否有元素,在shared中的查询方式是从	// 它的尾部弹出一个元素,如果shared队列中没有,继续尝试下一个位置,直到	// 循环检查一圈都没有,走victim中检查	for i := 0; i < int(size); i++ {		l := indexLocal(locals, (pid+i+1)%int(size))		if x, _ := l.shared.popTail(); x != nil {			return x		}	}
size = atomic.LoadUintptr(&p.victimSize) if uintptr(pid) >= size { return nil } // 下面尝试从受害中缓存victim中查找是否元素,查找的位置是从pid索引位置开始的poolLocal // 产生从它的shared尾部弹出一个元素,如果有就返回,如果没有就尝试下一个位置的poolLocal locals = p.victim l := indexLocal(locals, pid) // 检查victim.private是否有元素,有就返回 if x := l.private; x != nil { l.private = nil return x } for i := 0; i < int(size); i++ { l := indexLocal(locals, (pid+i)%int(size)) if x, _ := l.shared.popTail(); x != nil { return x } }}
复制代码

sync.Pool 在设计的时候,当操作本地的 poolChain 时,无论是 push 还是 pop,都是从头部开始。而当从其他 P 的 poolChain 获取数据,只能从尾部 popTail 取。这样可以尽量减少并发冲突。

对象的清理

sync.Pool 没有对外开放对象清理策略和清理接口。我们上面讲到,当窃取其他 P 的对象时,会逐步淘汰已经为空的 poolDequeue。但除此之外,sync.Pool 一定也还有其他的对象清理机制,否则对象池将可能会无限制的膨胀下去,造成内存泄漏。

Golang 对 sync.Pool 的清理逻辑非常简单粗暴。首先每个被使用的 sync.Pool,都会在初始化阶段被添加到全局变量 allPools []*Pool 对象中。Golang 的 runtime 将会在 每轮 GC 前,触发调用 poolCleanup 函数,清理 allPools。代码逻辑如下:

func poolCleanup() {    for _, p := range oldPools {        p.victim = nil        p.victimSize = 0    }
for _, p := range allPools { p.victim = p.local p.victimSize = p.localSize p.local = nil p.localSize = 0 }
oldPools, allPools = allPools, nil}
复制代码

这里需要正式介绍下 sync.Pool 的 victim(牺牲者) 机制,我们在 Get 函数的对象窃取逻辑中也有提到 victim。

在每轮 sync.Pool 的清理中,暂时不会完全清理对象池,而是将其放在 victim 中。等到下一轮清理,才完全清理掉 victim。也就是说,每轮 GC 后 sync.Pool 的对象池都会转移到 victim 中,同时将上一轮的 victim 清空掉。

为什么这么做呢? 这是因为 Golang 为了防止 GC 之后 sync.Pool 被突然清空,对程序性能造成影响。因此先利用 victim 作为过渡,如果在本轮的对象池中实在取不到数据,也可以从 victim 中取,这样程序性能会更加平滑。

victim 机制最早用在 CPU Cache 中。整个优势在于节省了一次 GC。

性能特点

总结来说,sync.Pool 利用以下手段将程序性能做到了极致:

  1. 利用 GMP 的特性,为每个 P 创建了一个本地对象池 poolLocal,尽量减少并发冲突。

  2. 每个 poolLocal 都有一个 private 对象,优先存取 private 对象,可以避免进入复杂逻辑。

  3. 在 Get 和 Put 期间,利用 pin 锁定当前 P,防止 goroutine 被抢占,造成程序混乱。

  4. 在获取对象期间,利用对象窃取的机制,从其他 P 的本地对象池以及 victim 中获取对象。

  5. 充分利用 CPU Cache 特性,提升程序性能。

  6. 将一次 GC 延缓成两次 GC,节省了一次 GC 开销。

参考文献

  1. 性能大杀器sync.pool

  2. 深度分析 Golang sync.Pool 底层原理


发布于: 刚刚阅读数: 3
用户头像

Quincy

关注

路过岁月,慢慢生活 2018-08-01 加入

一个心怀远方的搬砖懒汉

评论

发布
暂无评论
golang面试基础-sync.pool_Go_Quincy_InfoQ写作社区