| name | go-concurrency |
| description | Idiomatic Go concurrency - sync primitives, channel semantics, the select statement, and the standard channel patterns (cancellation/done-channel, fan-out/fan-in, pipeline, or-done, context, worker-pool). Use when writing or reviewing Go that spawns goroutines, shares state, or coordinates over channels, even if the user never says "concurrency" - e.g. data races, deadlocks, goroutine leaks, sync/context/select usage, or "make this parallel". Distilled from luk4z7/go-concurrency-guide.
|
| license | Apache-2.0 |
Go Concurrency
A quick-reference for writing and reviewing concurrent Go. Reach for the table
that fits the problem, copy the nearest pattern, then check it against the
gotchas. Verify everything with go test -race.
Don't communicate by sharing memory; share memory by communicating.
Use channels to pass ownership of data, distribute work, and signal.
Use a mutex for in-place shared state (caches, counters, registries).
Pick your tool
| Need | Use |
|---|
| Hand off / stream values between goroutines | chan |
| Protect shared state in place | sync.Mutex / sync.RWMutex (read-heavy) |
| Wait for N goroutines to finish | sync.WaitGroup |
| Run exactly once (lazy init) | sync.Once |
| Reuse expensive allocations, cut GC pressure | sync.Pool |
| Wake goroutines on a condition | sync.Cond (rare; a channel is usually clearer) |
| Lock-free counters / flags | sync/atomic |
| Cancellation, deadlines, request-scoped values | context.Context |
Always defer mu.Unlock() right after mu.Lock() so a panic can't leave the
lock held.
Channel states
Behaviour is defined by the operation × the channel's state. Memorize this:
| Operation | nil channel | open channel | closed channel |
|---|
receive v := <-c | blocks forever | a value (or blocks if empty) | zero value, ok==false (drains buffer first) |
send c <- v | blocks forever | sends (or blocks if full) | panic |
close(c) | panic | closes it | panic |
- Unbuffered (
make(chan T)): send and receive rendezvous - each blocks
until the other is ready. This is the synchronization.
- Buffered (
make(chan T, n)): send blocks only when full, receive only
when empty. A buffer decouples timing; it does not remove backpressure.
- Ownership rule: exactly one goroutine owns a channel - it creates, writes,
and
closes it, then hands consumers a receive-only <-chan T. This makes the
panics above structurally impossible. Receivers never close.
range c reads until the channel is closed; v, ok := <-c detects closure.
select idioms
select blocks until one case is ready; if several are ready it picks one
pseudo-randomly (so no case can starve another). nil channels are never
ready - set a channel to nil to disable its case.
select {
case v := <-c:
use(v)
case <-time.After(time.Second):
return errTimeout
}
select {
case v := <-c:
use(v)
default:
}
select {
case out <- v:
case <-done:
return
}
Gotchas
- Data race: concurrent read+write of the same variable with no
synchronization. Undefined behaviour. Catch it with
go test -race / go run -race.
- Goroutine leak: a goroutine blocked on a channel that will never proceed.
Every goroutine you start needs a guaranteed exit - a
done/ctx signal or a
channel that will close. Leaks accumulate silently.
- Deadlock: all goroutines blocked. Causes: acquiring locks in inconsistent
order (always lock in the same order), or send/receive on a
nil channel, or
an unbuffered send with no receiver. fatal error: all goroutines are asleep.
- Send on closed channel / double close: panics. Follows from the ownership
rule - one owner closes, once.
- Loop-variable capture: pre-Go 1.22,
for _, v := range xs { go f(v) }
shared one v; pass it as an argument. Go 1.22+ gives each iteration its own
copy. Know which Go you target.
- Livelock / starvation: goroutines run but make no progress (livelock), or a
greedy lock-holder keeps others from ever running (starvation). Usually a sign
to add backoff or rebalance critical-section size.
Signature patterns
Short, copy-ready skeletons. They use any (= interface{}) and assume Go 1.22+
loop semantics.
Done-channel cancellation - pass a read-only done; close it once to stop
all listeners. Send results under the done guard so a cancelled consumer
can't wedge the producer.
func worker(done <-chan struct{}, in <-chan string) <-chan string {
out := make(chan string)
go func() {
defer close(out)
for s := range in {
select {
case out <- transform(s):
case <-done:
return
}
}
}()
return out
}
done := make(chan struct{})
defer close(done)
Fan-out / fan-in - many workers read one input channel (distribute load),
one channel merges their results. No value is processed twice.
func fanIn(done <-chan struct{}, cs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(cs))
for _, c := range cs {
go func(c <-chan int) {
defer wg.Done()
for v := range c {
select {
case out <- v:
case <-done:
return
}
}
}(c)
}
go func() { wg.Wait(); close(out) }()
return out
}
Pipeline - compose stages, each a goroutine that reads one channel and
returns the next. Every stage closes the channel it owns, so closure propagates
downstream and range terminates cleanly.
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
for n := range sq(sq(gen(2, 3))) {
fmt.Println(n)
}
Or-done - range over a channel without leaking when you might quit early.
Wrap the channel; the loop body stays clean.
func orDone(done, c <-chan any) <-chan any {
out := make(chan any)
go func() {
defer close(out)
for {
select {
case <-done:
return
case v, ok := <-c:
if !ok {
return
}
select {
case out <- v:
case <-done:
}
}
}
}()
return out
}
for v := range orDone(done, stream) {
use(v)
}
Context - the standard cancellation/deadline carrier. Take ctx as the
first argument, always defer cancel(), and honor ctx.Done() in your loops.
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
select {
case out <- v:
case <-ctx.Done():
return ctx.Err()
}
Queuing / semaphore - a buffered channel bounds how many goroutines run at
once. Acquire before launching, release on exit.
sem := make(chan struct{}, runtime.NumCPU())
var wg sync.WaitGroup
for _, job := range jobs {
sem <- struct{}{}
wg.Add(1)
go func(job Job) {
defer wg.Done()
defer func() { <-sem }()
process(job)
}(job)
}
wg.Wait()
Lower-frequency patterns from the source, when you need them: or-channel
(combine many done signals into one), tee (duplicate one stream into two),
bridge (flatten a channel-of-channels), heartbeat (periodic liveness
signal for monitoring), replicated requests (fan a request to N handlers,
take the first reply, cancel the rest).
Scheduler, briefly
Go multiplexes goroutines onto OS threads with an M:N work-stealing
scheduler: G = goroutine, M = OS thread, P = processor (a run
context). GOMAXPROCS (default runtime.NumCPU()) caps how many Ps run Go code
at once; an idle P steals goroutines from a busy P's queue. Goroutines are cheap
- ~2 KB initial stacks that grow on demand and nanosecond-scale switches - so
thousands are fine where threads would not be. You rarely manage any of this
directly; you make goroutines cancellable and let the runtime schedule them.
None of it makes a racy program correct - run
-race.
Distilled from luk4z7/go-concurrency-guide,
which draws on "Concurrency in Go" (Katherine Cox-Buday) and "The Go Programming Language".