golang协程池库ants的分析和拆解

ants 协程池

https://github.com/panjf2000/ants.git

在 Go 中,goroutine 是一种轻量级线程,非常易于创建。但是在高并发场景下,如果无限制地创建 goroutine,可能导致:

  • 内存飙升
  • GC 压力增大
  • 系统调度负载变重
  • 宕机(out-of-memory)

因此,我们需要对 goroutine 数量进行限制,即协程池(Goroutine Pool)。

整体架构思路

ants的核心思想是:

  1. 任务复用:不重复创建 goroutine,而是复用已经创建的 worker。
  2. worker 缓存管理:通过队列(如栈结构)管理空闲 worker。
  3. 自动回收机制:当 worker 超过设定的空闲时间,会被回收以释放资源。
  4. 灵活策略选择:支持不同类型的 worker 队列(如 stack、loopQueue)。

ants提供了一些选项可以定制 goroutine 池的行为。选项使用Options结构定义:

type Options struct {
  ExpiryDuration time.Duration
  PreAlloc bool
  MaxBlockingTasks int
  Nonblocking bool
  PanicHandler func(interface{})
  Logger Logger
}

各个选项含义如下:

  • ExpiryDuration:过期时间。表示 goroutine 空闲多长时间之后会被ants池回收
  • PreAlloc:预分配。调用NewPool()/NewPoolWithFunc()之后预分配worker(管理一个工作 goroutine 的结构体)切片。而且使用预分配与否会直接影响池中管理worker的结构。见下面源码
  • MaxBlockingTasks:最大阻塞任务数量。即池中 goroutine 数量已到池容量,且所有 goroutine 都处理繁忙状态,这时到来的任务会在阻塞列表等待。这个选项设置的是列表的最大长度。阻塞的任务数量达到这个值后,后续任务提交直接返回失败
  • Nonblocking:池是否阻塞,默认阻塞。提交任务时,如果ants池中 goroutine 已到上限且全部繁忙,阻塞的池会将任务添加的阻塞列表等待(当然受限于阻塞列表长度,见上一个选项)。非阻塞的池直接返回失败
  • PanicHandler:panic 处理。遇到 panic 会调用这里设置的处理函数
  • Logger:指定日志记录器

生命周期流程

  1. 用户调用 Submit(func)
    • 从 pool 的 workerQueue 中 detach() 一个可用的 worker。
    • 如果没有可用 worker,则新建一个(前提:没达到上限)。
  2. 把任务函数塞给 worker,worker 执行 run()
  3. worker 任务执行完后:
    • 更新 lastUsedTime;
    • 再次 insert() 回队列,等待复用。
  4. 每隔一段时间(如定时器):
    • 执行一次 refresh(duration),清理过期的 worker。
  5. 用户主动 Release() 协程池时:
    • 调用 reset(),释放所有 worker 资源。

worker

worker 这个接口定义了一个 worker(本质上就是一个可以执行任务的 goroutine)所应实现的操作:

type worker interface {
	run()                        // run():worker 执行任务。
	finish()                     // finish():worker 被关闭/回收时调用。
	lastUsedTime() time.Time     // lastUsedTime() / setLastUsedTime(t):记录/更新最后一次被使用的时间,用于判断是否过期。
	setLastUsedTime(t time.Time) 
	inputFunc(func())            // inputFunc() / inputArg():为该 worker 设置待执行的函数及其参数。
	inputArg(any)                
}

workerQueue 是一个“空闲 worker 容器”,实现方式包括:栈(LIFO)、环形队列(FIFO)等,支持根据是否设置了 PreAlloc切换。

如果设置了 PreAlloc,则使用循环队列(loopQueue)的方式存取这个 workers。在初始化 pool 的时候,就初始化了 capacity 长度的循环队列,取的时候从队头取,插入的时候往队列尾部插入,整体 queue 保持在 capacity 长度。

