ants 协程池
https://github.com/panjf2000/ants.git
在 Go 中,goroutine 是一种轻量级线程,非常易于创建。但是在高并发场景下,如果无限制地创建 goroutine,可能导致:
- 内存飙升
- GC 压力增大
- 系统调度负载变重
- 宕机(out-of-memory)
因此,我们需要对 goroutine 数量进行限制,即协程池(Goroutine Pool)。
整体架构思路
ants
的核心思想是:
- 任务复用:不重复创建 goroutine,而是复用已经创建的 worker。
- worker 缓存管理:通过队列(如栈结构)管理空闲 worker。
- 自动回收机制:当 worker 超过设定的空闲时间,会被回收以释放资源。
- 灵活策略选择:支持不同类型的 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
:指定日志记录器
生命周期流程
- 用户调用
Submit(func)
:- 从 pool 的 workerQueue 中
detach()
一个可用的 worker。 - 如果没有可用 worker,则新建一个(前提:没达到上限)。
- 从 pool 的 workerQueue 中
- 把任务函数塞给 worker,worker 执行
run()
。 - worker 任务执行完后:
- 更新 lastUsedTime;
- 再次
insert()
回队列,等待复用。
- 每隔一段时间(如定时器):
- 执行一次
refresh(duration)
,清理过期的 worker。
- 执行一次
- 用户主动
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() | 释放锁并阻塞,直到被 Signal 或 Broadcast 唤醒(唤醒后重新获取锁) |
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.Mutex
或sync.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)
}
Spinlock
结构体有一个int32
类型的字段locked
,用于表示锁的状态。Lock
方法使用atomic.CompareAndSwapInt32
原子操作来尝试将locked
从0(未锁定)更改为1(已锁定)。如果锁已经被另一个goroutine持有(即locked
为1),则CompareAndSwapInt32
会返回false
,并且循环会继续。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 的自旋占用资源问题,可以从以下几个方面进行解决或优化:
- 减少自旋锁的使用
评估必要性:首先评估是否真的需要使用自旋锁。在许多情况下,互斥锁(mutex)已经足够满足需求,因为互斥锁在资源被占用时会让调用者进入睡眠状态,从而减少对 CPU 的占用。
优化锁的设计:考虑使用更高级的同步机制,如读写锁(rwmutex),它允许多个读操作同时进行,而写操作则是互斥的。这可以显著减少锁的竞争,从而降低自旋的需求。 - 优化自旋锁的实现
设置自旋次数限制:在自旋锁的实现中加入自旋次数的限制,当自旋达到一定次数后,如果仍未获取到锁,则让 Goroutine 进入睡眠状态。这样可以避免长时间的无效自旋,浪费 CPU 资源。
利用 Go 的调度器特性:Go 的调度器在检测到 Goroutine 长时间占用 CPU 而没有进展时,会主动进行抢占式调度,将 Goroutine 暂停并让出 CPU。这可以在一定程度上缓解自旋锁带来的资源占用问题。 - 监控和调整系统资源
监控系统性能:通过工具(如 pprof、statsviz 等)监控 Go 程序的运行时性能,包括 CPU 使用率、内存占用等指标。这有助于及时发现和解决资源占用过高的问题。
调整 Goroutine 数量:根据系统的负载情况动态调整 Goroutine 的数量。例如,在高并发场景下适当增加 Goroutine 的数量以提高处理能力,但在负载降低时及时减少 Goroutine 的数量以避免资源浪费。 - 利用 Go 的并发特性
充分利用多核 CPU:通过设置 runtime.GOMAXPROCS 来指定 Go 运行时使用的逻辑处理器数量,使其尽可能接近或等于物理 CPU 核心数,从而充分利用多核 CPU 的并行处理能力。
使用 Channel 进行通信:Go 鼓励使用 Channel 进行 Goroutine 之间的通信和同步,而不是直接使用锁。Channel 可以有效地避免死锁和竞态条件,并且减少了锁的使用,从而降低了资源占用的风险。
综上所述,解决 Goroutine 的自旋占用资源问题需要从多个方面入手,包括减少自旋锁的使用、优化自旋锁的实现、监控和调整系统资源以及充分利用 Go 的并发特性等。通过这些措施的综合应用,可以有效地降低 Goroutine 在自旋过程中对系统资源的占用。
参考链接: