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.

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
0
1
2
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