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/singleflightcome fromgolang.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
Oncealready handles it correctly. - A panic inside
once.Docounts 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
rangeblocked forever → a leak. - With multiple senders, use a
WaitGroupto 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/defaultto drop, or give each subscriber its own delivery goroutine. - A subscriber must
Unsubscribewhen it exits, otherwisePublishkeeps 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
replychannel 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
ctxcancellation, 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
outmustwg.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
ctxis always the first parameter of a function:func F(ctx context.Context, ...). - The
cancelreturned byWithCancel/WithTimeoutmust be called (usuallydefer cancel()), otherwise resources leak. - Don’t store
ctxin 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.Gotriggers the cascading cancellation; the task itself must listen forctx.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
goand 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
| Need | Use |
|---|---|
| Multiple parties read/write the same state | a lock (Mutex); use RWMutex for read-heavy; atomic for a single value |
| Initialize only once | sync.Once / sync.OnceValue |
| Data flowing between goroutines | channel (producer-consumer) |
| Broadcast one message to everyone | Pub/Sub Broker |
| One task goes to one worker | fan-out / worker pool |
| Exclusive state, want to avoid locks | Actor (exclusive goroutine + command channel) |
| Wait on multiple events (cancel/timeout/multiplex) | select |
| Process a data stream in stages | pipeline |
| Add controlled parallelism to a slow stage | worker pool / errgroup.SetLimit |
| Cancellation, timeout, leak prevention | context |
| Launch a batch, wait for all, cascade on failure | errgroup |
| Concurrent requests for the same resource compute once | singleflight |
| Control the processing rate | rate.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.