Go 协程池 Gopool VS ants 原理解析

写过高并发的都知道,控制协程数量是问题的关键,如何高效利用协程,本文将介绍gopool和ants两个广泛应用的协程池,通过本文你可以了解到:

1. 实现原理
2. 使用方法
3. 区别

背景

虽然通过go func()即可轻量级实现并发,但如果通过for循环创建go

func(),会出现成千上万个协程(1个协程2KB,过多协程会达到GB级别)导致系统资源耗尽,为了保护系统资源,往往需要控制协程数量。

协程池的核心思想是限制并发执行的 goroutine 数量,从而有效管理资源使用和避免过度并发带来的问题。

使用原生Channel实现协程池

Channel 支持多个协程之间的数据通信,并且可以通过有限长度的channel当做协程池,最大长度即为协程池最大容量。

核心原理

关键点1:定义协程池结构和构造函数

首先,定义一个协程池的结构体,它包含一个 channel 用于控制并发数量,以及一个 WaitGroup 用于等待所有任务完成。

go 复制代码
package main

import (
    "sync"
    "fmt"
    "time"
)

type GoroutinePool struct {
    maxGoroutines int
    pool          chan struct{}
    wg            sync.WaitGroup
}

func NewGoroutinePool(maxGoroutines int) *GoroutinePool {
    return &GoroutinePool{
        maxGoroutines: maxGoroutines,
        pool:          make(chan struct{}, maxGoroutines),
    }
}

关键点2:实现任务提交和执行的方法

为协程池添加方法以提交和执行任务。使用 channel 来控制同时运行的 goroutine 数量。

go 复制代码
func (p *GoroutinePool) Submit(task func()) {
    p.wg.Add(1)
    p.pool <- struct{}{} // 获取令牌,如果池已满,这里会阻塞

    go func() {
        defer p.wg.Done()
        defer func() { <-p.pool }() // 释放令牌

        task() // 执行任务
    }()
}

func (p *GoroutinePool) Wait() {
    p.wg.Wait()
}

举个🌰

go 复制代码
func main() {
    pool := NewGoroutinePool(5) // 创建一个大小为5的协程池

    for i := 0; i < 10; i++ {
        count := i
        pool.Submit(func() {
            fmt.Printf("Running task %d, timeNow: %v\n", count, time.Now())
            time.Sleep(1 * time.Second) // 模拟耗时任务
        })
    }

    pool.Wait() // 等待所有任务完成
    fmt.Println("All tasks completed.")
}
go 复制代码
// 输出
Running task 1, timeNow: 2024-08-14 15:46:19.937473 +0800 CST m=+0.000572251
Running task 2, timeNow: 2024-08-14 15:46:19.937135 +0800 CST m=+0.000234542
Running task 3, timeNow: 2024-08-14 15:46:19.937344 +0800 CST m=+0.000443501
Running task 0, timeNow: 2024-08-14 15:46:19.937135 +0800 CST m=+0.000234167
Running task 4, timeNow: 2024-08-14 15:46:19.937104 +0800 CST m=+0.000203709
Running task 9, timeNow: 2024-08-14 15:46:20.938929 +0800 CST m=+1.002042292
Running task 6, timeNow: 2024-08-14 15:46:20.938935 +0800 CST m=+1.002048334
Running task 8, timeNow: 2024-08-14 15:46:20.938965 +0800 CST m=+1.002078501
Running task 7, timeNow: 2024-08-14 15:46:20.938963 +0800 CST m=+1.002076542
Running task 5, timeNow: 2024-08-14 15:46:20.938948 +0800 CST m=+1.002061167
All tasks completed.

可以看到,因为协程池大小为5,所以前5个任务都在0s执行,后5个在1s执行。

说实话,原生的Channel实现,个人感觉已经能够完全覆盖日常使用,但是公司和业界还是推出了很多协程池的实现,存在即合理,我们来学习一下~

使用gopkg/gopool实现协程池

