Skip to main content
Go · lesson 16 of 25
Go intermediate Lesson 16 of 25

Concurrency Patterns in Go

Go concurrency patterns run for real: worker pools, bounded fan-out pipelines, context timeouts and cancellation, RWMutex, sync.Once, and errgroup limits.

By SkillByExample · Updated

The goroutines and channels lessons cover the primitives. This one assembles them into the patterns real services use. Concurrent code finishes in a different order on each run, so every example below either writes results into indexed slots or sorts them before printing; each was run repeatedly and with GOMAXPROCS=1 to confirm the output is identical every time.

Worker pool

A fixed number of workers read from a shared jobs channel. Closing jobs tells them there is no more work.

package main

import (
	"fmt"
	"sync"
)

type result struct {
	id, out int
}

func worker(jobs <-chan int, results chan<- result, wg *sync.WaitGroup) {
	defer wg.Done()
	for n := range jobs { // ends when jobs is closed and drained
		results <- result{n, n * n}
	}
}

func main() {
	const workers, jobCount = 3, 8
	jobs := make(chan int)
	results := make(chan result)

	var wg sync.WaitGroup
	for range workers {
		wg.Add(1)
		go worker(jobs, results, &wg)
	}
	go func() {
		for i := range jobCount {
			jobs <- i
		}
		close(jobs)
	}()
	go func() {
		wg.Wait()
		close(results) // only after every worker has stopped sending
	}()

	out := make([]int, jobCount)
	for r := range results {
		out[r.id] = r.out
	}
	fmt.Println(out)
}
$ go run pool.go
[0 1 4 9 16 25 36 49]

Three goroutines did eight jobs. The results arrived in an unpredictable order, so each carries its job id and lands in its own slot. The goroutine that waits and then closes results is the important detail: results may only be closed once no worker can send any more, and main can’t wait itself because it is busy receiving.

Bounded fan-out with errgroup

golang.org/x/sync/errgroup replaces the hand-built WaitGroup + error plumbing. SetLimit caps concurrency, and WithContext cancels the others when one fails.

package main

import (
	"context"
	"fmt"
	"sync/atomic"

	"golang.org/x/sync/errgroup"
)

func main() {
	var running, peak atomic.Int32
	sizes := make([]int, 10)

	g := new(errgroup.Group)
	g.SetLimit(3) // at most 3 goroutines at a time
	for i := range sizes {
		g.Go(func() error {
			n := running.Add(1)
			defer running.Add(-1)
			for p := peak.Load(); n > p && !peak.CompareAndSwap(p, n); p = peak.Load() {
			}
			sizes[i] = i * 10
			return nil
		})
	}
	fmt.Println(g.Wait(), sizes, "peak<=3:", peak.Load() <= 3)

	g2, ctx := errgroup.WithContext(context.Background())
	g2.Go(func() error { return fmt.Errorf("shard 2: disk full") })
	g2.Go(func() error {
		<-ctx.Done() // cancelled when the first goroutine fails
		return ctx.Err()
	})
	fmt.Println(g2.Wait())
}
$ go get golang.org/x/sync/errgroup@v0.19.0
go: added golang.org/x/sync v0.19.0
$ go run .
<nil> [0 10 20 30 40 50 60 70 80 90] peak<=3: true
shard 2: disk full

The version is pinned to v0.19.0 because it’s the newest x/sync release whose go.mod accepts Go 1.24, the toolchain used here; asking for a later one makes go get raise your module’s go line and download a newer toolchain to match. g.Go blocks once three goroutines are running, so at most three were ever active — the peak counter checks it. In the second group, the first error cancelled ctx, which released the other goroutine; Wait returned the first error, not context canceled. That’s the shape for “fetch these N things, give up on the first failure”.

Pipeline with parallel stages

Stages connected by channels, with the middle stage fanned out across several goroutines.

package main

import (
	"fmt"
	"slices"
	"strings"
	"sync"
)

func source(words ...string) <-chan string {
	out := make(chan string)
	go func() {
		defer close(out)
		for _, w := range words {
			out <- w
		}
	}()
	return out
}

func upper(in <-chan string, workers int) <-chan string {
	out := make(chan string)
	var wg sync.WaitGroup
	for range workers {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for w := range in {
				out <- strings.ToUpper(w)
			}
		}()
	}
	go func() { wg.Wait(); close(out) }()
	return out
}

func main() {
	var got []string
	for w := range upper(source("go", "is", "fun", "and", "fast"), 3) {
		got = append(got, w)
	}
	slices.Sort(got)
	fmt.Println(got)
}
$ go run pipeline.go
[AND FAST FUN GO IS]

