Common Go Concurrency Pattern Templates

A ready-to-use Go concurrency reference. Each pattern gives “when to use it / template / key pitfalls.”

Two judgment rules run through the whole piece:

  • If you can avoid sharing, communicate (channels / message passing); if you must share, wrap it with the simplest synchronization possible (locks / atomics).
  • Every time you start a goroutine, first work out under what condition and how it exits—otherwise you have a goroutine leak.

Version note: the examples are written for Go 1.22+. errgroup / singleflight come from golang.org/x/sync.

Locking Shared Variables

When to use: multiple goroutines read and write the same state, and the operation isn’t a single atomic action (you need to protect a “compound operation” as a whole).

import "sync"

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.n
}

If atomics will do, skip the lock (a single counter, flag, or wholesale pointer swap):

import "sync/atomic"

var n atomic.Int64
n.Add(1)        // atomic increment
_ = n.Load()    // atomic read

Read-heavy, write-light: use RWMutex (multiple readers can hold the read lock concurrently):

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

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

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

“Almost always read, occasionally swapped wholesale”: use an atomic pointer (COW, lock-free readers):

var config atomic.Pointer[Config]

// read (lock-free, very fast)
cfg := config.Load()

// write (build a new copy, swap it in atomically)
config.Store(buildNewConfig())

Key pitfalls

  • One lock should either protect a group of related fields, or it gets too coarse and becomes a bottleneck—draw lock boundaries by “data that gets read and written together.”
  • With multiple locks, always acquire them in a fixed global order, or you’ll deadlock.
  • defer Unlock() is the default habit; it avoids forgetting to unlock on the return / panic path.

Singleton / Lazy Initialization (Once)

When to use: some resource should be initialized exactly once globally and thread-safely—a database connection, a connection pool, global config.

import "sync"

var (
	once sync.Once
	db   *DB
)

func GetDB() *DB {
	once.Do(func() {
		db = mustConnect() // runs only once; concurrent callers block until it completes
	})
	return db
}

A cleaner form in Go 1.21+:

// OnceValue: wraps up a "compute-once value"; just call GetDB() to get it
var GetDB = sync.OnceValue(func() *DB {
	return mustConnect()
})

Key pitfalls

  • Don’t hand-roll a “double-checked lock”—the memory ordering is extremely easy to get wrong, and Once already handles it correctly.
  • A panic inside once.Do counts as “already executed”; later calls won’t retry, so the init logic must guarantee it won’t panic, or handle the fallback itself.

Communication: Passing Data over a Channel (Producer-Consumer)

When to use: data / ownership needs to flow between goroutines, rather than being read and written by multiple parties at once.

// unbuffered = synchronous handoff (sender blocks until receiver is ready)
// buffered = decouples rates (can send as long as the buffer isn't full)
ch := make(chan Item, 100)

// producer: the sender is responsible for close
go func() {
	defer close(ch)
	for _, it := range items {
		ch <- it
	}
}()

// consumer: range keeps pulling until the channel is "closed and drained"
for it := range ch {
	handle(it)
}

Key pitfalls

  • Always close from the sender, and only after all sends are done. Sending on a closed channel panics.
  • Forgetting to close leaves the consumer’s range blocked forever → a leak.
  • With multiple senders, use a WaitGroup to wait for all senders to finish, then close in one place (see pattern 6).

Publish/Subscribe (Pub/Sub)

When to use: one message must be broadcast to all subscribers (each gets a copy). Note the difference from fan-out: fan-out means “one message goes to exactly one worker.”

import "sync"

type Broker[T any] struct {
	mu   sync.RWMutex
	subs map[chan T]struct{}
}

func NewBroker[T any]() *Broker[T] {
	return &Broker[T]{subs: make(map[chan T]struct{})}
}

func (b *Broker[T]) Subscribe() chan T {
	ch := make(chan T, 16)
	b.mu.Lock()
	b.subs[ch] = struct{}{}
	b.mu.Unlock()
	return ch
}

func (b *Broker[T]) Unsubscribe(ch chan T) {
	b.mu.Lock()
	delete(b.subs, ch)
	close(ch)
	b.mu.Unlock()
}