如果没有设置 PreAlloc,则使用堆栈(stack)的方式存取这个 workers。初始化的时候不初始化任何的 worker,在第一次取的时候在 stack 中取不到,则会从 sync.Pool 中初始化并取到对象,然后使用完成后插入到 当前这个栈中。下次取就直接从 stack 中再获取了。

type workerQueue interface {
	len() int
	isEmpty() bool
	insert(worker) error                     // 插入 
	detach() worker                          // 弹出 
	refresh(duration time.Duration) []worker // 回收过期 worker
	reset()                                  // 清空并释放 
}
栈实现(动态管理)
// workerStack 结构体用于管理协程池(Goroutine Pool)中的工作单元(worker)
type workerStack struct {
	items  []worker // 存储当前活跃且可复用的 worker(按最后一次使用时间排序,栈顶是最新使用的 worker)
	expiry []worker // 临时存储已过期的 worker(在清理操作中暂存待回收的 worker)
}
  • insert(w worker) 插入一个 worker 到栈顶。
func (ws *workerStack) insert(w worker) error {
	ws.items = append(ws.items, w)
	return nil
}
  • detach() 弹出最近使用的一个 worker(栈顶元素)。
func (ws *workerStack) detach() worker {
	l := ws.len()
	if l == 0 {
		return nil
	}
	w := ws.items[l-1]
	ws.items[l-1] = nil // avoid memory leaks
	ws.items = ws.items[:l-1]
	return w
}
  • refresh(duration) 回收一定时间未使用的 worker:

items 按时间进行二分查找;

将过期的 worker 移动到 expiry

剩余的 worker 前移,确保 items 紧凑,避免内存浪费。

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

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

	ws.expiry = ws.expiry[:0]
	if index != -1 {
		ws.expiry = append(ws.expiry, ws.items[:index+1]...)
		m := copy(ws.items, ws.items[index+1:])
		for i := m; i < n; i++ {
			ws.items[i] = nil
		}
		ws.items = ws.items[:m]
	}
	return ws.expiry
}
  • reset() 用于彻底销毁协程池(例如:关闭时),调用每个 worker 的 finish() 并清空内存。
func (ws *workerStack) reset() {
	for i := 0; i < ws.len(); i++ {
		ws.items[i].finish()
		ws.items[i] = nil
	}
	ws.items = ws.items[:0]
}
队列实现(预分配大小)
type loopQueue struct {
	items  []worker // 存放 worker 的循环数组
	expiry []worker // 存放超时过期的 worker
	head   int      // 队头索引
	tail   int      // 队尾索引
	size   int      // 队列大小
	isFull bool     // 是否满了
}

loopQueue 是一个固定容量的循环队列,用于:

  • 快速插入和移除 worker
  • 支持高效的过期清理(通过二分查找)
  • 保证并发调度性能

时间管理

自实现的计时器

func (p *poolCommon) goTicktock()

为什么需要自实现定时?

time.Now() 开销不小。虽然在一般场景中我们会频繁调用 time.Now(),但它在 Go 的底层实现是涉及到 系统调用(syscall) 的,尤其是在高并发下,频繁调用 time.Now() 会带来性能瓶颈。

time.Now() 在 Linux 上需要调用 gettimeofday,即使用 VDSO 优化,也依然不算“便宜”。

ants 中,p.now 是一个 atomic.Value (提供并发安全的存取机制),由一个专门的协程通过 ticktock() 周期性(500ms)地将当前时间 time.Now() 写入其中:

p.now.Store(time.Now())

然后,其他代码(比如判断 worker 是否过期)就可以复用这个统一时间,而不是频繁调用 time.Now()

后台线程回收过期worker

为什么要回收worker?

在协程池中,每个 worker 本质上就是一个 goroutine。虽然 Go 的 goroutine 是轻量级线程,但如果长时间不销毁闲置的 worker,会导致它的结构体、chan、栈空间仍然在内存中,长期堆积等于 资源泄漏

为什么 ants 要ExpiryDuration来回收 worker

