Go, Together: A Visual Guide to Go Concurrency

Go, Together: mutex, semaphore, channel, select

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.

Exclusive access

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.

Experiment 01 / Shared counter
Speed
G₁
goroutine A
0shared count
G₂
goroutine B
Mutex is unlocked
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.
Bounded access

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.

Experiment 02 / Worker limit
Speed
3/3
permits free
J1
J2
J3
J4
J5
J6
All permits available
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/semaphore supports weighted permits.
Typed communication

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.

Experiment 03 / Buffered pipeline
Speed
SEND
producer
CHANNEL CLOSED
RECV
consumer
Buffer is empty
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.
Channel groups

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.

Experiment 04 / Fan-in + timeout
Speed
apiResults
events
timeout
select
{ ... }
Waiting on 3 channels
select {
case result := <-apiResults:
    handle(result)
case event := <-events:
    dispatch(event)
case <-time.After(2 * time.Second):
    return errors.New("timeout")
}

Why “channel groups”?

  • select composes many possible channel operations.
  • If several are ready, Go chooses one pseudo-randomly.
  • A default case makes the operation non-blocking.

Choose by intent.

Do not ask “which primitive is best?” Ask “what relationship am I modeling?”

ConceptModelsBest forWatch out for
MutexOwnershipProtecting shared stateDeadlocks, long critical sections
SemaphoreCapacityBounding concurrent workLeaked permits, starvation
ChannelCommunicationPassing values and ownershipBlocked sends, unclear closing
SelectChoiceMultiplexing, cancellation, timeoutsBusy 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.

Fix: 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.

Fix: use pointer receivers; run 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 with defer, 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.

Fix: 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.

Fix: wait on a real signal - 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.

Fix: drop 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 on ctx.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.

Fix: know which one you meant; 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.