1. 简介

sync.Poolsync.Pool
sync.Poolsync.Pool

主要会涉及到Go语言中实现并发的GMP模型以及其基本的调度原理,以及本地缓存的设计,无锁队列的使用这几个部分的内容,综上这几个方面的内容实现了不加锁也能够保证线程安全。

2. GMP之间的绑定关系

sync.Pool

2.1 M和P的关系

在GMP模型中,M和P可以动态绑定,一个M可以在运行时绑定到任意一个P上,而一个P也可以与任意一个M绑定。这种绑定方式是动态的,可以根据实际情况进行灵活调整,从而实现更加高效的协程调度。

尽管M和P可以动态绑定,但在特定时间点,一个M只会对应一个P。这是因为M是操作系统线程,而P是Go语言的逻辑处理器,Go语言的逻辑处理器需要在某个操作系统线程中运行,并且是被该逻辑处理器(P)单独占用的。

P的数量一般是和CPU核数保持一致,每个P占用一个CPU核心来执行,可以通过runtime.GOMAXPROCS函数来修改。不过在大多数情况下,不需要手动修改,Go语言的调度器会根据实际情况自动进行调整。

2.2 P和G的关系

刚创建的Goroutine会被放入当前线程对应P的本地队列中等待被执行。如果本地队列已满,则会放入全局队列中,供其他线程的P来抢占执行。

当P空闲时,会尝试从全局队列中获取Goroutine来执行。如果全局队列中没有Goroutine,则会从其他处理器的本地运行队列中"偷取"一些Goroutine来执行。

如果协程执行过程中遇到阻塞操作(比如等待I/O或者锁),处理器(P)会立即将协程移出本地运行队列,并执行其他协程,直到被阻塞的协程可以继续执行为止。被阻塞的协程会被放到相应的等待队列中等待事件发生后再次被唤醒并加入到运行队列中,但不一定还是放回原来的处理器(P)的等待队列中。

从上述过程可以看出,G和P的绑定关系是动态绑定的,在不同的时间点,同一个G可能在不同的P上执行,同时,在不同的时间点,P也会调度执行不同的G。

2.3 总结

每个P在某个时刻只能绑定一个M,而每个G在某个时刻也只存在于某个P的等待队列中,等待被调度执行。这是GMP模型的基本调度原理,也是Go语言高效调度的核心所在。通过动态绑定和灵活调度,可以充分利用多核处理器的计算能力,从而实现高并发、高效率的协程调度。

sync.Pool

3.Sync.Pool与GMP模型

3.1 sync.Pool性能问题

sync.Pool
sync.Pool

因此,为了提高程序的性能,我们需要寻找一种减少并发冲突的方式。有什么方式能够减少并发冲突呢?

3.2 基于GMP模型的改进

回到GMP模型,从第二节对GMP模型的介绍中,我们知道协程(G)需要在逻辑处理器(P)上执行,而逻辑处理器的数量是有限的,一般与CPU核心数相同。而之前的sync.Pool实现方式是所有P竞争同一份数据,容易导致大量协程进入阻塞状态,影响程序性能。

sync.Pool

协程运行时都需要绑定一个逻辑处理器(P),此时每个P都有自己的数据缓存,需要对象时从绑定的P的缓存中获取,用完后重新放回。这种实现方式减少了协程竞争同一份数据的情况,只有在同一个逻辑处理器上的协程才存在竞争,从而减少并发冲突,提升性能。

3.3 能不能完全不加锁

在上面的实现中,处于不同的P上的协程都是操作不同的数据,此时并不会出现并发问题。唯一可能出现并发问题的地方,为协程在获取缓存对象时,逻辑处理器中途调度其他协程来执行,此时才可能导致的并发问题。那这里能不能避免并发呢?

那如果能够将协程固定到逻辑处理器P上,并且不允许被抢占,也就是该P上永远都是执行某一个协程,直到成功获取缓存对象后,才允许逻辑处理器去调度执行其他协程,那么就可以完全避免并发冲突的问题了。