这个包在字节内部已经广泛应用,咨询作者后,才发现这个包已经开源啦

https://github.com/bytedance/gopkg/tree/main/util/gopool

核心原理

这三个"池"纯属个人理解,抽象了两个池的概念
三个"池"的关系

实现流程

黄色 为新任务到来业务处理流程

协程池结构体

给用户侧暴露的协程池结构体为pool,存储了名称,最大容量,任务链表,正在进行的任务数量。通过NewPool来进行创建。通过pool.Go()方法进行添加事件处理

go 复制代码
type pool struct {
    // pool 的名字,打 metrics 和打 log 时用到
    name string
    // pool 的容量,也就是最大的真正在工作的 goroutine 的数量
    // 为了性能考虑,可能会有小误差
    cap int32
    // 配置信息
    config *Config
    // 任务链表
    taskHead  *task
    taskTail  *task
    taskLock  sync.Mutex
    taskCount int32

    // 记录正在运行的 worker 数量
    workerCount int32

    // 用来标记是否关闭
    closed int32

    // worker panic 的时候会调用这个方法
    panicHandler func(context.Context, interface{})
}

// name 必须是不含有空格的,只能含有字母、数字和下划线,否则 metrics 会失败
func NewPool(name string, cap int32, config *Config) Pool {
    p := &pool{
       name:   name,
       cap:    cap,
       config: config,
    }
    return p
}

// 添加事件处理
func (p *pool) Go(f func()) {
    p.CtxGo(context.Background(), f)  // 在下文分析
}

关键点1:任务队列池

taskPool,存储对象为*task

用于存放待执行任务的队列,如果当前任务数<=最大容量,直接新建工作协程执行;如果当前任务数量>最大容量,新的任务会被链表链接到上一个任务尾部,不新建工作协程。这样所有的任务通过链表链接在一起,形成了任务队列池。

go 复制代码
var taskPool sync.Pool

func init() {
    taskPool.New = newTask
}

type task struct {
    ctx context.Context
    f   func()
    next *task
}

func newTask() interface{} {
    return &task{}
}

// 核心代码-将新任务放到任务队列池并执行
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)
    // 如果 pool 已经被关闭了,就 panic
    if atomic.LoadInt32(&p.closed) == 1 {
       panic("use closed pool")
    }
    // 判断条件满足以下两个条件:
    // 1. task 数量大于阈值
    // 2. 目前的 worker 数量小于上限 p.cap
    // 或者目前没有 worker
    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()  // 下文------工作协程池的处理
    }
}

关键点2:工作协程池

workerPool,存储对象为*worker

存储当前执行的协程,针对一个工作协程,判断当前节点的任务并执行,并且通过for循环一直寻找下一个待执行的任务,如果没有下一个节点,则跳出循环。

go 复制代码
var workerPool sync.Pool

func init() {
    workerPool.New = newWorker
}

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 {
             w.close()
             w.pool.taskLock.Unlock()
             // 工作协程对象回收
             w.Recycle()
             return
          }
          w.pool.taskLock.Unlock()
          func() {
             defer func() {
                if r := recover(); r != nil {
                   logs.CtxFatal(t.ctx, "GOPOOL: panic in pool: %s: %v: %s", w.pool.name, r, debug.Stack())
                   if w.pool.config.EnablePanicMetrics {
                      panicMetricsClient.EmitCounter(panicKey, 1, metrics.T{Name: "pool", Value: w.pool.name})
                   }
                   w.pool.panicHandler(t.ctx, r)
                }
             }()
             t.f()
          }()
          // 任务对象回收
          t.Recycle()
       }
    }()
}