func (b *Broker[T]) Publish(msg T) {
	b.mu.RLock()
	defer b.mu.RUnlock()
	for ch := range b.subs {
		select {
		case ch <- msg:
		default: // if a subscriber's buffer is full, drop it to avoid dragging down the whole Broker
		}
	}
}

Key pitfalls

  • A slow subscriber drags down the broadcast—use a buffered channel + select/default to drop, or give each subscriber its own delivery goroutine.
  • A subscriber must Unsubscribe when it exits, otherwise Publish keeps sending to a channel no one reads.

select Multi-way Waiting + Actor State Serialization

select: the general building block for timeout / cancellation / multiplexing

select {
case v := <-ch:
	handle(v)
case <-ctx.Done(): // cancellation signal (most common)
	return ctx.Err()
case <-time.After(2 * time.Second): // timeout
	return errTimeout
}

Non-blocking send/receive (add default):

select {
case ch <- v:
	// sent
default:
	// couldn't send (buffer full / no receiver), don't block
}

Actor: replace locks with an exclusive goroutine

When to use: let state belong to a single goroutine; anyone who wants to read or write sends a command in—lock-free throughout, because nothing is shared.

type cmd struct {
	op    string
	key   string
	value int
	reply chan int // one-shot reply channel
}

func store(ctx context.Context, cmds <-chan cmd) {
	state := map[string]int{} // private state, no lock needed
	for {
		select {
		case <-ctx.Done():
			return
		case c := <-cmds:
			switch c.op {
			case "set":
				state[c.key] = c.value
			case "get":
				c.reply <- state[c.key]
			}
		}
	}
}

// caller
func get(cmds chan<- cmd, key string) int {
	reply := make(chan int, 1)
	cmds <- cmd{op: "get", key: key, reply: reply}
	return <-reply
}

Key pitfalls

  • The reply channel should be buffered (capacity 1), so the actor doesn’t block when the caller hasn’t received in time.
  • The actor goroutine must be able to exit on ctx cancellation, otherwise it leaks.

Pipeline / Fan-out / Worker Pool

The three build on each other: pipeline is the skeleton, fan-out adds parallelism to a slow stage, and a worker pool is the “bounded” version of fan-out.

Pipeline (stages run concurrently, data flows through in order)

func gen(ctx context.Context, nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			select {
			case out <- n:
			case <-ctx.Done():
				return // support cancellation, prevents a leak when downstream stops receiving
			}
		}
	}()
	return out
}

func sq(ctx context.Context, in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			select {
			case out <- n * n:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

// usage: for r := range sq(ctx, gen(ctx, 1, 2, 3, 4)) { ... }

Worker Pool (a fixed N workers consuming a task queue)

func workerPool[J any, R any](
	ctx context.Context,
	jobs <-chan J,
	n int,
	work func(J) R,
) <-chan R {
	results := make(chan R)
	var wg sync.WaitGroup
	for i := 0; i < n; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := range jobs {
				select {
				case results <- work(j):
				case <-ctx.Done():
					return
				}
			}
		}()
	}
	// close results only after all workers have exited (key: you must wait on wg)
	go func() {
		wg.Wait()
		close(results)
	}()
	return results
}

Fan-in (merge multiple channels into one)

func merge[T any](cs ...<-chan T) <-chan T {
	out := make(chan T)
	var wg sync.WaitGroup
	wg.Add(len(cs))
	for _, c := range cs {
		go func(c <-chan T) {
			defer wg.Done()
			for v := range c {
				out <- v
			}
		}(c)
	}
	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

Key pitfalls

  • The goroutine that closes out must wg.Wait() before it closes, otherwise it panics by sending on a closed channel.
  • When fan-out workers share one input channel, Go guarantees each value is taken by exactly one worker, so no extra locking is needed.
  • Add select { case <-ctx.Done(): return } to every stage, otherwise an upstream stage blocks forever when downstream exits early.

context: Propagating Cancellation and Timeouts

When to use: nearly every concurrent function that crosses goroutine / API boundaries—it gives the whole task tree a master switch, preventing leaks and controlling timeouts.

import (
	"context"
	"time"
)

// timeout
ctx, cancel := context.WithTimeout(parent, 3*time.Second)
defer cancel() // always call it, otherwise the timer leaks

// or manual cancellation
ctx, cancel := context.WithCancel(parent)
defer cancel()

// listen for cancellation inside a goroutine
func worker(ctx context.Context, in <-chan Task) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err() // canceled or timed out
		case t, ok := <-in:
			if !ok {
				return nil
			}
			handle(t)
		}
	}
}

