Recommended resources
The freely available resources this lesson will direct you to.
Hello. In the previous lesson, you made blocked channel operations responsive to cancellation. Now you can use those same lifecycle rules to build a worker pool: a fixed number of goroutines share a stream of jobs, so no more than that number can process work at once.
This lesson takes about 40 minutes. You’ll implement a pool, check that its concurrency limit holds, and distinguish limiting active work from merely creating a queue.
What the limit actually limits
Suppose you have 1,000 independent jobs, but each job uses a connection or a substantial amount of memory. Starting 1,000 goroutines may start too much work at once. With three workers reading from one jobs channel, at most three calls to the processing function can be in progress.
Start with Go by Example: Worker Pools. Read the worker and main code as one small program.
Go by Example’s “Worker Pools” page provides a compact starting implementation. Notice who sends jobs, who closes the jobs channel, and how the caller knows the work is complete.
On the “Worker Pools” page, begin at the paragraph introducing the worker (“Here’s the worker…”). Read the worker example through the final results-receiving loop. Pay particular attention to the two channel capacities and the loop that starts three workers.
The example buffers both channels for every job. That is convenient for a five-job demonstration, but it does not bound the number of jobs waiting in memory. The worker count caps simultaneous processing; the jobs-channel capacity controls how many jobs can wait in the channel. An unbuffered jobs channel provides a simple form of backpressure: the sender must wait until a worker accepts each job.
Implement a pool with a clear lifetime
The following pool takes a slice of inputs and returns results in input order. Its processing function is synchronous: each worker calls it once per job and does not accept another job until that call returns. Save this as main.go.
package main
import (
"context"
"fmt"
"sync"
"time"
)
type job struct {
index int
value int
}
func runPool(
ctx context.Context,
inputs []int,
limit int,
process func(context.Context, int) (int, error),
) ([]int, error) {
if limit < 1 {
return nil, fmt.Errorf("worker limit must be positive")
}
jobs := make(chan job) // No queued jobs in the channel.
results := make([]int, len(inputs))
jobErrors := make([]error, len(inputs))
var workers sync.WaitGroup
workers.Add(limit)
for range limit {
go func() {
defer workers.Done()
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok {
return
}
results[j.index], jobErrors[j.index] =
process(ctx, j.value)
}
}
}()
}
dispatch:
for i, value := range inputs {
select {
case <-ctx.Done():
break dispatch
case jobs <- job{index: i, value: value}:
}
}
close(jobs) // This function is the sole sender.
workers.Wait() // All writes finish before results are inspected.
if err := ctx.Err(); err != nil {
return nil, err
}
for _, err := range jobErrors {
if err != nil {
return nil, err
}
}
return results, nil
}
func doubleAfterDelay(ctx context.Context, n int) (int, error) {
timer := time.NewTimer(25 * time.Millisecond)
defer timer.Stop()
select {
case <-ctx.Done():
return 0, ctx.Err()
case <-timer.C:
return n * 2, nil
}
}
func main() {
got, err := runPool(
context.Background(),
[]int{1, 2, 3, 4, 5},
2,
doubleAfterDelay,
)
fmt.Println(got, err)
}
Run go run main.go. The result is [2 4 6 8 10] <nil>, regardless of which worker finishes a particular job first. Because there are two workers, the five delayed jobs finish in roughly three rounds; elapsed time is not itself a reliable proof of the concurrency bound.
Several details make this more than a loop that launches goroutines:
workers.Add(limit)happens before the goroutines start and beforeWait. Each worker callsDoneexactly once when it exits.- The caller is the only sender to
jobs, so it closesjobsafter dispatch ends. A worker receivesok == falsewhen no more jobs are coming. - A canceled context can release the caller from a blocked job send and workers from blocked receives. The processing function also checks the context; a
selectaround channel operations cannot interrupt a processing function that ignores cancellation. - Each job has a distinct index. Workers write to separate, already-allocated slice elements; they do not append to the slices.
Waitcompletes before the caller reads those elements. There is no shared slice element being modified concurrently.
This pool’s error policy is deliberately straightforward: a processing error is recorded, but other submitted jobs continue. After every worker exits, the function returns the first error in input order. Cancellation takes precedence and may leave some result slots unwritten; in that case, the function returns no results. A select can still accept a ready job when cancellation is also ready, so cancellation means stop promptly, not guarantee that no further job starts.
Verify the bound rather than inferring it from speed
A test can count calls currently inside process. The following one makes the first three calls wait until all three have started. That checks that the pool can use its configured capacity; its peak count checks that it does not exceed it. Save it as main_test.go.
package main
import (
"context"
"reflect"
"sync"
"testing"
"time"
)
func TestRunPoolBoundsWorkAndPreservesOrder(t *testing.T) {
ctx, cancel := context.WithTimeout(
context.Background(), 3*time.Second,
)
defer cancel()
var mu sync.Mutex
active, peak := 0, 0
released := false
threeStarted := make(chan struct{})
process := func(ctx context.Context, n int) (int, error) {
mu.Lock()
active++
if active > peak {
peak = active
}
if active == 3 && !released {
released = true
close(threeStarted)
}
mu.Unlock()
defer func() {
mu.Lock()
active--
mu.Unlock()
}()
select {
case <-ctx.Done():
return 0, ctx.Err()
case <-threeStarted:
return n * 2, nil
}
}
got, err := runPool(
ctx, []int{1, 2, 3, 4, 5, 6}, 3, process,
)
if err != nil {
t.Fatal(err)
}
want := []int{2, 4, 6, 8, 10, 12}
if !reflect.DeepEqual(got, want) {
t.Fatalf("results = %v, want %v", got, want)
}
if peak != 3 {
t.Fatalf("peak active calls = %d, want 3", peak)
}
}
Run go test -race main.go main_test.go. The timeout is a failure bound: if fewer than three workers can enter process, the test returns an error rather than waiting forever. The mutex protects the test’s counters, while the race detector also checks the pool’s concurrent result writes.
The function does not return until workers.Wait() completes. That matters when using the pool inside a request or command: the caller receives a result and knows that this invocation left no worker goroutines running.
A fixed pool is a choice, not the only limiter
Watch the worker-pool portion of Bryan C. Mills’s Rethinking Classical Concurrency Patterns. It places the fixed-worker design alongside another way to limit active work.
GopherCon 2018: Rethinking Classical Concurrency Patterns - Bryan C. Mills
In this Gopher Academy talk, Mills explains what a worker pool usefully limits in Go, then examines the cost of keeping worker goroutines around.
Watch fixed workers for the resource-limit argument, worker lifetimes for the shutdown concern, and on-demand limiting for the alternative that acquires capacity before starting each goroutine. Compare that alternative with the fixed, per-call lifetime of the pool you just built.
An on-demand design can acquire a slot before launching each job goroutine, avoiding idle workers. A fixed pool is useful when jobs arrive through a shared channel and a stable group of workers should consume them. Neither design is safe merely because it has a concurrency limit: you still need to decide how submissions stop, how in-progress work responds to cancellation, and how completion is observed.
Also keep the two bounds separate. This lesson’s unbuffered channel prevents a backlog inside the jobs channel; the caller still holds the entire inputs slice. Streaming an arbitrarily large input without retaining it all requires an input source that produces jobs incrementally.
Takeaways
A bounded worker pool starts a fixed number of workers, feeds them jobs, and waits for them to exit. The worker count limits simultaneous processing; channel capacity separately controls queued jobs. Keep the jobs channel’s closer on the sending side, make blocking operations cancellable, and wait before reading worker-written results or claiming shutdown is complete.
This finishes the module’s concurrency and lifecycle patterns. In the next module, you’ll measure concurrent behavior—including whether a chosen concurrency limit improves throughput without unacceptable latency.