核心原理

  1. 复用Go协程:每一个协程作为一个工作协程,执行完当前任务后,会进入for循环寻找下一个执行
  2. 减少内存分配:使用sync.Pool包来减少内存分配的频率,通过重用已经存在但不再使用的对象(即工作协程)来达到这一目的。同时这个包帮你完成了对象创建、回收操作;
  3. 显示记录容量:通过int32类型记录最大容量,记录当前执行数量,为了保证并发安全,通过atomic.AddInt32、atomic.LoadInt32等方法进行加减操作;
  4. 控制执行顺序:通过链表指定任务链,实现(非严格意义)FIFO,同时采用sync.Mutex来保证链表流转过程中的并发安全;之前的channel是通过阻塞的形式,实现(非严格意义)FIFO;

缺点:说实话,我内心是希望它能够把sync.WaitGroup包做一个集成,让它能够控制各个协程结束的时间,当然这个也不是协程池原生定义里的内容。

举个🌰

go 复制代码
func main() {
    // 写法1
    _ = gopool.RegisterPool(gopool.NewPool("test", 5, gopool.NewConfig()))
    p := gopool.GetPool("test")
    
    // 写法2
    // p := gopool.NewPool("test", 5, gopool.NewConfig())

    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        count := i
        wg.Add(1)
        p.Go(func() {
           fmt.Printf("Running task %d, timeNow: %v\n", count, time.Now())
           time.Sleep(1 * time.Second) // 模拟耗时任务
           wg.Done()
        })
    }
    wg.Wait()
    fmt.Println("All tasks completed.")
}
go 复制代码
// 输出
Running task 1, timeNow: 2024-08-15 10:32:16.729052 +0800 CST m=+0.023291751
Running task 3, timeNow: 2024-08-15 10:32:16.72925 +0800 CST m=+0.023490418
Running task 4, timeNow: 2024-08-15 10:32:16.729255 +0800 CST m=+0.023495168
Running task 0, timeNow: 2024-08-15 10:32:16.72905 +0800 CST m=+0.023289626
Running task 2, timeNow: 2024-08-15 10:32:16.729062 +0800 CST m=+0.023302334
Running task 6, timeNow: 2024-08-15 10:32:17.729546 +0800 CST m=+1.023782501
Running task 8, timeNow: 2024-08-15 10:32:17.729611 +0800 CST m=+1.023847543
Running task 5, timeNow: 2024-08-15 10:32:17.729532 +0800 CST m=+1.023768209
Running task 9, timeNow: 2024-08-15 10:32:17.729643 +0800 CST m=+1.023879126
Running task 7, timeNow: 2024-08-15 10:32:17.729586 +0800 CST m=+1.023822626
All tasks completed.

可以看到,因为协程池大小为5,所以前5个任务都在0s执行,后5个在1s执行。

使用panjf2000/ants实现协程池

https://github.com/panjf2000/ants Github 12.6k stars MIT license

核心原理

不得不说这个开源Readme写的还是比较好的,可以抄几张图贴在这里🐶

另外,作者还有一篇专门的文档来介绍 GMP 并发调度器深度解析之手撸一个高性能 goroutine pool|Strike Freedom , 不过这篇文档介绍的是第一个版本,目前已经更新到v2.10了,本文也主要介绍这个版本

流程图

活动图

step1: 容量为4的协程池,接收了6个任务

step2: 有4个立马执行,另外两个等待

step3: 剩余的两个执行

step4: 执行结束,worker都空闲下来

协程池结构体

给用户侧暴露的协程池结构体为Pool,存储了最大容量,当前工作协程的数量,定时器等。通过NewPool来进行创建。通过pool.Submit()方法进行添加事件处理。

go 复制代码
type Pool struct {
    poolCommon
}

type poolCommon struct {
    // 协程池容量
    capacity int32

    // 当前正在执行的go routinue数量
    running int32

    // 作者自己实现的自旋锁 (用atomic.StoreUint32, atomic.CompareAndSwapUint32 实现)
    lock sync.Locker

    // 核心 - 工作协程池
    workers workerQueue

    // 记录协程池当前的状态是否已关闭
    state int32

    // 条件变量,处理任务等待和唤醒
    cond *sync.Cond

    // 所有都执行完成标志位
    allDone chan struct{}
    // 保证关闭协程池仅一次
    once *sync.Once

    // 通过 sync.Pool 的模式加快对worker对象的获取
    workerCache sync.Pool

    // 等待执行的数量
    waiting int32

    // 清除过期 worker 的终结标记
    purgeDone int32
    purgeCtx  context.Context
    stopPurge context.CancelFunc

    // 定时更新协程池当前时间的终结标记
    ticktockDone int32
    ticktockCtx  context.Context
    stopTicktock context.CancelFunc

    now atomic.Value

    options *Options
}