我们不能靠任务数量判断一个 worker 是否该被清理,只有“空闲时间”才能体现它是否“长期无用”。 worker 是“复用资源”,不是一次性资源,是被设计成可重用的 goroutine,不能在每次任务完成后就把它干掉,那样就没“池”的意义了。

任务来的节奏不稳定有时候几秒一个任务,有时候几分钟才来一个。如果你在任务空了就回收,那高峰来了就得重新创建,代价很大。用“空闲时间是否过长”来判断是否该回收,就可以平衡性能 & 资源使用。

其他回收方式:每次任务结束就回收|判断无任务就回收|固定大小+FIFO回收

// goPurge() 启动一个后台协程,定时清理过期 worker
func (p *poolCommon) goPurge() {
	if p.options.DisablePurge {
		return
	}
	p.purgeCtx, p.stopPurge = context.WithCancel(context.Background())
	go p.purgeStaleWorkers()
}
// 这个协程是 worker 的“清道夫”,核心就是:定期(每隔 ExpiryDuration)检查并清除超时的 worker。

purgeStaleWorkers()核心逻辑refresh() 查出过期 worker。调用 worker.finish() 关闭通道并销毁 goroutine

staleWorkers := p.workers.refresh(p.options.ExpiryDuration)

for i := range staleWorkers {
    staleWorkers[i].finish() // 通知协程退出
    staleWorkers[i] = nil    // 手动清理引用
}
  • 每个 worker 都有 recycleTime 字段,记录上次回收时间。
  • refresh() 遍历所有空闲 worker,如果当前时间减去 recycleTime > ExpiryDuration,就算作“过期”。

然后,唤醒等待队列中可能被挂起的 invoker

if isDormant && p.Waiting() > 0 {
    p.cond.Broadcast()
}

如果所有 worker 都销毁了,而还有任务等待 worker 被分配,这时候得主动唤醒他们,不然他们可能会永远阻塞。

条件变量

cond

在并发编程中,sync.Cond(条件变量)是一种用于 协调多个 Goroutine 间等待和通知的机制。在 ants 协程池库中,条件变量被用来高效管理 Worker 的 休眠与唤醒,避免忙等待(Busy Waiting)造成的 CPU 资源浪费。

核心作用

1. 解决的问题

  • 场景:当多个 Goroutine 需要等待某个条件(如任务到达、资源可用)时,若使用忙等待(循环检查条件),会导致 CPU 空转。
  • 条件变量的优势:
    • 在条件不满足时,Goroutine 主动休眠,释放 CPU。
    • 条件满足时,精确唤醒 等待的 Goroutine,减少无效唤醒。

2. 核心方法

方法作用
Wait()释放锁并阻塞,直到被 SignalBroadcast 唤醒(唤醒后重新获取锁)
Signal()唤醒一个等待的 Goroutine(随机选择)
Broadcast()唤醒所有等待的 Goroutine
底层原理
type Cond struct {
	noCopy noCopy 
	L Locker // L is held while observing or changing the condition
	notify  notifyList  // 指针记录关注的地址
	checker copyChecker // 记录条件变量是否被复制
}

1. 依赖关系

  • 必须与锁(sync.Mutexsync.RWMutex)结合使用
    条件变量的操作需要在锁的保护下进行,确保对共享状态的原子访问。

2. 内部实现

  • 等待队列:维护一个 FIFO 队列,记录所有调用 Wait() 的 Goroutine。
  • 操作系统级阻塞Wait() 内部通过 sync.runtime_notifyListWait 进入阻塞状态,由调度器管理。
ants 中的应用

ants 库中,条件变量主要用于 Worker 的休眠与唤醒,以下是关键代码片段和解析:

1. 创建一个Worker

当协程池提交任务时,Submit()被调用,触发retrieveWorker()唤醒或创建一个worker执行任务。

