解析字节跳动开源的微型协程池gopool

188 阅读5分钟

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

  1. task队列长度超过阈值 且 当前worker数量未超过cap
  2. 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传播

一些自己的考虑有

  1. 仅考虑判断task链表为空的操作作为worker自我了解的条件,配合阈值设置为1,实际上goroutine的创建和销毁还是很频繁,想象一下,已经有n个worker在吃饭,但是每次有新的食物来,总是会优先分配给新的worker,旧的worker很快就自我了结了,通过这种条件来判断本不算好,配合设置阈值为1就更加糟糕了,我将阈值改为10以后,基准测试有约2-3倍的性能提升。考虑一些优化手段,或许worker可以多次重试并且逐次增加重试间隔,来确认短时间内不需要这么多的worker,再进行销毁,或者做一个计时器,一段时间后未被使用再销毁。或者类似ants的设计, 将worker分新旧来达到自动的扩缩容,但由于本项目并没有实现pool和worker中有效的通讯,所以需要比较大的改动