// NewPool 初始化
func NewPool(size int, options ...Option) (*Pool, error) {
    if size <= 0 {
       size = -1
    }

    opts := loadOptions(options...)

    if !opts.DisablePurge {
       if expiry := opts.ExpiryDuration; expiry < 0 {
          return nil, ErrInvalidPoolExpiry
       } else if expiry == 0 {
          opts.ExpiryDuration = DefaultCleanIntervalTime
       }
    }

    if opts.Logger == nil {
       opts.Logger = defaultLogger
    }

    p := &Pool{poolCommon: poolCommon{
       capacity: int32(size),
       allDone:  make(chan struct{}),
       lock:     syncx.NewSpinLock(),
       once:     &sync.Once{},
       options:  opts,
    }}
    p.workerCache.New = func() interface{} {
       return &goWorker{
          pool: p,
          task: make(chan func(), workerChanCap),
       }
    }
    // 这里提供了两种工作协程的组织模式,
    // queueTypeLoopQueue为循环队列模式;queueTypeStack为列表(栈)模式
    // 如果需要预先分配资源,则采用循环队列模式,否则采用列表(栈)模式
    if p.options.PreAlloc {
       if size == -1 {
          return nil, ErrInvalidPreAllocSize
       }
       p.workers = newWorkerQueue(queueTypeLoopQueue, size)
    } else {
       p.workers = newWorkerQueue(queueTypeStack, 0)
    }

    p.cond = sync.NewCond(p.lock)

    p.goPurge()    // 回收工作协程
    p.goTicktock() // 开启定时任务,记录当前的时间,根据时间判断过期对应的工作协程  (这个名字有点像 TikTok 🐶)

    return p, nil
}

// 添加事件处理 
func (p *Pool) Submit(task func()) error {
    if p.IsClosed() {
       return ErrPoolClosed
    }

    w, err := p.retrieveWorker()  // 核心代码 - 如何获取一个空闲的工作协程
    if w != nil {
       w.inputFunc(task)
    }
    return err
}

关键点1:任务队列"池"

上述流程图中核心的实现逻辑 这里的"池"带引号,因为实际上并没有对任务队列组织起来放到一起,仅仅是流程性的逻辑

这里接收到的任务,如果可以执行,则立即返回,如果不能够执行,则阻塞住,等待通知有空闲的工作协程。其实这里并没有任务队列池的概念,反而是即用即处理,不满足条件阻塞的方式。

go 复制代码
func (p *Pool) retrieveWorker() (w worker, err error) {
    p.lock.Lock()

retry:
    // 尝试获取一个空闲的工作协程
    if w = p.workers.detach(); w != nil {
       p.lock.Unlock()
       return
    }

    // 如果没达到上限,直接取一个工作协程做执行,这里也是用 sync.Pool 包来优化对象的获取
    if capacity := p.Cap(); capacity == -1 || capacity > p.Running() {
       p.lock.Unlock()
       w = p.workerCache.Get().(*goWorker)
       w.run()
       return
    }

    // 如果处于非阻塞模式 或 待处理的调用者数量达到最大限制值,退出  (这是一个optional的配置项,核心流程可以不关注这个)
    if p.options.Nonblocking || (p.options.MaxBlockingTasks != 0 && p.Waiting() >= p.options.MaxBlockingTasks) {
       p.lock.Unlock()
       return nil, ErrPoolOverload
    }

    // 阻塞等待工作协程
    p.addWaiting(1)
    p.cond.Wait() // 通过 sync.Cond 阻塞住,如果通过则表示已经有工作协程空闲
    p.addWaiting(-1)

    if p.IsClosed() {
       p.lock.Unlock()
       return nil, ErrPoolClosed
    }

    // 🐶 我咋感觉这块用for循环也行呢?哈哈,goto 这个用法感觉很罕见
    goto retry
}

