ants 和 tunny 是 Go 生态里最知名的两个 goroutine pool 库。它们解决同一个问题——限制并发 goroutine 数量——但走了两条完全不同的路:
- ants:lock-free worker queue + push model,
Submit(func())提交即忘,追求极致吞吐 - tunny:bounded mailbox + pull model,
Process(payload) interface{}同步等结果,追求 API 极简
选 ants 是因为你需要"提交任务";选 tunny 是因为你需要"调用函数并拿到返回值"。
两者的差异不是谁更好,而是各自假设的使用场景不同。理解这种差异,比记住哪个库的 star 多更有价值。
为什么 Go 需要 goroutine pool?
Go 的 goroutine 确实很轻量——初始栈只有 2KB,创建和切换成本远低于 OS 线程。但"轻量"不等于"免费"。
无限制地 go func() 会带来三个问题:
1. 内存暴涨:每个 goroutine 至少占用 2KB 栈空间,10 万个就是 ~200MB
2. 调度压力:Go runtime 的 scheduler 需要在所有 runnable goroutine 之间切换,量大了照样卡
3. GC 压力:goroutine 持有的引用都是 GC root,goroutine 越多,GC 扫描越慢
goroutine pool 的核心价值不是"比原生 goroutine 更快"——实际上走 pool 多了调度开销,单任务延迟反而可能略高于裸 go func()。Pool 的真正价值是:
限流 + 复用 = 可控的资源消耗
一个常见误解:goroutine pool 能让程序更快。实际上,如果你的瓶颈是 IO 等待,goroutine pool 不会让它变快——它只是让内存和调度开销变得可预测。真正需要 pool 的场景是:你需要限制的是并发执行的 goroutine 数量,而不是 goroutine 的总数。
架构对比:push model vs pull model
这是 ants 和 tunny 最本质的差异,也是理解所有 API 差异的钥匙。
ants:lock-free push model
ants 的每个 worker 都有一个独立的 buffered channel(chan func()):
Pool.Submit(task)
→ retrieveWorker() // CAS/spin-lock 从 lock-free queue 取一个空闲 worker
→ worker.task <- task // 把 task push 进这个 worker 的 channel
→ worker goroutine 执行 task
→ worker 归还到 ready queue
核心机制:
- worker 队列:使用 lock-free stack/ring buffer(基于 CAS 原子操作),避免 mutex 竞争
- worker 复用:通过
sync.Pool缓存 goWorker 结构体,减少 GC 分配 - PreAlloc:可选,提前分配整个 ring buffer,适合超大容量 + 长运行任务
关键代码路径(简化):
func (p *Pool) Submit(task func()) error {
w := p.retrieveWorker() // lock-free 取 worker
if w == nil {
return ErrPoolOverload // 非阻塞模式,池满了
}
w.inputFunc(task) // 把任务 push 到 worker 的 channel
return nil
}
tunny:bounded mailbox pull model
tunny 用的是一个共享的 reqChan,worker 自己在上面广播"我闲了":
Pool.Process(payload)
→ worker := <-p.reqChan // 阻塞等待任意 worker 广播它的 workRequest
→ worker.jobChan <- payload // 把 payload 发给这个 worker
→ result := <-worker.retChan // 阻塞等 worker 返回结果
→ return result
Worker 侧的逻辑:
func (w *workerWrapper) loop() {
for {
w.reqChan <- workRequest{ // 广播:我闲了,谁来用我?
jobChan: w.jobChan,
retChan: w.retChan,
}
select {
case payload := <-w.jobChan: // 收到任务
result := w.worker.Process(payload)
w.retChan <- result // 返回结果
case <-w.interruptChan: // 超时中断
return
}
}
}
核心机制:
- 没有内部任务队列——
reqChan本身就是背压机制 - caller 直接阻塞在
reqChan上等待 worker,天然限流 - Worker interface 有
Interrupt()/Terminate()生命周期 hook
一张图总结
ants (push model):
Caller ──push task──→ Worker[0].taskChan
│ Worker[1].taskChan
│ Worker[2].taskChan
└── Pool 决定推给谁(lock-free queue)
tunny (pull model):
Caller ──pull worker──→ reqChan ←── Worker 自己 broadcast 空闲
│
└── 没有 Pool 层调度,worker 自己"报到"
★ Insight ─────────────────────────────────────
- ants 的 push model 优势在吞吐:pool 层面可以做调度优化(比如挑最近运行过的 worker,利用 CPU cache 热度)
- tunny 的 pull model 优势在简洁:代码 ~400 行,零分配,没有调度算法就意味着没有调度 bug
- push model 适合"任务多、worker 少"的场景;pull model 适合"worker 就是资源瓶颈本身"的场景
─────────────────────────────────────────────────
API 设计对比
任务提交
// ants:提交即忘
pool.Submit(func() {
doWork()
})
// 没有内置返回值——你需要自己用 channel/closure 传结果
// tunny:同步等结果
result := pool.Process(input)
// 直接返回 interface{},caller 阻塞到 worker 完成
这是最根本的 API 差异:ants 是异步的,tunny 是同步的。
两种风格各自适合的场景:
ants Submit() 适合:
- HTTP handler 里异步处理(发邮件、写日志、推通知)
- 不需要返回值的 fire-and-forget 任务
- 需要自己控制结果收集方式的场景
tunny Process() 适合:
- CPU 密集型计算(图片处理、序列化、加密)
- 需要立即使用返回值的同步调用链路
- 不想引入额外 channel/goroutine 协调的场景
超时和取消
// tunny 原生支持超时
result, err := pool.ProcessTimed(input, 5*time.Second)
if err == tunny.ErrJobTimedOut {
// worker 会收到 Interrupt() 信号
}
// tunny 也支持 context
result, err := pool.ProcessCtx(ctx, input)
// ants 没有内置超时——需要自己包装
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go func() {
pool.Submit(func() { /* ... */ })
}()
select {
case <-ctx.Done():
// 超时了,但 worker 里的任务还在跑
}
tunny 的超时机制会主动调用 worker.Interrupt(),通知 worker 放弃当前任务。但 worker 是否真的停掉取决于实现——Interrupt 只是发信号,worker 需要在内部检查 interruptChan。
ants 没有这个机制,因为它是 fire-and-forget 的——任务提交后 caller 就不管了,超时控制需要由任务本身处理。
生命周期管理
// ants:完整的生命周期
pool.Tune(100) // 运行时调整容量
pool.Release() // 释放池
pool.ReleaseTimeout(time.Second) // 等 worker 完成再释放
pool.Reboot() // 释放后还可以重启
// tunny:简洁但够用
pool.SetSize(10) // 运行时调整容量(线程安全)
pool.Close() // 关闭池
整体 API 对比
| 维度 | ants | tunny |
|---|---|---|
| 任务提交 | Submit(func()) 异步 | Process(interface{}) 同步 |
| 返回值 | 无内置机制 | 内置 interface{} 返回 |
| 超时 | 无,需自己封装 | ProcessTimed / ProcessCtx |
| 动态扩缩容 | Tune(size) | SetSize(size) |
| 非阻塞提交 | WithNonblocking(true) | 无(天然阻塞) |
| PreAlloc | WithPreAlloc(true) ring buffer | 无内部队列 |
| Pool 重启 | Reboot() | 无(需新建) |
| 代码规模 | ~2000+ 行,多文件 | ~400 行,单文件 |
| 外部依赖 | 无 | 无 |
性能特性对比
ants 的性能优化点
- lock-free worker queue:通过 CAS 原子操作实现无锁的 worker 获取和归还,避免 mutex 竞争
- sync.Pool worker 复用:
goWorker结构体通过sync.Pool缓存,高频创建/销毁 worker 时减少 GC - PreAlloc ring buffer:超大容量 + 长运行任务场景下,预分配 ring buffer 避免动态扩容
- 非阻塞模式:
WithNonblocking(true)时,池满直接返回ErrPoolOverload而非阻塞 caller
// ants 非阻塞模式:池满了不阻塞
pool, _ := ants.NewPool(100, ants.WithNonblocking(true))
err := pool.Submit(task)
if err == ants.ErrPoolOverload {
// 池满了,做降级处理
}
tunny 的性能特性
- 零内部队列:没有内部 job queue,也就没有 queue 的内存开销和 GC 压力
- 天然背压:caller 阻塞在
reqChan上,当所有 worker 都忙时,caller 自动等待,不会堆积任务 - 无调度开销:没有"选择哪个 worker"这一步,worker 自己 broadcast,先到先得
- Worker 生命周期懒加载:worker 只在
SetSize增容时才通过 constructor 创建
// tunny 的背压是天然的:不需要配置"队列长度"、"拒绝策略"
pool := tunny.NewFunc(runtime.NumCPU(), func(payload interface{}) interface{} {
return heavyCompute(payload)
})
// 第 N+1 个 caller 自动阻塞,直到有 worker 空闲
result := pool.Process(data) // 阻塞等 worker
性能差异的本质
ants 的优化方向:
"大量短期任务" → worker 复用 + lock-free dispatch → 减少每次 Submit 的延迟
tunny 的优化方向:
"少量长期任务" → 零内部队列 + 天然背压 → 减少内存占用和 GC 压力
如果你的场景是 HTTP server 里每次请求 Submit 一个小任务(微秒级),ants 的 lock-free dispatch 和 worker 复用会体现优势。
如果你的场景是处理一批大文件,每个文件处理需要几秒钟,worker 数量固定为 NumCPU(),tunny 的简洁同步 API 反而更舒服。
选型指南
决策表
| 场景 | 推荐 | 原因 |
|---|---|---|
| 大规模短期任务(>10000/s),不需要返回值 | ants | lock-free dispatch,worker 复用,吞吐更高 |
| CPU 密集型计算,需要立即拿返回值 | tunny | Process() 同步返回 interface{},零分配 |
| HTTP handler 异步处理(发通知、写日志) | ants | Submit() fire-and-forget,API 直接 |
| 需要超时/取消的任务链 | tunny | ProcessTimed / ProcessCtx + worker Interrupt |
| 嵌入式/CLI 工具,不想引入大依赖 | tunny | 单文件 ~400 行,零外部依赖,代码可读可审计 |
| 需要动态控制并发数,池需要重启 | ants | Tune() + Reboot() 完整生命周期 |
| 有状态 worker(连接池、session) | tunny | Worker interface 有 Terminate() hook |
| 需要非阻塞 fallback(池满降级) | ants | WithNonblocking(true) → ErrPoolOverload |
决策树
你的任务是?
├── fire-and-forget,不需要返回值
│ └── → ants
├── 需要同步等返回值
│ └── → tunny
├── 需要超时/取消
│ └── → tunny(或用 ants + context 自己封装)
├── worker 有状态(连接、session、初始化)
│ └── → tunny(Worker interface 有生命周期 hook)
└── 极简需求,想审计代码
└── → tunny(400 行,读完只需 10 分钟)
一个不选的场景
如果你只是需要限制并发的 goroutine 数量,且任务逻辑简单,其实还有一个选择:
不用任何库,直接用 channel 做 semaphore。
sem := make(chan struct{}, 10) // 最多 10 并发
for _, item := range items {
sem <- struct{}{} // 获取
go func(item Item) {
defer func() { <-sem }() // 释放
process(item)
}(item)
}
这个模式适用于轻量场景——不需要返回值收集、不需要动态扩缩容、不需要 worker 生命周期管理。如果这已经够用,引入 ants 或 tunny 就是过度工程化。
总结
ants 和 tunny 不是竞争关系,而是互补关系。它们代表了 goroutine pool 的两种设计哲学:
ants = "worker pool" → 池是管理者,负责调度任务
tunny = "bounded executor" → 池是执行器,caller 自己排队
选择的核心判断就两个问题:
1. 我需要返回值吗?
- 需要 → tunny
- 不需要 → ants
2. 我的任务量大到需要 lock-free dispatch 吗?
- 是 → ants
- 不是 → tunny(够用、够简单)
大多数项目其实用 tunny 就够了——它的 API 更符合 Go 的"显式同步"风格。ants 的价值体现在大规模并发场景下:当你的 Submit QPS 达到数万级别,lock-free worker queue 和 sync.Pool 优化才会真正体现差异。
但不管选哪个,理解它们的设计差异本身就是一次很好的 Go 并发模型学习——push model 怎么用 CAS 做无锁调度,pull model 怎么用 channel 做 bounded mailbox。这些模式的价值远超"用哪个库"本身。