The Go Goroutine Pool Pattern That Always Works
The goroutine pool is the most over-engineered pattern in the Go ecosystem and the most under-engineered one in production. The standard library gives you sync.WaitGroup (a barrier, not a pool) and errgroup (a fan-out, not a pool); everything else — the 40-line WorkerPool on StackOverflow, the channel-based “pool” in every blog post — is a re-implementation of the same 50-line pattern, each with a slightly different name for the slot and a slightly different condition for slot free.
This post is the one pattern that always works and the one condition that always breaks it. By the end: a 50-line pool (a chan func() + a sync.WaitGroup), a test that proves the pool bounds concurrency, the deadlock that a worker that submits into its own pool causes, and the three rules that keep the pool boring.
The pattern: a chan func() is the pool
The entire pattern is a channel of closures, a fixed set of worker goroutines, and a barrier. No acquire, no release, no Lock, no Unlock — the channel is the mutex, and the range is the lock. A worker is a goroutine blocked on the channel; a free slot is an empty channel slot; a full pool is a full channel.
sequenceDiagram
participant P as Producer (Submit)
participant J as jobs chan (cap = size)
participant W1 as worker 1
participant W2 as worker 2
P-->>J: Submit(fn1)
P-->>J: Submit(fn2)
P-->>J: Submit(fn3) -- blocks: channel full, pool at cap
W1-->>J: range: take fn1
W1-->>W1: fn1()
W1-->>J: range: take fn3 (unblocked)
Note over J: the channel *is* the cap: at most `size`
closures are in flight at once
stateDiagram-v2
[*] --> Idle: NewPool(size)
Idle --> Running: Submit fills the jobs channel
Running --> Running: workers take/execute, one at a time
Running --> Draining: close(jobs)
Draining --> [*]: Wait() returns (all workers done)
The code: the 50-line pool
| |
That is the whole thing. A chan func() is the pool; a sync.WaitGroup is the barrier that tells you when it has drained. Submit is the only way to add work; Wait is the only way to drain it.
The code: a test that proves the bound
| |
The one condition that always breaks it
The pattern above is correct as long as the workers never block on the caller. The one thing that breaks it is a worker that calls back into the pool — a worker that Submits a new job into the very pool it is running in.
That creates a cycle in the work graph: a job holds the only slot and waits for a slot to run its child, and the child is waiting for the parent’s slot. The chan is a FIFO with no priority, so the last job in the queue that needs the slot is the one that deadlocks it. The symptom is “the pool stops making progress and the CPU is at 0%.”
The fix is never to Submit from inside a worker that blocks on the pool. If you need to fan out, spawn a new goroutine inside the worker and Submit from that — the new goroutine is not a worker, so it does not hold a slot, and the cycle is broken.
The three rules that keep the pool boring
- The pool is a
chan. Nomutex, nocond, noatomic. The channel is the only synchronization primitive; if you find yourself adding async.Mutexaround the channel, you have re-implemented the channel. - The worker is a
goroutine. Not athread, not atask, not acoroutine. A goroutine is the only thing the channel blocks on. If you make the worker a C thread via cgo, the channel blocks on the goroutine that wraps the thread and you have re-implemented the scheduler. Waitis a barrier, not a scheduler. It closes the channel and waits for thewgto drain. It does not cancel, does not interrupt, does not time out. If you need any of those, you have left the pool and entered the scheduler.
Pools vs errgroup vs raw goroutines
| Pattern | Concurrency bound | Backpressure | Cancellation | Lines |
|---|---|---|---|---|
errgroup | Unlimited (one goroutine per task) | None | context | ~10 |
Raw go + WaitGroup | Unlimited | None | context | ~10 |
chan func() pool (this post) | size | Submit blocks | Manual (close + wg) | ~50 |
Note: if you only need bounded fan-out with an error,
errgroupwith aSetLimit(n)is 20 lines and gives youcontextcancellation for free. The rawchan func()pool is for when you need unbounded work with a hard concurrency cap and no per-task result plumbing.
Warning: the classic “pool stops making progress, CPU at 0%” is a worker that
Submits into its own pool. If your jobs fan out (a parser that submits sub-parses, a crawler that submits next URLs), the fan-out must happen in a new goroutine, not in the worker. The pool does not detect the cycle — the deadlock is the only signal.
The 50-line rule
If your pool is more than 50 lines, you have added a scheduler. The 50-line pool is the only pool that always works: a chan func() for the slots, a WaitGroup for the drain, and a test that proves the cap. Everything else is a scheduler in a trench coat.
Takeaways
- A
chan func()is the pool. The channel is the cap, therangeis the lock, theWaitGroupis the barrier. Noacquire, norelease, noMutex. Submitblocking is the backpressure. A pool that accepts work faster than it runs it is not a pool; it is a queue that lies to you.- Never
Submitfrom inside a worker. A worker that blocks on its own pool is a deadlock. Fan out in a new goroutine. - The 50-line rule is the design test. More than 50 lines and you have a scheduler; the scheduler is where the 3am pages live.
Next steps
- Add
contextwhere you need cancellation:Submittakes actxand the worker checks it before running the job. Thechanpattern andcontextcompose cleanly; the barrier becomes “drain until the context is done.” - Add a bounded queue metric (len of the
jobschannel) and alert when it approaches capacity — a pool that is always full is a pool that is always the bottleneck. - Prefer
errgroupwithSetLimitfor the common “bounded fan-out with an error” case. Reach for the rawchanpool only when you need unbounded work with a hard cap and no per-task result plumbing.