关键点2:工作协程池

工作协程池为 workerQueue 结构体,里面的具体工作协程的元素是 worker。

go 复制代码
type worker interface {
    run()                     
    finish()  
    lastUsedTime() time.Time
    inputFunc(func())
    inputParam(interface{})
}

type workerQueue interface {
    len() int             // worker 数量
    isEmpty() bool        // worker 数量是否为0
    insert(worker) error  // 添加工作协程
    detach() worker       // 获取一个工作协程
    refresh(duration time.Duration) []worker // 清除过期 worker 并返回它们
    reset()               // 重置工作协程池
}

可以看到这两种都是interface形式,在官方包中给了两种实现的模式。

  1. workerQueue 工作协程池 - 两个实现
workerStack 列表形式存储工作协程,不需要预先分配空间 【默认】
loopQueue 循环链表形式存储工作协程,预先分配空间,eg:有一个场景需要一个超大容量的池,而且每个 goroutine 里面的任务都是耗时任务,这种情况下,预先分配 goroutine 队列内存将会减少不必要的内存重新分配
  1. worker 工作协程 - 两个实现
goWorker 工作协程 【默认】
goWorkerWithFunc 带方法的工作协程,eg:如果每次调用的方法都相同,则会默认这种模式,避免每次方法的绑定消耗资源

不同的工作协程池实现,会有完全不同的流转逻辑,下面详细介绍一下。

workerStack-列表/栈

列表形式的存储,但是弹出元素detach和追加元素insert,都是从尾部进行,满足后进先出(LIFO)的特点。

go 复制代码
type workerStack struct {
    items  []worker  // 列表形式存储
    expiry []worker  // 存储过期的工作协程
}

func newWorkerStack(size int) *workerStack {
    return &workerStack{
       items: make([]worker, 0, size),
    }
}

func (wq *workerStack) len() int {
    return len(wq.items)
}

func (wq *workerStack) isEmpty() bool {
    return len(wq.items) == 0
}

func (wq *workerStack) insert(w worker) error {
    wq.items = append(wq.items, w)  // 尾部添加元素
    return nil
}

func (wq *workerStack) detach() worker {
    l := wq.len()
    if l == 0 {
       return nil
    }

    w := wq.items[l-1]  // 尾部获取元素
    wq.items[l-1] = nil // 避免内存泄漏
    wq.items = wq.items[:l-1]

    return w
}

func (wq *workerStack) refresh(duration time.Duration) []worker {
    n := wq.len()
    if n == 0 {
       return nil
    }

    expiryTime := time.Now().Add(-duration)
    index := wq.binarySearch(0, n-1, expiryTime)

    wq.expiry = wq.expiry[:0]
    if index != -1 {
       wq.expiry = append(wq.expiry, wq.items[:index+1]...)
       m := copy(wq.items, wq.items[index+1:])
       for i := m; i < n; i++ {
          wq.items[i] = nil
       }
       wq.items = wq.items[:m]
    }
    return wq.expiry
}

// 二分法搜索,用于获取临界过期的工作协程
func (wq *workerStack) binarySearch(l, r int, expiryTime time.Time) int {
    for l <= r {
       mid := l + ((r - l) >> 1) // avoid overflow when computing mid
       if expiryTime.Before(wq.items[mid].lastUsedTime()) {
          r = mid - 1
       } else {
          l = mid + 1
       }
    }
    return r
}

func (wq *workerStack) reset() {
    for i := 0; i < wq.len(); i++ {
       wq.items[i].finish()
       wq.items[i] = nil
    }
    wq.items = wq.items[:0]
}
loopQueue-循环队列

