Go Module 15 – Concurrency patterns in Go
Worker pools, semaphores and bounded parallelism
Bound Go goroutines with a worker pool or a channel semaphore, let a full queue push back on producers, and size a pool for work that computes or waits.
What you will learn
- Run a fixed pool of workers over a jobs channel and shut it down in order
- Limit goroutines with a buffered channel used as a counting semaphore
- Size a pool for CPU-bound and I/O-bound work from measurements
- Choose between waiting, refusing and giving up when a bounded queue is full
Before you start
What you’ll learn · Before you start
What you will learn
- Run a fixed pool of workers over a jobs channel and shut it down in order
- Limit goroutines with a buffered channel used as a counting semaphore
- Size a pool for CPU-bound and I/O-bound work from measurements
- Choose between waiting, refusing and giving up when a bounded queue is full
Before you start
On this page
Starting one goroutine per image is one line of Go, and it sends every read at once. Against a store that serves at most 8 reads at a time, pool.go below sent 200 reads together and the store refused 192 of them, while a pool of 8 workers made the same 200 reads in about half a second with none refused.
Worker pools and semaphores give you bounded parallelism: at most N jobs run at once, however many wait. You will run a pool and stop it in order, and bound goroutines with a buffered channel. The output shown was recorded with Go 1.27.1.
A worker pool keeps a fixed number of jobs in flight
A worker pool: A fixed number of goroutines that take jobs from one shared channel, so that at most that many jobs run at once, however many are waiting. is a fixed number of goroutines that range over one jobs channel. Each job goes to whichever worker is free, so never more than N run at once, and results come back on a second channel. pool.go reads the 200 images both ways, from a store that refuses a read while 8 are in flight:
// pool.go: 200 images to read from a store that serves at most 8 reads at a time and refuses a read beyond that, as
// a storage service with a concurrency limit does. One goroutine per image sends all 200 reads at once; a pool of 8
// workers ranging over one jobs channel never has more than 8 in flight, and the producer waits while all 8 are busy.
package main
import (
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
)
var errBusy = errors.New("store busy")
// store serves at most limit reads at once, each taking 20 ms, and refuses a read while limit are in flight.
type store struct {
limit int64
active, most atomic.Int64
}
func (s *store) read(id int) (int, error) {
n := s.active.Add(1)
if n > s.limit {
s.active.Add(-1)
return 0, errBusy
}
for m := s.most.Load(); n > m && !s.most.CompareAndSwap(m, n); m = s.most.Load() {
}
defer s.active.Add(-1)
time.Sleep(20 * time.Millisecond)
return 1000 + id, nil
}
// perImage starts one goroutine for each image, all at once.
func perImage(s *store, ids []int) (done, refused int) {
var mu sync.Mutex
var wg sync.WaitGroup
for _, id := range ids {
wg.Go(func() {
_, err := s.read(id)
mu.Lock()
defer mu.Unlock()
if err != nil {
refused++
} else {
done++
}
})
}
wg.Wait()
return done, refused
}
// pool starts n workers that range over one jobs channel. It shuts down in order: the producer closes jobs, each
// worker's loop ends, and a closer closes results once wg.Wait says that every worker has returned.
func pool(s *store, ids []int, n int) (done, refused int, took, waited time.Duration) {
start := time.Now()
jobs := make(chan int)
results := make(chan error)
var wg sync.WaitGroup
for range n {
wg.Go(func() {
for id := range jobs {
_, err := s.read(id)
results <- err
}
})
}
go func() {
for _, id := range ids {
t := time.Now()
jobs <- id // waits while all n workers are busy
waited += time.Since(t)
}
close(jobs) // written after waited, so main reads waited only once results is closed
}()
go func() {
wg.Wait()
close(results)
}()
for err := range results {
if err != nil {
refused++
} else {
done++
}
}
return done, refused, time.Since(start), waited
}
// about rounds a time to the nearest 100 ms: 25 rounds of 20 ms reads gather a little scheduling delay.
func about(d time.Duration) string { return "about " + d.Round(100*time.Millisecond).String() }
func main() {
ids := make([]int, 200)
for i := range ids {
ids[i] = i
}
done, refused := perImage(&store{limit: 8}, ids)
fmt.Printf("one goroutine per image: %3d reads done, %3d refused\n", done, refused)
s := &store{limit: 8}
done, refused, took, waited := pool(s, ids, 8)
fmt.Printf("a pool of 8 workers: %3d reads done, %3d refused\n", done, refused)
fmt.Printf("the pool kept at most %d reads in flight and took %s\n", s.most.Load(), about(took))
fmt.Printf("the producer waited %s in all for a free worker\n", about(waited))
} Output
one goroutine per image: 8 reads done, 192 refused a pool of 8 workers: 200 reads done, 0 refused the pool kept at most 8 reads in flight and took about 500ms the producer waited about 500ms in all for a free worker
This output changes from run to run: the reads take 20 ms of real time each, so the total times printed can differ by a round or so from run to run.
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run pool.go
One goroutine per image put all 200 reads in flight at once, and 192 were refused. The pool never had more than 8 in flight; its 25 rounds of 8 reads at 20 ms took about half a second, a time set by the store’s limit.
The producer spent nearly all of that time waiting in jobs <- id for a free worker. That wait is
backpressure: A busy stage making the stage before it wait, as a full channel blocks its sender, so that extra work waits at its source instead of piling up as goroutines or memory.: the pool slows the producer to its own pace instead of letting goroutines
pile up. Step through the pool and the order in which it stops:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Eight workers wait. pool starts 8 goroutines, and each one ranges over the same jobs channel. None has a job yet, so all 8 wait to receive.
- The first id. The producer sends image 0. A channel gives each value to exactly one receiver, so worker 1 takes it and starts its 20 ms read.
- Eight reads in flight. Images 1 to 7 go to the other workers. Eight reads now run at once, exactly the store's limit, so the store refuses none of them.
- The producer waits. Image 8 has no free worker to take it, and jobs has no buffer, so the producer's send waits. That wait is backpressure.
- A worker comes back. Worker 1 finishes, sends nil on results for a read that worked, and main counts it. Worker 1 then receives image 8, and the producer moves on.
- The producer closes jobs. After image 199 the producer closes jobs. Each worker still gets every id already sent; once jobs is empty, its range loop ends and it returns.
- Close results once. A closer goroutine waits in wg.Wait() until all 8 workers have returned, and only then closes results, so no worker can send after the close.
- main is done. main's range over results ends after 200 results, with 200 reads done, none refused and never more than 8 at a time.
A producer that never closes jobs
Leave out close(jobs) and each worker’s range loop waits for ever after the last id, so wg.Wait() never returns,
results is never closed and the runtime stops the program with all goroutines are asleep - deadlock!. Close jobs
in the goroutine that sends on it, after its last send.
Each close ends the next loop: the producer closes jobs after its last send, and results is closed only after
wg.Wait() has seen every worker return, so no worker can send on it later.
Key idea
A worker pool bounds the work in flight by its number of workers, makes the producer wait while all are busy, and
closes jobs first and results after wg.Wait().
Checkpoint
Check: A worker pool and its shutdown order
A buffered channel of empty structs is a counting semaphore
When code starts one goroutine per job and only the number running at once must be bounded, a buffered channel works
as a counting semaphore: A count of free slots that bounds how many users may hold a resource at once. In Go a buffered channel works as one: a send takes a slot, a receive gives it back, and a send waits while every slot is taken.: its capacity is the number of slots, a send takes a slot
and waits while all are taken, and a receive gives one back. The Go memory model names this idiom, and struct{}
values take no memory. sem.go bounds the 200 reads with 8 slots, taken in two places:
// sem.go: the same 200 reads, bounded by a buffered channel of struct{} used as a counting semaphore: a send takes
// one of its 8 slots and blocks while all 8 are taken, a receive gives a slot back. Taking the slot inside each
// goroutine bounds the reads; taking it before the go statement bounds the goroutines too.
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
// read stands for one read from the store of pool.go: 20 ms of waiting.
func read(id int) int {
time.Sleep(20 * time.Millisecond)
return 1000 + id
}
// peak counts what runs now and the most that ever ran at once.
type peak struct{ now, most atomic.Int64 }
func (p *peak) up() {
n := p.now.Add(1)
for m := p.most.Load(); n > m && !p.most.CompareAndSwap(m, n); m = p.most.Load() {
}
}
func (p *peak) down() { p.now.Add(-1) }
// slotInside starts every goroutine at once; each one waits for a slot before it reads.
func slotInside(ids []int) (reads, goroutines int64) {
sem := make(chan struct{}, 8)
var r, g peak
var wg sync.WaitGroup
for _, id := range ids {
wg.Go(func() {
g.up()
sem <- struct{}{} // take a slot: blocks while all 8 are taken
r.up()
read(id)
r.down()
g.down()
<-sem // give the slot back
})
}
wg.Wait()
return r.most.Load(), g.most.Load()
}
// slotBefore takes the slot in the loop, before the go statement, so a goroutine starts only when it can read.
func slotBefore(ids []int) (reads, goroutines int64) {
sem := make(chan struct{}, 8)
var r, g peak
var wg sync.WaitGroup
for _, id := range ids {
sem <- struct{}{} // the loop itself waits here while all 8 are taken
wg.Go(func() {
g.up()
r.up()
read(id)
r.down()
g.down()
<-sem
})
}
wg.Wait()
return r.most.Load(), g.most.Load()
}
func main() {
ids := make([]int, 200)
for i := range ids {
ids[i] = i
}
r, g := slotInside(ids)
fmt.Printf("slot taken inside each goroutine: %d reads at once, %3d goroutines alive at once\n", r, g)
r, g = slotBefore(ids)
fmt.Printf("slot taken before each go: %d reads at once, %3d goroutines alive at once\n", r, g)
} Output
slot taken inside each goroutine: 8 reads at once, 200 goroutines alive at once slot taken before each go: 8 reads at once, 8 goroutines alive at once
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run sem.go
Both kept the reads at 8, but with the slot taken inside each goroutine all 200 goroutines existed at once, 192 of
them waiting for a slot; taken before the go statement, the loop itself waited and never more than 8 existed.
Effective Go gives the same advice for a server that starts a goroutine per request: gate the creation of goroutines.
Semaphores and worker pools have two relatives in the golang.org/x/sync module. errgroup.Group with SetLimit(n)
starts at most n goroutines at once and keeps the first error any of them returns. semaphore.Weighted lets one job
take several units, so a large image can count as four reads:
| Option | What it bounds | Goroutines alive | When the bound is reached |
|---|---|---|---|
| worker pool | jobs in flight | N, all run long | the producer’s send waits |
channel semaphore, slot before go |
goroutines and jobs | at most N | the loop’s send waits |
errgroup.Group with SetLimit(n) |
goroutines of the group | at most n | Go waits, TryGo reports false |
semaphore.Weighted |
the units in use | as many as started | Acquire waits, or its context ends |
Pool or semaphore
Use a pool when each worker keeps something between jobs, such as a connection; use a semaphore taken before go
when each job is a self-contained call.
How large N should be depends on what the jobs do. Jobs that only compute gain little from more workers than GOMAXPROCS: The number of goroutines that can run Go code at the same moment, the number of Ps in the scheduler. It defaults to the number of logical CPUs and, on Linux since Go 1.25, to a lower cgroup CPU limit. More in “Goroutines and the Go scheduler”, while jobs that wait on another service can keep many more busy. A full queue in front of the workers leaves a producer three answers: wait for room, be refused at once, or wait until a deadline.
Checkpoint
Check: A buffered channel as a counting semaphore
Sizing a pool from measurements and Little's law Pro: opens with a Pro, Premium or Ultimate pass
How many workers a pool should have depends on what each job spends its time doing. This part measures a pool on jobs that compute and on jobs that wait, then turns the measurements into a size with Little’s law.
In this part:
- Jobs that compute stop getting faster at GOMAXPROCS, measured
- Jobs that wait: Little’s law turns a latency into a pool size
- A service that serves 16 at a time caps the pool’s throughput
2 runnable examples (Go) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A full queue, a clean shutdown and the x/sync types, run Pro: opens with a Pro, Premium or Ultimate pass
A bounded pool must decide what happens to work that arrives faster than it can be done, and how to stop without losing a job or sending on a closed channel. This part runs three answers to a full queue, a shutdown with a deadline, and the same bound kept by golang.org/x/sync.
In this part:
- Three answers to a full queue: wait, refuse, or give up at a deadline
- A shutdown that loses no job and never sends on a closed queue
- errgroup.SetLimit and semaphore.Weighted keep the same bound
1 diagram · 4 runnable examples (Go, Go (pool.go), Go (pool_test.go), MOD, Python) · about 7 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: thumbnails, at most n at a time
practice/resize.go has ResizeAll to write: call resize for every photo, at most n at a time, and return the thumbnails in the photos’ order. practice/resize_test.go checks the order, the bound and that the calls really overlap, inside synctest bubbles, so a 10 ms resize costs no real time:
Go (resize.go) · practice/resize.go
// Package practice is the practice of the lesson on worker pools: ResizeAll makes a thumbnail of every photo, with
// at most n resizes running at the same time.
package practice
// Thumb is the thumbnail of one photo.
type Thumb struct {
Path string
Bytes int
}
// ResizeAll calls resize once for every path, with at most n calls running at the same time, and returns the
// thumbnails in the order of paths. It returns once every call has returned, and leaves no goroutine behind.
func ResizeAll(paths []string, n int, resize func(path string) Thumb) []Thumb {
return nil // replace this with at most n resizes at a time
} Go (resize_test.go) · practice/resize_test.go
package practice
import (
"fmt"
"sync"
"testing"
"testing/synctest"
"time"
)
func photos(count int) []string {
var paths []string
for i := range count {
paths = append(paths, fmt.Sprintf("photo-%02d.jpg", i))
}
return paths
}
// counter is a resize of 10 ms of the bubble's clock that records how many calls ran at the same time.
type counter struct {
mu sync.Mutex
active, most int
}
func (c *counter) resize(path string) Thumb {
c.mu.Lock()
c.active++
c.most = max(c.most, c.active)
c.mu.Unlock()
time.Sleep(10 * time.Millisecond)
c.mu.Lock()
c.active--
c.mu.Unlock()
return Thumb{Path: path, Bytes: len(path)}
}
// TestOrderKept gives the first photos the slowest resizes, so the calls finish in the opposite order.
func TestOrderKept(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
paths := photos(8)
index := map[string]int{}
for i, p := range paths {
index[p] = i
}
got := ResizeAll(paths, 4, func(path string) Thumb {
time.Sleep(time.Duration(len(paths)-index[path]) * time.Millisecond) // photo 0: 8 ms, photo 7: 1 ms
return Thumb{Path: path, Bytes: len(path)}
})
if len(got) != len(paths) {
t.Fatalf("got %d thumbnails; want %d", len(got), len(paths))
}
for i, th := range got {
if th.Path != paths[i] {
t.Fatalf("thumbnail %d is %s; want %s", i, th.Path, paths[i])
}
}
})
}
// TestAtMostN checks the bound and that the resizes really overlap: 10 photos, 3 at a time, 10 ms each, take
// 4 rounds, 40 ms of the bubble's clock.
func TestAtMostN(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var c counter
start := time.Now()
got := ResizeAll(photos(10), 3, c.resize)
if took := time.Since(start); len(got) != 10 || c.most != 3 || took != 40*time.Millisecond {
t.Fatalf("%d thumbnails, at most %d resizes at once, in %v; want 10, 3 and 40ms", len(got), c.most, took)
}
})
}
// TestOneAtATime: with n = 1 the resizes run one after another, 5 photos in 50 ms.
func TestOneAtATime(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var c counter
start := time.Now()
got := ResizeAll(photos(5), 1, c.resize)
if took := time.Since(start); len(got) != 5 || c.most != 1 || took != 50*time.Millisecond {
t.Fatalf("%d thumbnails, at most %d resizes at once, in %v; want 5, 1 and 50ms", len(got), c.most, took)
}
})
} The starter code compiles, and every test fails:
"""The starter ResizeAll of practice/resize.go against its tests: all three fail until the pool is written."""
import session
session.run(
"""
cd practice && go test .
""",
expect_failure=["cd practice && go test ."],
) Output
$ cd practice && go test .
--- FAIL: TestOrderKept (…s)
resize_test.go:50: got 0 thumbnails; want 8
--- FAIL: TestAtMostN (…s)
resize_test.go:68: 0 thumbnails, at most 0 resizes at once, in 0s; want 10, 3 and 40ms
--- FAIL: TestOneAtATime (…s)
resize_test.go:80: 0 thumbnails, at most 0 resizes at once, in 0s; want 5, 1 and 50ms
FAIL
FAIL example.com/practice …s
FAIL
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
Make go test . pass in the practice folder, then run go test -race . too. A first hint: take a slot of a buffered
channel before each go statement, and write each thumbnail at its own index of a slice made with
make([]Thumb, len(paths)).
The self-check below covers the pool and the semaphore.
Practice that adapts to you
Practise this lesson
7 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
A warm-up, answered in full, then three module-bank questions about pools under load:
-
Fresher to mid · Warm-up
How does a buffered channel of struct{} work as a semaphore, and where do you take the slot?
Its capacity is the number of slots. A send takes a slot and waits while every slot is taken; a receive gives one back, so at most that many goroutines are past the send at once. struct{} values take no memory, so the channel holds only the count. When the jobs are many, take the slot in the loop, before the go statement: the loop then waits, and no more goroutines exist than slots. Taken inside each goroutine, the slot bounds the work, but every goroutine still starts and waits for a slot. Give the slot back with defer, so that an early return cannot keep it.
-
Mid to senior
An upload service starts one goroutine per uploaded photo, and each goroutine calls an image API that accepts at most 20 requests at once. A marketing email brings 5,000 uploads in a minute, and most of them fail. Explain why, and redesign the service.
-
Senior to staff
A Go service must make 1,500 calls a second to a payments API whose median latency is 80 ms and whose p99 is 400 ms. How many workers do you give the pool, and what happens to that choice when the API's latency doubles during an incident?
-
Mid to senior
On every deploy, a service with an HTTP server and a worker pool loses some queued jobs, and once in a while it panics with "send on closed channel". Walk through a shutdown that loses nothing, and say what you do when the deadline is too short to drain the queue.
Key takeaways
- Worker pools bound the work in flight by their number of workers, and the producer waits while all are busy, which is backpressure.
- A pool stops in order: the producer closes jobs after its last send, and results is closed after
wg.Wait(). - A buffered channel of
struct{}is a counting semaphore; taken beforego, its slot bounds the goroutines too, so semaphores give bounded parallelism without a pool.
Terms in this lesson
- backpressure
- A busy stage making the stage before it wait, as a full channel blocks its sender, so that extra work waits at its source instead of piling up as goroutines or memory.
- counting semaphore
- A count of free slots that bounds how many users may hold a resource at once. In Go a buffered channel works as one: a send takes a slot, a receive gives it back, and a send waits while every slot is taken.
- worker pool
- A fixed number of goroutines that take jobs from one shared channel, so that at most that many jobs run at once, however many are waiting.
References
- Effective Go: Channels (The Go Authors)
- Go Concurrency Patterns: Pipelines and cancellation (The Go Blog) (The Go Authors)
- The Go Memory Model: Channel communication (The Go Authors)
- Package runtime: GOMAXPROCS (The Go Authors)
- Go 1.25 Release Notes: Container-aware GOMAXPROCS (The Go Authors)
- Package golang.org/x/sync/errgroup: Group.SetLimit and Group.TryGo (The Go Authors)
- Package golang.org/x/sync/semaphore (The Go Authors)
- Package sync: WaitGroup.Go (The Go Authors)
- Package testing/synctest (The Go Authors)
- Package net/http: Server.Shutdown (The Go Authors)
- RFC 6585: Additional HTTP Status Codes, section 4 (429 Too Many Requests) (IETF)
- RFC 9110: HTTP Semantics, section 10.2.3 (Retry-After) (IETF)
- RFC 9110: HTTP Semantics, section 15.6.4 (503 Service Unavailable) (IETF)
- A Proof for the Queuing Formula: L = λW (J. D. C. Little, Operations Research 9(3)) (INFORMS)
- Discrete Stochastic Processes, chapter 4: Renewal processes (Little's theorem) (MIT OpenCourseWare (Robert Gallager))
Report a problem with this lesson
Kept only in this browser. Your Learn progress