Fanning a stage out loses ordering: three goroutines race to send, so the collected slice is sorted before printing. If order matters, carry an index with each item as the worker pool does. Each stage owns and closes its own output, so shutdown flows down the pipeline when the source closes.

Context: timeouts

context.WithTimeout gives a context whose Done channel closes after the deadline. Functions that take a ctx should give up when it does.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

func query(ctx context.Context, ready <-chan string) (string, error) {
	select {
	case r := <-ready:
		return r, nil
	case <-ctx.Done():
		return "", fmt.Errorf("query: %w", ctx.Err())
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel() // always release the context's timer

	fast := make(chan string, 1)
	fast <- "3 rows"
	fmt.Println(query(ctx, fast))

	never := make(chan string)
	_, err := query(ctx, never)
	fmt.Println(err)
	fmt.Println(errors.Is(err, context.DeadlineExceeded))
}
$ go run ctxtimeout.go
3 rows <nil>
query: context deadline exceeded
true

The second query waited until the 50 ms deadline and returned a wrapped context.DeadlineExceeded. defer cancel() is required even when the timeout fires on its own: it releases the timer early on the success path, and go vet warns when a cancel function is dropped.

Context: cancellation down a call chain

Cancelling a parent context cancels every context derived from it.

package main

import (
	"context"
	"fmt"
	"sync"
)

func count(ctx context.Context, name string, out chan<- string) {
	for i := 0; ; i++ {
		select {
		case out <- fmt.Sprint(name, i):
		case <-ctx.Done():
			return
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	out := make(chan string)

	var wg sync.WaitGroup
	for _, name := range []string{"a", "b", "c"} {
		wg.Add(1)
		go func() { defer wg.Done(); count(ctx, name, out) }()
	}

	got := 0
	for range out {
		if got++; got == 10 {
			break
		}
	}
	cancel()
	wg.Wait() // every goroutine has returned
	fmt.Println("received", got, "values; workers stopped:", ctx.Err())
}
$ go run ctxcancel.go
received 10 values; workers stopped: context canceled

Three producers would run forever. After ten values, one cancel() closed ctx.Done() for all of them, and wg.Wait() proves they all returned — no leaked goroutines. A context.Context is the first parameter of any function that may block, by convention named ctx.

RWMutex for read-heavy state

sync.RWMutex lets any number of readers in at once, but a writer gets exclusive access.

package main

import (
	"fmt"
	"sync"
)

type Cache struct {
	mu   sync.RWMutex
	data map[string]string
}

func (c *Cache) Get(k string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.data[k]
	return v, ok
}

func (c *Cache) Set(k, v string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[k] = v
}

func main() {
	c := &Cache{data: map[string]string{}}
	var wg sync.WaitGroup
	for i := range 50 {
		wg.Add(2)
		go func() { defer wg.Done(); c.Set(fmt.Sprint("k", i%5), "v") }()
		go func() { defer wg.Done(); c.Get("k0") }()
	}
	wg.Wait()
	fmt.Println(len(c.data))
}
$ go run -race rwmutex.go
5

100 concurrent goroutines, no race reported, and five distinct keys. RLock is only worth it when reads dominate and hold the lock for a while; for tiny critical sections a plain Mutex is often as fast. Either way, the mutex lives next to the data it guards, and the struct must not be copied once used — go vet flags copies of a value containing a mutex.

sync.Once

Once.Do(f) runs f exactly once, however many goroutines call it, and the others wait until it has finished.

package main

import (
	"fmt"
	"sync"
)

var (
	loadOnce sync.Once
	config   map[string]string
	loads    int
)

func Config() map[string]string {
	loadOnce.Do(func() {
		loads++
		config = map[string]string{"env": "prod"}
	})
	return config
}

func main() {
	var wg sync.WaitGroup
	for range 20 {
		wg.Add(1)
		go func() { defer wg.Done(); _ = Config()["env"] }()
	}
	wg.Wait()
	fmt.Println("loads:", loads, "env:", Config()["env"])
}
$ go run -race once.go
loads: 1 env: prod

Twenty goroutines raced to call Config, the loader ran once, and the race detector is satisfied: Once guarantees that everything f wrote is visible to every caller after Do returns. Go 1.21 added sync.OnceValue for the common case of computing a single value: var Config = sync.OnceValue(loadConfig).

Practice

1. Change pool.go so each worker also returns an error for negative inputs, carried in result, and feed it the jobs 3, -1, 4.

Solution
package main

import (
	"fmt"
	"sync"
)

type result struct {
	id, out int
	err     error
}

func main() {
	inputs := []int{3, -1, 4}
	jobs, results := make(chan int), make(chan result)
	var wg sync.WaitGroup
	for range 2 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for id := range jobs {
				n := inputs[id]
				if n < 0 {
					results <- result{id: id, err: fmt.Errorf("job %d: negative input %d", id, n)}
					continue
				}
				results <- result{id: id, out: n * n}
			}
		}()
	}
	go func() {
		for id := range inputs {
			jobs <- id
		}
		close(jobs)
	}()
	go func() { wg.Wait(); close(results) }()

	out := make([]result, len(inputs))
	for r := range results {
		out[r.id] = r
	}
	for _, r := range out {
		fmt.Println(r.out, r.err)
	}
}
$ go run p1.go
9 <nil>
0 job 1: negative input -1
16 <nil>

The error travels with the result instead of stopping the pool, so one bad job doesn’t lose the others.

2. Use context.WithTimeout to give a loop that does one step every 20 ms a budget of 70 ms, and report how many steps it completed.

Solution
package main

import (
	"context"
	"fmt"
	"time"
)

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 70*time.Millisecond)
	defer cancel()

	tick := time.NewTicker(20 * time.Millisecond)
	defer tick.Stop()
	steps := 0
	for {
		select {
		case <-tick.C:
			steps++
		case <-ctx.Done():
			fmt.Println("steps:", steps, "err:", ctx.Err())
			return
		}
	}
}
$ go run p2.go
steps: 3 err: context deadline exceeded