loopQueue 实现基于循环队列,定义如下。循环队列包含一个长度为 size 的切片,head 表示队列头指针,tail 表示队列尾指针,也就是最后一个元素的下一位,isFull 表示队列是否已满,当 head 和 tail 指向同一位置时用于区分队列是空还是满。

go 复制代码
type loopQueue struct {
    items  []worker    // 用于初始分配worker
    expiry []worker    // 过期worker
    head   int         // 头结点
    tail   int         // 尾结点
    size   int         // 总大小
    isFull bool        // 是否队列已满
}

func newWorkerLoopQueue(size int) *loopQueue {
    return &loopQueue{
       items: make([]worker, size),
       size:  size,
    }
}

func (wq *loopQueue) len() int {
    if wq.size == 0 || wq.isEmpty() {
       return 0
    }

    if wq.head == wq.tail && wq.isFull {
       return wq.size
    }

    if wq.tail > wq.head {
       return wq.tail - wq.head
    }

    return wq.size - wq.head + wq.tail
}

func (wq *loopQueue) isEmpty() bool {
    return wq.head == wq.tail && !wq.isFull
}

func (wq *loopQueue) insert(w worker) error {
    if wq.size == 0 {
       return errQueueIsReleased
    }

    if wq.isFull {
       return errQueueIsFull
    }
    wq.items[wq.tail] = w
    wq.tail = (wq.tail + 1) % wq.size   // tail结点向后遍历,如果超过长度,则取余从头开始

    if wq.tail == wq.head {
       wq.isFull = true
    }

    return nil
}

func (wq *loopQueue) detach() worker {
    if wq.isEmpty() {
       return nil
    }

    w := wq.items[wq.head]             // 从head结点取出worker,先进先出
    wq.items[wq.head] = nil
    wq.head = (wq.head + 1) % wq.size

    wq.isFull = false

    return w
}

func (wq *loopQueue) refresh(duration time.Duration) []worker {
    expiryTime := time.Now().Add(-duration)
    index := wq.binarySearch(expiryTime)
    if index == -1 {
       return nil
    }
    wq.expiry = wq.expiry[:0]

    if wq.head <= index {
       wq.expiry = append(wq.expiry, wq.items[wq.head:index+1]...)
       for i := wq.head; i < index+1; i++ {
          wq.items[i] = nil
       }
    } else {
       wq.expiry = append(wq.expiry, wq.items[0:index+1]...)
       wq.expiry = append(wq.expiry, wq.items[wq.head:]...)
       for i := 0; i < index+1; i++ {
          wq.items[i] = nil
       }
       for i := wq.head; i < wq.size; i++ {
          wq.items[i] = nil
       }
    }
    head := (index + 1) % wq.size
    wq.head = head
    if len(wq.expiry) > 0 {
       wq.isFull = false
    }

    return wq.expiry
}

func (wq *loopQueue) binarySearch(expiryTime time.Time) int {
    var mid, nlen, basel, tmid int
    nlen = len(wq.items)

    // if no need to remove work, return -1
    if wq.isEmpty() || expiryTime.Before(wq.items[wq.head].lastUsedTime()) {
       return -1
    }

    // example
    // size = 8, head = 7, tail = 4
    // [ 2, 3, 4, 5, nil, nil, nil,  1]  true position
    //   0  1  2  3    4   5     6   7
    //              tail          head
    //
    //   1  2  3  4  nil nil   nil   0   mapped position
    //            r                  l

    // base algorithm is a copy from worker_stack
    // map head and tail to effective left and right
    r := (wq.tail - 1 - wq.head + nlen) % nlen
    basel = wq.head
    l := 0
    for l <= r {
       mid = l + ((r - l) >> 1) // avoid overflow when computing mid
       // calculate true mid position from mapped mid position
       tmid = (mid + basel + nlen) % nlen
       if expiryTime.Before(wq.items[tmid].lastUsedTime()) {
          r = mid - 1
       } else {
          l = mid + 1
       }
    }
    // return true position from mapped position
    return (r + basel + nlen) % nlen
}

