Your outcome
Read less.
Run ten tiny programs.
Build a practical feel for goroutines, channels, and mutexes in about 30 minutes. Before running each example, predict its output and identify what makes the program finish safely.
How to use this lesson
Predict → run → explain → change
go run ./examples/concurrency/01-first-goroutine
go run -race ./examples/concurrency/09-mutex-counter
Run commands from the workspace root. The race detector reports only races on code paths that execute.
One mental model
Separate work, communication, and protection
The question to ask for every goroutine
If one box has no answer, the design may exit too early, deadlock, leak a goroutine, or race.
Authority: Go specification — go statements · Go memory model
The practice ladder
Ten examples, one new idea each
Open each answer only after you make a prediction. Output from genuinely concurrent tasks may appear in a different order on different runs.
One goroutine, one join
Why is Wait required? Which line must print last?
var wg sync.WaitGroup
wg.Go(func() {
fmt.Println("hello from a goroutine")
})
wg.Wait()
fmt.Println("main is finished")
Reveal explanation
Wait blocks main until the registered function returns, so main is finished prints last. Without the wait, the process may exit before the goroutine prints.
Try: remove wg.Wait() and run repeatedly. Timing is not a correctness rule.
Several goroutines
Must tasks 1, 2, and 3 finish in that order? What does Wait guarantee?
for id := 1; id <= 3; id++ {
id := id
wg.Go(func() {
fmt.Printf("task %d finished\n", id)
})
}
wg.Wait()
fmt.Println("all tasks finished")
Reveal explanation
The three task lines can appear in any order. Wait guarantees that all registered functions return before the final line; it does not impose an order between workers. The inner id := id makes the captured value visibly private to each iteration.
Try: add a different short sleep to each task and predict the usual—not guaranteed—order.
Unbuffered handoff
At what point can the send complete?
messages := make(chan string)
go func() {
messages <- "work complete"
}()
message := <-messages
fmt.Println(message)
Reveal explanation
An unbuffered send can complete only when a receiver is ready. The send and matching receive form a handoff; the received value also communicates completion of everything sequenced before the send.
Try: remove the goroutine around the send. The only goroutine blocks before it can reach the receive, producing a deadlock.
A two-slot buffer
Why can one goroutine send twice before it receives?
queue := make(chan string, 2)
queue <- "first"
queue <- "second"
fmt.Println(<-queue)
fmt.Println(<-queue)
Reveal explanation
Each send occupies one free buffer slot, so both sends complete. Values are received in send order. A buffer changes when a send blocks; it does not remove synchronization or provide unlimited storage.
Try: change capacity from 2 to 1. The second send blocks because no other goroutine can receive.
Close, then range
Why does the loop stop after printing 1, 2, and 3?
go func() {
defer close(numbers)
for number := 1; number <= 3; number++ {
numbers <- number
}
}()
for number := range numbers {
fmt.Println(number)
}
Reveal explanation
The producer closes its output after its last send. A channel range receives values until the channel is closed and drained. Closing means “no more values”; it does not send a value.
Try: remove close(numbers). After receiving 3, the range waits forever for either another value or closure.
Directional pipeline
Which function owns each close, and what do the channel arrows prevent?
func produce(out chan<- int) {
defer close(out)
for n := 1; n <= 3; n++ { out <- n }
}
func square(in <-chan int, out chan<- int) {
defer close(out)
for n := range in { out <- n * n }
}
Reveal explanation
produce owns and closes numbers; square owns and closes squares. chan<- int permits sends only, while <-chan int permits receives only. These types make ownership mistakes harder to compile.
Try: receive from out inside produce; the compiler rejects it.
Select with timeout
What determines which branch executes?
select {
case message := <-response:
fmt.Println(message)
case <-time.After(100 * time.Millisecond):
fmt.Println("timed out")
}
Reveal explanation
select waits until a case can proceed. The example normally receives the response after 20 ms; if the timeout becomes ready first, it prints timed out. If several cases are ready together, one is chosen pseudo-randomly.
Try: make the worker sleep 200 ms. The response channel is buffered so the late sender can still finish instead of leaking on its send.
Two-worker pool
Why does a coordinator close results instead of either worker?
for id := 1; id <= 2; id++ {
id := id
workers.Go(func() {
worker(id, jobs, results)
})
}
go func() {
workers.Wait()
close(results)
}()
Reveal explanation
No single worker knows whether the other worker is still sending. The coordinator waits for both, then closes the shared output exactly once. Meanwhile, main drains results so workers do not block forever on their sends.
Try: change the worker count to 1 and then 4. The results remain correct, but ownership and ordering become visible.
Mutex-protected counter
Why must both the write and the read lock the same mutex?
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
Reveal explanation
The mutex protects the invariant “all access to value is serialized.” Locking only writers still races with an unlocked reader. Pointer receivers keep every method using the same counter and mutex rather than a copy.
Try: remove the locks and run go run -race ./examples/concurrency/09-mutex-counter.
RWMutex-protected map
Why do Set and Get use different locks?
func (s *Store) Set(key string, value int) {
s.mu.Lock()
defer s.mu.Unlock()
s.data[key] = value
}
func (s *Store) Get(key string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
value, ok := s.data[key]
return value, ok
}
Reveal explanation
A write lock excludes all other readers and writers. A read lock allows several readers together while still excluding writers. Use RWMutex only when that read concurrency is useful; a plain Mutex is often simpler.
Try: replace RWMutex, RLock, and RUnlock with Mutex, Lock, and Unlock. Correctness remains; the concurrency policy changes.
Retrieval practice
Explain these without looking back
1 · Lifetime
Starting a goroutine is easy. Name two different ways these examples make completion observable.
Check
WaitGroup.Wait joins tasks; receiving or ranging on a channel observes communication and closure.
2 · Ownership
When several workers send to one channel, why should none of them close it individually?
Check
A worker cannot know that every other sender is finished. A sender-side coordinator can wait for all senders and close exactly once.
3 · Protection
Why is locking only map writes insufficient when another goroutine reads the map?
Check
The read and write are still concurrent conflicting accesses. Every access must obey the same mutex policy.
If you can explain each program’s start, block, stop, and completion conditions, you can turn these examples into interview follow-ups instead of syntax trivia.
Primary reading
Continue with the official tour
Primary source
Work through A Tour of Go — Concurrency. It is the shortest official runnable path through goroutines, channels, select, and mutexes.
Quick reference
Keep the concurrency patterns sheet beside you while changing the examples. It compresses the tool choice, ownership, and failure rules onto one page.