// 尝试获取一个可用 worker 来运行任务。如果没有空闲 worker,则可能会:新建一个 worker|阻塞等待一个释放的 worker|报错退出(超出上限或设置为 non-blocking)
func (p *poolCommon) retrieveWorker() (w worker, err error) {
	p.lock.Lock() // 线程安全

retry:
	// 尝试从 worker 队列中获取可用 worker
	if w = p.workers.detach(); w != nil {
		p.lock.Unlock()
		return
	}

	// 如果没有,判断是否可以新建 worker。如果还没达到 pool 容量上限,就从缓存拿一个新的 worker 并启动。
	if capacity := p.Cap(); capacity == -1 || capacity > p.Running() {
		p.lock.Unlock()
		w = p.workerCache.Get().(worker)
		w.run()
		return
	}

	// 如果是 non-blocking 模式,或排队线程数已达上限,则直接返回错误
	if p.options.Nonblocking || (p.options.MaxBlockingTasks != 0 && p.Waiting() >= p.options.MaxBlockingTasks) {
		p.lock.Unlock()
		return nil, ErrPoolOverload
	}

	// 使用条件变量阻塞当前 goroutine,等待有 worker 被释放,注意,后续会回到起始的retry处重新分配worker
	p.addWaiting(1)
	p.cond.Wait() // block and wait for an available worker
	p.addWaiting(-1)

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

	goto retry
}

条件变量阻塞直到有其他 worker 被放回池子,会通过 p.cond.Signal() 进行唤醒。

注意:p.cond 是在获取了 p.lock 之后调用的 Wait(),这是条件变量的经典用法:条件变量必须和互斥锁配合使用,防止竞态条件。

Wait() 过程中需要 自动释放锁,挂起等待,恢复后重新加锁,这样才能保证在检查条件、挂起等待、被唤醒这整个流程中是安全的,避免竞态条件

条件变量配合互斥锁使用,是为了确保“检查条件 + 挂起等待”这一步是原子操作,避免竞态和虚假唤醒,确保被唤醒后的逻辑是正确的。

例子:没有锁,会有竞态

if pool.length == 0 {
    cond.Wait() // ⛔ 可能刚好别人 push 进来了,但你还在等
}
  • 上面的 if 判断和 Wait() 之间不是原子操作!
  • 如果判断完之后,还没进入 Wait(),恰好有另一个 goroutine 添加了元素并 Signal(),你就可能错过信号,永远挂起等待

使用 mutex 锁就能让这一段逻辑变成原子性操作

mutex.Lock()
for pool.length == 0 {
    cond.Wait() // 会释放锁,挂起;被唤醒后重新加锁,继续检查
}
mutex.Unlock()

这就是通过加锁避免竞态条件。

2. 回收一个worker

将一个空闲的 worker 放回线程池(WorkerQueue) 的函数,也就是一个“回收重用”的逻辑

func (p *poolCommon) revertWorker(worker worker) bool {
	if capacity := p.Cap(); (capacity > 0 && p.Running() > capacity) || p.IsClosed() {
		p.cond.Broadcast() // 确保池关闭或超容时,所有等待的 Goroutine 能及时退出。
		return false
	}

	worker.setLastUsedTime(p.nowTime()) // 记录回收时间戳,供空闲 Worker 超时清理机制使用(如 ants.WithExpiryDuration 选项)

	p.lock.Lock() // 防止竟态条件
    // 加锁后的双重检查:防止无锁快速路径检查后,其他 Goroutine 可能已关闭池
	if p.IsClosed() {
		p.lock.Unlock()
		return false
	}
    
	if err := p.workers.insert(worker); err != nil {
		p.lock.Unlock()
		return false
	}

	p.cond.Signal() // 仅唤醒一个等待的Goroutine
	p.lock.Unlock()
	return true
}
3. worker容量调整

Tune函数动态调整池容量的,使用条件变量 cond.Signal()cond.Broadcast() 来唤醒正在等待 worker 的 goroutine

