gopool
项目地址
gopkg/util/gopool at main · bytedance/gopkg
简介
gopool是一个高性能的goroutine池,旨在重用goroutine并限制goroutine的数量。
它是go关键字的替代品。
特性
- 高性能
- 自动恢复Panic
- 限制Goroutine数量
- 重用Goroutine栈
快速开始
只需将你的go func(){...}替换为gopool.Go(func(){...})。
旧方式:
go func() {
// 执行你的任务
}()
新方式:
gopool.Go(func(){
/// 执行你的任务
})
模型解读
这个库利用的分配模型有三个角色pool task worker
pool是一个goroutine池,它维护着一个task队列,worker队列的长度就是pool的容量。
task是一个任务,它是一个函数,它会被pool分配给worker执行。
worker是实际产生goroutine的对象,它会从pool的worker队列中取出n个task执行。
其中task和worker使用了sync.Pool来减少gc的压力,sync的pool是对象池,和协程池不一样,不要被名字混淆
Pool
pool的抽象类,需要实现的接口含义都比较简单
type Pool interface {
Name() string
// 设置容量
SetCap(cap int32)
// 执行go 替代go func()
Go(f func())
CtxGo(ctx context.Context, f func())
// 设置错误恢复函数,后面会讲到
SetPanicHandler(f func(context.Context, interface{}))
// 返回worker数量
WorkerCount() int32
}
Pool的具体实现
type pool struct {
name string
//池的容量 对应worker的最大数量
cap int32
// 后面会讲到,其中的属性最重要的是阈值,如果task的数量大于阈值,且worker的数量不超过pool的容量, 就会创建新的worker
config *Config
taskHead *task
taskTail *task
taskLock sync.Mutex
taskCount int32 // 四个属性维护一个task队列,按fifo的原则执行
// worker的数量
workerCount int32
// 内置了错误恢复函数
panicHandler func(context.Context, interface{})
}
其中有关task的四个属性维护了一个task队列,天然具有FIFO(先进先出)的属性,另外比较重要的是Config的结构
Config
type Config struct {
ScaleThreshold int32
}
// NewConfig creates a default Config.
func NewConfig() *Config {
c := &Config{
ScaleThreshold: defaultScalaThreshold,
}
return c
}
比较简单,就是设置了一个阈值,源码里面设置的默认值是1,这个阈值的含义是,在加入task的时候,如果task队列的数量超过了阈值并且现有的worker数量没有超过设置的容量cap,那么Pool就会新建一个worker来执行任务,也就是说如果没有超过阈值,就不会新建worker,而是快速返回并等待现有的worker执行任务,而默认设置的ScaleThreshold为1 ,这样的话就代表在worker数量没到达cap前,新来任务总是会新建worker,这样会有一些缺点,但需要讲到后面worker的结构才能揭晓。
执行
下面是Pool实际执行任务的函数
func (p *pool) Go(f func()) {
p.CtxGo(context.Background(), f)
}
func (p *pool) CtxGo(ctx context.Context, f func()) {
t := taskPool.Get().(*task)
t.ctx = ctx
t.f = f
p.taskLock.Lock()
if p.taskHead == nil {
p.taskHead = t
p.taskTail = t
} else {
p.taskTail.next = t
p.taskTail = t
}
p.taskLock.Unlock()
atomic.AddInt32(&p.taskCount, 1)
// The following two conditions are met:
// 1. the number of tasks is greater than the threshold.
// 2. The current number of workers is less than the upper limit p.cap.
// or there are currently no workers.
if (atomic.LoadInt32(&p.taskCount) >= p.config.ScaleThreshold && p.WorkerCount() < atomic.LoadInt32(&p.cap)) || p.WorkerCount() == 0 {
p.incWorkerCount()
w := workerPool.Get().(*worker)
w.pool = p
w.run()
}
}
基本流程就是,从task的sync.Pool中取出一个task对象并复制,检查task队列状态并插入新的task,在以下条件满足时新建worker
- task队列长度超过阈值 且 当前worker数量未超过cap
- worker数量为0
需要注意的是,源码中task和worker的初始化使用了sync.Pool,这种方式在对象重复初始化的时候可以减少gc的压力,相当于不再进行之前的 申请内存 ---->赋值 ----->销毁的流程,而是建立一个垃圾场,用完对象之后,洗洗放进去,下次直接取出来重新用,所以需要注意的是,重新放回sync.Pool前要将对象恢复到刚初始化的状态,以免影响后面的复用。
func (t *task) zero() {
t.ctx = nil
t.f = nil
t.next = nil
}
func (t *task) Recycle() {
// 加入sync.Pool前清除数据保证不会在重新取出时不可复用
t.zero()
taskPool.Put(t)
}
task
task的结构比较简单,内部为执行的实体,和维护链表的结构
type task struct {
ctx context.Context
f func()
next *task
}
func (t *task) zero() {
t.ctx = nil
t.f = nil
t.next = nil
}
func (t *task) Recycle() {
// 加入sync.Pool前清除数据保证不会在重新取出时不可复用
t.zero()
taskPool.Put(t)
}
func newTask() interface{} {
return &task{}
}
woker
type worker struct {
pool *pool
}
func newWorker() interface{} {
return &worker{}
}
func (w *worker) run() {
go func() {
for {
var t *task
w.pool.taskLock.Lock()
if w.pool.taskHead != nil {
t = w.pool.taskHead
w.pool.taskHead = w.pool.taskHead.next
atomic.AddInt32(&w.pool.taskCount, -1)
}
if t == nil {
// if there's no task to do, exit
w.close()
w.pool.taskLock.Unlock()
w.Recycle()
return
}
w.pool.taskLock.Unlock()
func() {
defer func() {
if r := recover(); r != nil {
if w.pool.panicHandler != nil {
w.pool.panicHandler(t.ctx, r)
} else {
msg := fmt.Sprintf("GOPOOL: panic in pool: %s: %v: %s", w.pool.name, r, debug.Stack())
logger.CtxErrorf(t.ctx, msg)
}
}
}()
t.f()
}()
t.Recycle()
}
}()
}
worker内的Pool指针是为了获取task队列,我们重心放在实际执行的逻辑上, 主要的逻辑是,从pool的task链表中取出链表头,如果不为nil(非空)的话,就执行
其中需要注意的是,task链表使用了锁,关于记录的一些参数比如taskCount,cap,workCount都使用了原子操作,这些都是为了维护并发安全,在实际执行外套了错误恢复函数防止panic传播
一些自己的考虑有
- 仅考虑判断task链表为空的操作作为worker自我了解的条件,配合阈值设置为1,实际上goroutine的创建和销毁还是很频繁,想象一下,已经有n个worker在吃饭,但是每次有新的食物来,总是会优先分配给新的worker,旧的worker很快就自我了结了,通过这种条件来判断本不算好,配合设置阈值为1就更加糟糕了,我将阈值改为10以后,基准测试有约2-3倍的性能提升。考虑一些优化手段,或许worker可以多次重试并且逐次增加重试间隔,来确认短时间内不需要这么多的worker,再进行销毁,或者做一个计时器,一段时间后未被使用再销毁。或者类似ants的设计, 将worker分新旧来达到自动的扩缩容,但由于本项目并没有实现pool和worker中有效的通讯,所以需要比较大的改动