Skip to main content

ants vs tunny:Go goroutine pool 的两种哲学

10 min read
TLDR

antstunny 是 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 的真正价值是:

限流 + 复用 = 可控的资源消耗
caution

一个常见误解: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 里的任务还在跑
}
warning

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 对比

维度antstunny
任务提交Submit(func()) 异步Process(interface{}) 同步
返回值无内置机制内置 interface{} 返回
超时无,需自己封装ProcessTimed / ProcessCtx
动态扩缩容Tune(size)SetSize(size)
非阻塞提交WithNonblocking(true)无(天然阻塞)
PreAllocWithPreAlloc(true) ring buffer无内部队列
Pool 重启Reboot()无(需新建)
代码规模~2000+ 行,多文件~400 行,单文件
外部依赖

性能特性对比

ants 的性能优化点

  1. lock-free worker queue:通过 CAS 原子操作实现无锁的 worker 获取和归还,避免 mutex 竞争
  2. sync.Pool worker 复用goWorker 结构体通过 sync.Pool 缓存,高频创建/销毁 worker 时减少 GC
  3. PreAlloc ring buffer:超大容量 + 长运行任务场景下,预分配 ring buffer 避免动态扩容
  4. 非阻塞模式WithNonblocking(true) 时,池满直接返回 ErrPoolOverload 而非阻塞 caller
// ants 非阻塞模式:池满了不阻塞
pool, _ := ants.NewPool(100, ants.WithNonblocking(true))
err := pool.Submit(task)
if err == ants.ErrPoolOverload {
// 池满了,做降级处理
}

tunny 的性能特性

  1. 零内部队列:没有内部 job queue,也就没有 queue 的内存开销和 GC 压力
  2. 天然背压:caller 阻塞在 reqChan 上,当所有 worker 都忙时,caller 自动等待,不会堆积任务
  3. 无调度开销:没有"选择哪个 worker"这一步,worker 自己 broadcast,先到先得
  4. 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 压力
tip

如果你的场景是 HTTP server 里每次请求 Submit 一个小任务(微秒级),ants 的 lock-free dispatch 和 worker 复用会体现优势。

如果你的场景是处理一批大文件,每个文件处理需要几秒钟,worker 数量固定为 NumCPU(),tunny 的简洁同步 API 反而更舒服。


选型指南

决策表

场景推荐原因
大规模短期任务(>10000/s),不需要返回值antslock-free dispatch,worker 复用,吞吐更高
CPU 密集型计算,需要立即拿返回值tunnyProcess() 同步返回 interface{},零分配
HTTP handler 异步处理(发通知、写日志)antsSubmit() fire-and-forget,API 直接
需要超时/取消的任务链tunnyProcessTimed / ProcessCtx + worker Interrupt
嵌入式/CLI 工具,不想引入大依赖tunny单文件 ~400 行,零外部依赖,代码可读可审计
需要动态控制并发数,池需要重启antsTune() + Reboot() 完整生命周期
有状态 worker(连接池、session)tunnyWorker interface 有 Terminate() hook
需要非阻塞 fallback(池满降级)antsWithNonblocking(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。这些模式的价值远超"用哪个库"本身。