func (p *poolCommon) Tune(size int) {
	capacity := p.Cap()
	if capacity == -1 || size <= 0 || size == capacity || p.options.PreAlloc { // 容量空|目标大小size不合法|目标和容量相等|预分配内存(循环队列:容量已固定)
		return
	}
	atomic.StoreInt32(&p.capacity, int32(size)) // 更新容量为新的 size
	if size > capacity { // 如果变更是“扩容”,说明可能有等待的调用方可以继续执行。
		if size-capacity == 1 {
            // 如果只是扩容 1 个位置,用 Signal() 唤醒 一个等待中的调用方;
         	// 否则,用 Broadcast() 唤醒 所有等待的调用方。
			p.cond.Signal()
			return
		}
		p.cond.Broadcast()
	}
}

自旋锁

自旋锁是指当一个线程(在 Go 中是 Goroutine)在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待(自旋),不断判断锁是否已经被释放,而不是进入睡眠状态。

核心设计目标

  • 低延迟获取锁:在低竞争场景下快速通过 CAS 获取锁(无系统调用开销)。
  • 高竞争适应性:通过指数退避减少 CPU 空转消耗。
  • 公平性平衡:通过 runtime.Gosched() 让出 CPU 时间片,防止 Goroutine 饥饿。
type spinLock uint32

const maxBackoff = 16 // 最大退避所对应的时间

func (sl *spinLock) Lock() {
	backoff := 1
	for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
		for i := 0; i < backoff; i++ {
			runtime.Gosched() // 主动让出时间
		}
		if backoff < maxBackoff {
			backoff <<= 1 // 指数退避
		}
	}
}

func (sl *spinLock) Unlock() {
	atomic.StoreUint32((*uint32)(sl), 0)
}
  1. Spinlock 结构体有一个 int32 类型的字段 locked,用于表示锁的状态。
  2. Lock 方法使用 atomic.CompareAndSwapInt32 原子操作来尝试将 locked 从0(未锁定)更改为1(已锁定)。如果锁已经被另一个goroutine持有(即 locked 为1),则 CompareAndSwapInt32 会返回 false,并且循环会继续。
  3. Unlock 方法使用 atomic.StoreInt32 原子操作将 locked 设置回0,表示锁已被释放。

此自旋锁设计通过 CAS 原子操作、指数退避和协作式调度 的三角优化,在 低/中竞争场景 下实现了比标准库锁更低的延迟,同时避免传统自旋锁的 CPU 资源浪费问题。其核心思想是:用短暂的空转换取无系统调用的速度优势,用退避算法平衡竞争强度

下面的例子是使用*testing.PB 中的 RunParallel() 模拟高并发场景。多个 Goroutine 同时争抢锁,评估锁在高竞争下的性能。pb.Next() 确保每个 Goroutine 执行足够的次数。

三种方式,Mutex互斥锁方案,SpinMutex线性/指数退避方案。

使用go test -bench . 来运行所有测试案例得出对应的时间

type originSpinLock uint32

func (sl *originSpinLock) Lock() {
	for !atomic.CompareAndSwapUint32((*uint32)(sl), 0, 1) {
		runtime.Gosched()
	}
}

func (sl *originSpinLock) Unlock() {
	atomic.StoreUint32((*uint32)(sl), 0)
}

func NewOriginSpinLock() sync.Locker {
	return new(originSpinLock)
}

func BenchmarkMutex(b *testing.B) {
	m := sync.Mutex{}
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			m.Lock()
			//nolint:staticcheck
			m.Unlock()
		}
	})
}

func BenchmarkSpinLock(b *testing.B) {
	spin := NewOriginSpinLock()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			spin.Lock()
			//nolint:staticcheck
			spin.Unlock()
		}
	})
}

func BenchmarkBackOffSpinLock(b *testing.B) {
	spin := NewSpinLock()
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			spin.Lock()
			//nolint:staticcheck
			spin.Unlock()
		}
	})
}