func (wq *loopQueue) reset() {
    if wq.isEmpty() {
       return
    }

retry:
    if w := wq.detach(); w != nil {
       w.finish()
       goto retry
    }
    wq.items = wq.items[:0]
    wq.size = 0
    wq.head = 0
    wq.tail = 0
}
goWorker-工作协程

在工作协程池中的存储的具体执行者是goWorker。

go 复制代码
type goWorker struct {
    // 所属协程池的引用,绑定的是同一个pool
    pool *Pool

    // 任务通道,通过这个通道将类型为func()的函数作为任务发送给goWorker
    task chan func()

    // 记录goWorker被放回协程池的时间
    lastUsed time.Time
}

// run 方法启动一个协程,用来执行指定的函数。
// 从task通道中不断地接收任务,获取函数变量后直接执行,然后将goWorker对象放回协程池。
// 这个 for 循环将一直从task通道接收任务,直至通道关闭或取出nil任务才会终止,期间协程将一直保持运行。
// 也就是说,每个 goWorker 只会启动一次协程,后续可以重复利用这个协程,这就是 ants 高性能的关键所在。
func (w *goWorker) run() {
    w.pool.addRunning(1)
    go func() {
       defer func() {
          if w.pool.addRunning(-1) == 0 && w.pool.IsClosed() {
             w.pool.once.Do(func() {
                close(w.pool.allDone)
             })
          }
          w.pool.workerCache.Put(w)
          if p := recover(); p != nil {
             if ph := w.pool.options.PanicHandler; ph != nil {
                ph(p)
             } else {
                w.pool.options.Logger.Printf("worker exits from panic: %v\n%s\n", p, debug.Stack())
             }
          }
          // 通知有空闲的工作协程
          w.pool.cond.Signal()
       }()

       for f := range w.task {
          if f == nil {
             return
          }
          f()                                       // 用户的程序
          if ok := w.pool.revertWorker(w); !ok {    // 将本协程释放,插入工作协程池中
             return
          }
       }
    }()
}

// 将本协程释放,插入工作协程池中
func (p *Pool) revertWorker(worker *goWorker) bool {
    if capacity := p.Cap(); (capacity > 0 && p.Running() > capacity) || p.IsClosed() {
       p.cond.Broadcast()
       return false
    }

    worker.lastUsed = p.nowTime()

    p.lock.Lock()
    // To avoid memory leaks, add a double check in the lock scope.
    if p.IsClosed() {
       p.lock.Unlock()
       return false
    }
    // 将worker插入到工作协程池
    if err := p.workers.insert(worker); err != nil {
       p.lock.Unlock()
       return false
    }
    // Notify the invoker stuck in 'retrieveWorker()' of there is an available worker in the worker queue.
    p.cond.Signal()
    p.lock.Unlock()

    return true
}

func (w *goWorker) finish() {
    w.task <- nil
}

func (w *goWorker) lastUsedTime() time.Time {
    return w.lastUsed
}

func (w *goWorker) inputFunc(fn func()) {
    w.task <- fn
}

func (w *goWorker) inputParam(interface{}) {
    panic("unreachable")
}

核心原理

  1. 复用Go协程:每一个协程作为一个工作协程,执行完当前任务后,会标记为已结束,进入空闲状态,通过工作协程池的堆栈复用/循环队列复用,寻找下一个执行,这个应该是核心提高性能的地方。
  2. 减少内存分配:构造了两种使用sync.Pool包来减少内存分配的频率,通过重用已经存在但不再使用的对象(即工作协程)来达到这一目的。
  3. 协程池组织模式:提供了两种:列表/堆栈,循环队列,用来控制是否需要进行提前的协程池分配,以及存取协程过程中分配方式:(非严格意义)LIFO,(非严格意义)FIFO
  4. 空闲协程回收:ants使用时钟精确控制哪些工作协程需要进行垃圾回收,这一部分在本文没有详细介绍