因此,如果我们能够做到协程在读取缓冲池中的数据时,能够完全占用逻辑处理器P,不会被抢占,此时就不会出现并发了,也不需要加锁了。

runtime_procPinruntime_procPin

4. sync.Pool初步实现

sync.Poolruntime_procPin
sync.Pool

4.1 sync.Pool结构体定义

NewlocalpoolLocalpoolLocal

4.2 Put方法

PutpinpoolLocalpoolLocalruntime_procUnpin
pinpoolLocalpin
pinruntime_procPinpoolLocal
runtime_procPinpoolLocalpoolLocal
runtime_LoadAcquintptrlocalSizelocalSizepoolLocalpoolLocal
localSizepoolLocalpinSlow()pinSlow
runtime.GOMAXPROCS(0)poolLocallocal
atomic.StorePointerp.locallocalruntime_StoreReluintptrp.localSizesizepoolLocal
poolLocalpidlocalpinSlowp.localpinpoolLocal

4.3 Get方法

pin()localpidlocal.privateruntime_procUnpin()
PoolNewNew

4.4 总结

sync.Poolsync.Pool
runtime_procPin

5. sync.Pool实现优化

5.1 问题描述

sync.Poolsync.Pool

现在可能存在的问题,在于每个 Processor 都保存一份缓存数据,那么当某个 Processor 上的 goroutine 需要使用缓存时,可能会发现它所在的 Processor 上的缓存池为空的,而其他 Processor 上的缓存对象却没有被利用。这样就浪费了其他 Processor 上的资源。

sync.Poolsync.Pool

这里就陷入了一个两难的境地,如果多个Processor共享同一个缓冲池,会存在容易导致大量协程进入阻塞状态,进一步降低性能。每个 Processor 都保存一份缓存数据的话,此时也容易陷入资源浪费的问题。那能怎么办呢?

5.2 实现优化

很多时候,可能并没有十全十美的事情,我们往往需要折中。比如上面多个Processor共享同一个缓冲池,会降低性能;而每个 Processor 都保存一份缓存数据的话,容易陷入资源浪费的问题。

这个时候,我们可以折中一下,不采用完全共享的模式,也不采用完全独占的模式。而采用部分独有、部分共享的模式。每个 Processor 独占一部分缓存,可以避免不同 Processor 之间的竞争,提高并发性能。同时,每个 Processor 也可以共享其他 Processor 上的缓存,避免了浪费。相对于完全共享和完全独立的模式,这种设计方式是不是能够更好地平衡并发性能和缓存利用效率。

同时,也可以基于部分独有,部分共享的模式的基础上,再对其进行优化。对于共享部分的资源,可以使用多个缓冲池来存储,是将其给了所有的Processor,每个Processor保留一部分共享数据。

当Processor读取数据时,此时先从自身的私有缓冲中读取,读取不到再到自身的共享缓存中读取,读取不到才到其他Processor读取其共享部分。这样子能够避免了多个Processor同时竞争一个池导致的性能问题。同时,共享部分也可以被充分利用,避免了资源浪费。

6.Sync.Pool最终实现

6.1 sync.Pool结构体定义

poolLocalpoolLocalpoolLocalpoolLocalInternalpoolLocalpad
poolLocalInternalprivatesharedprivatesharedsharedpoolChainpushHeadpopHead
PoollocalpoolLocallocalSizepoolLocal

6.2 Get方法

pin()poolLocalpid
poolLocalprivatesharedxxgetSlow()NewxxxNewNewxnil
GetpoolLocalgetSlowgetSlow
getSlowPoollocalSizepoolLocalpoolLocalpoolLocal

6.3 Put方法

pinpoolLocalpoolLocalprivateprivateprivatepoolLocalshared

6.4 总结

sync.Pool

7.总结

sync.Pool
sync.Poolsync.Poolsync.Pool
sync.Pool
sync.Poolsync.Pool