Go, Together: A Visual Guide to Go Concurrency
Concurrency becomes easier when you can see it. Run these small models to watch goroutines compete for shared state, wait for permits, pass values through channels, and react to whichever operation becomes ready first.
One at a time.
A mutex protects shared state. A goroutine calls Lock, changes the data, then calls Unlock. Everyone else waits outside the critical section.
var mu sync.Mutex count := 0 func increment() { mu.Lock() // enter count++ // protected mu.Unlock() // leave }
Use it when
- Multiple goroutines mutate the same value.
- The protected operation must be atomic.
- Keep the critical section short. Never forget
Unlock.
A few at a time.
A semaphore is a counter of permits. Here, only three jobs may run at once. It limits pressure on databases, APIs, CPUs, or any scarce resource.
sem := make(chan struct{}, 3) for _, job := range jobs { sem <- struct{}{} // acquire go func() { defer func() { <-sem }() // release work(job) }() }
Use it when
- You need concurrency, but not unlimited concurrency.
- Capacity means how many tasks may enter together.
- In larger projects,
x/sync/semaphoresupports weighted permits.
Pass the value.
Channels move typed values between goroutines. An unbuffered channel is a rendezvous. A buffered channel can hold values until a receiver is ready.
jobs := make(chan int, 3) go func() { for i := 1; i <= 5; i++ { jobs <- i // send } close(jobs) // no more sends }() for job := range jobs { work(job) }
Remember
- Send and receive may block. That is coordination, not a bug.
- Only the sender should close a channel.
- Receiving from a closed channel returns buffered values, then zero values.
Whichever is ready.
select waits on a group of channel operations. When one is ready, its case runs. Add a timer or context channel to make waits cancellable.
{ ... }
select { case result := <-apiResults: handle(result) case event := <-events: dispatch(event) case <-time.After(2 * time.Second): return errors.New("timeout") }
Why “channel groups”?
selectcomposes many possible channel operations.- If several are ready, Go chooses one pseudo-randomly.
- A
defaultcase makes the operation non-blocking.
Choose by intent.
Do not ask “which primitive is best?” Ask “what relationship am I modeling?”
| Concept | Models | Best for | Watch out for |
|---|---|---|---|
| Mutex | Ownership | Protecting shared state | Deadlocks, long critical sections |
| Semaphore | Capacity | Bounding concurrent work | Leaked permits, starvation |
| Channel | Communication | Passing values and ownership | Blocked sends, unclear closing |
| Select | Choice | Multiplexing, cancellation, timeouts | Busy loops with default |
Where it usually breaks.
Almost every concurrency bug I have shipped is on this list.
An Unlock that never runs
An early return, an error branch or a panic between Lock and Unlock leaves the mutex held forever, and every other goroutine parks behind it.
defer mu.Unlock() on the line after Lock.
Copying a value that contains a lock
Passing a struct with a sync.Mutex by value gives the copy its own lock, so two goroutines happily enter the same critical section.
go vet.
Goroutines that never finish
A goroutine sends into a channel nobody reads any more - the caller returned early or timed out. It blocks forever and its memory stays alive.
Fix: give every goroutine an exit:ctx.Done(), a closed channel, or a buffered slot.
Closing from the wrong side
Receivers closing a channel, or two senders closing it, panics with close of closed channel. A send on a closed channel panics too.
Fix: one owner - the sender - closes, once.Permits that leak
Acquiring a semaphore slot and releasing it only on the happy path slowly burns capacity until the pool is exhausted and everything stalls.
Fix: release withdefer, in the same function that acquired.
wg.Add inside the goroutine
If Add(1) runs inside the goroutine, Wait may observe a zero counter and return before any work started.
Add before go, defer wg.Done() inside.
Sleep used as synchronisation
time.Sleep(100 * time.Millisecond) to "let the goroutine finish" passes on your laptop and fails on a loaded CI machine.
WaitGroup, channel, or errgroup.
select with a hot default
default makes the operation non-blocking, so a select in a for loop spins at 100% CPU while it waits for nothing.
default, or add a ticker/timeout case.
Ignoring cancellation
The caller gave up two seconds ago, but the worker keeps querying the database. Context is passed down and then never checked.
Fix: select onctx.Done() and return ctx.Err().
Locks taken in different orders
One path locks A then B, another locks B then A. Under load they meet in the middle and both wait forever.
Fix: define one global lock order and keep critical sections short.A nil channel in a select
Send and receive on a nil channel block forever. Usually an accidental deadlock - occasionally a deliberate way to disable a case.
make the channel before use.
Trusting tests without -race
Data races are invisible until they corrupt something in production. A green test suite proves nothing if the detector never ran.
Fix:go test -race ./... in CI.