/*
Benchmark result for three types of locks:
	goos: darwin
	goarch: arm64
	pkg: github.com/panjf2000/ants/v2/pkg/sync
	BenchmarkMutex-10              	10452573	       111.1 ns/op	       0 B/op	       0 allocs/op
	BenchmarkSpinLock-10           	58953211	        18.01 ns/op	       0 B/op	       0 allocs/op
	BenchmarkBackOffSpinLock-10    	100000000	        10.81 ns/op	       0 B/op	       0 allocs/op
*/

goroutine 的自旋占用资源如何解决

Goroutine 的自旋占用资源问题主要涉及到 Goroutine 在等待锁或其他资源时的一种行为模式,即自旋锁(spinlock)。自旋锁是指当一个线程(在 Go 中是 Goroutine)在获取锁的时候,如果锁已经被其他线程获取,那么该线程将循环等待(自旋),不断判断锁是否已经被释放,而不是进入睡眠状态。这种行为在某些情况下可能会导致资源的过度占用,特别是当锁持有时间较长或者自旋的 Goroutine 数量较多时。

针对 Goroutine 的自旋占用资源问题,可以从以下几个方面进行解决或优化:

  1. 减少自旋锁的使用
    评估必要性:首先评估是否真的需要使用自旋锁。在许多情况下,互斥锁(mutex)已经足够满足需求,因为互斥锁在资源被占用时会让调用者进入睡眠状态,从而减少对 CPU 的占用。
    优化锁的设计:考虑使用更高级的同步机制,如读写锁(rwmutex),它允许多个读操作同时进行,而写操作则是互斥的。这可以显著减少锁的竞争,从而降低自旋的需求。
  2. 优化自旋锁的实现
    设置自旋次数限制:在自旋锁的实现中加入自旋次数的限制,当自旋达到一定次数后,如果仍未获取到锁,则让 Goroutine 进入睡眠状态。这样可以避免长时间的无效自旋,浪费 CPU 资源。
    利用 Go 的调度器特性:Go 的调度器在检测到 Goroutine 长时间占用 CPU 而没有进展时,会主动进行抢占式调度,将 Goroutine 暂停并让出 CPU。这可以在一定程度上缓解自旋锁带来的资源占用问题。
  3. 监控和调整系统资源
    监控系统性能:通过工具(如 pprof、statsviz 等)监控 Go 程序的运行时性能,包括 CPU 使用率、内存占用等指标。这有助于及时发现和解决资源占用过高的问题。
    调整 Goroutine 数量:根据系统的负载情况动态调整 Goroutine 的数量。例如,在高并发场景下适当增加 Goroutine 的数量以提高处理能力,但在负载降低时及时减少 Goroutine 的数量以避免资源浪费。
  4. 利用 Go 的并发特性
    充分利用多核 CPU:通过设置 runtime.GOMAXPROCS 来指定 Go 运行时使用的逻辑处理器数量,使其尽可能接近或等于物理 CPU 核心数,从而充分利用多核 CPU 的并行处理能力。
    使用 Channel 进行通信:Go 鼓励使用 Channel 进行 Goroutine 之间的通信和同步,而不是直接使用锁。Channel 可以有效地避免死锁和竞态条件,并且减少了锁的使用,从而降低了资源占用的风险。
    综上所述,解决 Goroutine 的自旋占用资源问题需要从多个方面入手,包括减少自旋锁的使用、优化自旋锁的实现、监控和调整系统资源以及充分利用 Go 的并发特性等。通过这些措施的综合应用,可以有效地降低 Goroutine 在自旋过程中对系统资源的占用。

参考链接:

  1. panjf2000/ants: 🐜🐜🐜 ants is the most powerful and reliable pooling solution for Go.
  2. ants - 目前开源最优的协程池 - 轩脉刃 - 博客园
  3. Go 每日一库之 ants - 大俊的博客
  4. 后端 - go 读写锁实现原理解读 - 个人文章 - SegmentFault 思否
  5. 腾讯元宝|GPT等AI工具
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值