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 |