举个🌰

go 复制代码
func main() {
    pool, _ := ants.NewPool(5)

    var wg sync.WaitGroup
    
    for i := 0; i < 10; i++ {
        count := i
        wg.Add(1)
        _ = pool.Submit(func() {
           fmt.Printf("Running task %d, timeNow: %v\n", count, time.Now())
           time.Sleep(1 * time.Second) // 模拟耗时任务
           wg.Done()
        })
    }
    wg.Wait()
    fmt.Println("All tasks completed.")
}

// 输出

go 复制代码
Running task 1, timeNow: 2024-08-15 10:32:16.729052 +0800 CST m=+0.023291751
Running task 3, timeNow: 2024-08-15 10:32:16.72925 +0800 CST m=+0.023490418
Running task 4, timeNow: 2024-08-15 10:32:16.729255 +0800 CST m=+0.023495168
Running task 0, timeNow: 2024-08-15 10:32:16.72905 +0800 CST m=+0.023289626
Running task 2, timeNow: 2024-08-15 10:32:16.729062 +0800 CST m=+0.023302334
Running task 6, timeNow: 2024-08-15 10:32:17.729546 +0800 CST m=+1.023782501
Running task 8, timeNow: 2024-08-15 10:32:17.729611 +0800 CST m=+1.023847543
Running task 5, timeNow: 2024-08-15 10:32:17.729532 +0800 CST m=+1.023768209
Running task 9, timeNow: 2024-08-15 10:32:17.729643 +0800 CST m=+1.023879126
Running task 7, timeNow: 2024-08-15 10:32:17.729586 +0800 CST m=+1.023822626
All tasks completed.

可以看到,因为协程池大小为5,所以前5个任务都在0s执行,后5个在1s执行。

对比一下

共同点

  1. 通过sync.Pool (可以理解成 GMP 模型中的P)注册一个工作协程,通过复用工作协程,达到减少内存分配,减轻垃圾回收的压力。
  2. 都拥有类似的任务队列池、工作协程池的概念,实现对每个任务由哪个工作协程执行的工作。
  3. 都配置了垃圾回收的方式。
  4. 调用方式相似&&简单。

不同点

原生Channel实现 gopkg/gopool panjf2000/ants
运行效率
复用方式 通过工作协程自己for循环寻找未执行任务 通过堆栈/循环队列管控空闲工作协程,控制工作协程如何分配给任务
错误处理 专门的可配置的方法 专门的可配置的方法
集成metrics 集成了公司自研的metrics
灵活度 可支持扩容的阈值 有大量optional参数,可以配置是否阻塞、是否预分配、回收周期、支持动态扩容协程池大小
相关推荐
白宇横流学长26 分钟前
基于Java的银行排号系统的设计与实现【源码+文档+部署讲解】
java·开发语言·数据库
勉灬之33 分钟前
封装上传组件,提供各种校验、显示预览、排序等功能
开发语言·前端·javascript
西猫雷婶3 小时前
python学opencv|读取图像(二十三)使用cv2.putText()绘制文字
开发语言·python·opencv
我要学编程(ಥ_ಥ)4 小时前
速通前端篇——JavaScript
开发语言·前端·javascript
HEU_firejef4 小时前
设计模式——工厂模式
java·开发语言·设计模式
云计算DevOps-韩老师4 小时前
【网络云SRE运维开发】2024第52周-每日【2024/12/31】小测-计算机网络参考模型和通信协议的理论和实操考题
开发语言·网络·计算机网络·云计算·运维开发
fajianchen5 小时前
应用架构模式
java·开发语言
Code成立5 小时前
《Java核心技术 卷II》流的创建
java·开发语言·流编程
Amo 67296 小时前
axios 实现进度监控
开发语言·前端·javascript
魂兮-龙游7 小时前
C语言中的printf、sprintf、snprintf、vsnprintf 函数
c语言·开发语言·算法