Key pitfalls

  • By convention ctx is always the first parameter of a function: func F(ctx context.Context, ...).
  • The cancel returned by WithCancel/WithTimeout must be called (usually defer cancel()), otherwise resources leak.
  • Don’t store ctx in a struct and hold it long-term; it should travel along the call chain.
  • Don’t use context to pass business parameters; pass only request-scoped cancellation / deadlines / a small amount of metadata.

Structured Concurrency (errgroup)

When to use: launch a batch of independent tasks concurrently, wait for them all to finish, and cancel the rest if any one fails.

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

func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
	g, ctx := errgroup.WithContext(ctx)
	results := make([][]byte, len(urls))

	for i, url := range urls { // Go 1.22+ each iteration has its own variable, no more i, url := i, url
		g.Go(func() error {
			data, err := fetch(ctx, url)
			if err != nil {
				return err // the first non-nil error cancels ctx, cascading to the other tasks
			}
			results[i] = data
			return nil
		})
	}

	if err := g.Wait(); err != nil { // join point: wait for all, return the first error
		return nil, err
	}
	return results, nil
}

Limiting concurrency (built into errgroup, equivalent to a built-in worker pool):

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // at most 8 tasks running at once
for _, job := range jobs {
	g.Go(func() error { return process(ctx, job) })
}
err := g.Wait()

Key pitfalls

  • Only returning an error from g.Go triggers the cascading cancellation; the task itself must listen for ctx.Done() to actually exit early.
  • Multiple tasks writing different indices of the same slice is safe (no overlap); writing the same map / accumulating into the same variable still needs a lock or atomics.
  • Don’t fire off bare go and forget it—whenever you “launch a batch and need to wait for all,” use errgroup to get a clear join point.

Appendix: Two High-frequency Tools (look them up when you need them)

Singleflight—when concurrent requests hit the same key, only one actually runs and the rest share the result (prevents cache stampede):

import "golang.org/x/sync/singleflight"

var g singleflight.Group

func getUser(id string) (*User, error) {
	v, err, _ := g.Do(id, func() (any, error) {
		return queryUserFromDB(id) // concurrent calls for the same id query the DB once
	})
	if err != nil {
		return nil, err
	}
	return v.(*User), nil
}

Rate limiting (token bucket)—control how many you process per second at most:

import "golang.org/x/time/rate"

limiter := rate.NewLimiter(rate.Limit(10), 1) // 10/sec, bucket capacity 1
for req := range requests {
	if err := limiter.Wait(ctx); err != nil { // wait if no token is available (cancelable via ctx)
		return
	}
	go handle(req)
}

Cheat Sheet: How to Choose

NeedUse
Multiple parties read/write the same statea lock (Mutex); use RWMutex for read-heavy; atomic for a single value
Initialize only oncesync.Once / sync.OnceValue
Data flowing between goroutineschannel (producer-consumer)
Broadcast one message to everyonePub/Sub Broker
One task goes to one workerfan-out / worker pool
Exclusive state, want to avoid locksActor (exclusive goroutine + command channel)
Wait on multiple events (cancel/timeout/multiplex)select
Process a data stream in stagespipeline
Add controlled parallelism to a slow stageworker pool / errgroup.SetLimit
Cancellation, timeout, leak preventioncontext
Launch a batch, wait for all, cascade on failureerrgroup
Concurrent requests for the same resource compute oncesingleflight
Control the processing raterate.Limiter

In one sentence: the first half (locks / Once / channel / Pub-Sub) solves “how to share or pass state safely,” and the second half (select / pipeline / pool / context / errgroup) solves “how to organize, control, and wind down concurrent tasks.” If you can avoid sharing, communicate instead; if you must share, wrap it with the simplest synchronization possible; and every goroutine needs a clear exit path.