Ticks at 20, 40 and 60 ms fit inside the budget; the deadline fires at 70 ms before the fourth. On a heavily loaded machine a tick can be delayed, so treat the exact count as typical rather than guaranteed.

3. Rewrite the loader in once.go with sync.OnceValue.

Solution
package main

import (
	"fmt"
	"sync"
)

var loads int

var Config = sync.OnceValue(func() map[string]string {
	loads++
	return map[string]string{"env": "prod"}
})

func main() {
	var wg sync.WaitGroup
	for range 20 {
		wg.Add(1)
		go func() { defer wg.Done(); _ = Config()["env"] }()
	}
	wg.Wait()
	fmt.Println("loads:", loads, "env:", Config()["env"])
}
$ go run -race p3.go
loads: 1 env: prod

OnceValue returns a function that computes the value on first call and returns the cached result after that — no separate Once and package variable needed.

4. Add a third stage to pipeline.go that keeps only words longer than 2 characters, without changing the other stages.

Solution
package main

import (
	"fmt"
	"slices"
	"strings"
)

func source(words ...string) <-chan string {
	out := make(chan string)
	go func() {
		defer close(out)
		for _, w := range words {
			out <- w
		}
	}()
	return out
}

func filter(in <-chan string, keep func(string) bool) <-chan string {
	out := make(chan string)
	go func() {
		defer close(out)
		for w := range in {
			if keep(w) {
				out <- w
			}
		}
	}()
	return out
}

func main() {
	long := filter(source("go", "is", "fun", "and", "fast"), func(w string) bool { return len(w) > 2 })
	var got []string
	for w := range long {
		got = append(got, strings.ToUpper(w))
	}
	slices.Sort(got)
	fmt.Println(got)
}
$ go run p4.go
[AND FAST FUN]

A stage is just a function from one channel to another, so stages compose like ordinary function calls.

Frequently Asked Questions

What is a worker pool and when should I use one?
A worker pool is a fixed number of goroutines reading jobs from a shared channel. Use one to cap concurrency, for example to limit parallel HTTP requests, database connections or CPU-heavy tasks, so a burst of work cannot exhaust memory or overwhelm a downstream service.
What is context cancellation and why is it important?
A context.Context carries a cancellation signal and optional deadline down a call chain. When a request is abandoned or a timeout expires, cancelling the context closes ctx.Done(), and every goroutine selecting on it can stop. Without it, abandoned work keeps running and goroutines leak.
When should I use sync.Mutex vs sync/atomic?
Use atomic types such as atomic.Int64 for a single counter or flag. Use a Mutex when an update touches several fields or a data structure like a map, which must change together. Use RWMutex when reads vastly outnumber writes and the critical sections are not trivially short.
What does errgroup add over sync.WaitGroup?
errgroup.Group from golang.org/x/sync waits for a set of goroutines like a WaitGroup, but each goroutine returns an error and Wait returns the first non-nil one. WithContext cancels a shared context on that first error, and SetLimit caps how many goroutines run at once.
Why should results from a worker pool be sorted or indexed?
Workers finish in whatever order the scheduler and the work dictate, so results arrive in a different order from run to run. Carry the job's index with each result and write it into a slice slot, or sort afterwards, whenever the output order matters.