Go interviewer course · Lesson 0002

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.

Level · simple → intermediate 10 runnable programs Go · 1.25+ Focus · interview reasoning

How to use this lesson

Predict → run → explain → change

1Predict before running
2Run the exact program
3Explain its stop condition
4Try the small 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

goroutineStarts concurrent work. It does not automatically wait, return a value, or stop with its caller.
WaitGroupJoins a known set of tasks. Use it when completion—not a returned value—is the event you need.
channelSends a value or signal between goroutines. The sender normally owns closing it.
selectWaits until one communication can proceed. Useful for timeouts and cancellation.
mutexMakes access to a shared invariant exclusive. Every access must follow the same lock rule.

The question to ask for every goroutine

What starts it?
What can block it?
What stops it?
Who observes completion?

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.

01

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.

Runnable source · WaitGroup docs

02

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.

Runnable source · Go statement spec

03

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.

Runnable source · Channel type spec

04

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.

Runnable source · Channel type spec

05

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.

Runnable source · Built-in close spec

06

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.

Runnable source · Go blog — pipelines

07

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.

Runnable source · Select statement spec

08

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.

Runnable source · Go blog — fan-out and fan-in

09

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.

Runnable source · Mutex docs

10

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.

Runnable source · RWMutex docs

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.

Your tangible win

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.