Includes solved 02-worker-pool and 09-safe-counter (mutex, atomic, sharded). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GbnJj9ZqEx9nUvzkjdwfMq
10 — Gotchas: "What Does This Print?"
The trivia round. Each subfolder is a tiny program. The drill:
- Open
main.go, predict the exact output (or panic/deadlock/compile error). go run ./10-gotchas/exNN-.../and check yourself.- Read the answer below — then explain it OUT LOUD in one breath, because that's the actual interview skill.
Note: ex11 intentionally does not compile, and ex02 intentionally deadlocks —
that IS the exercise. (This folder is excluded from go build ./... sanity by
being all main packages; run them individually.)
Answers (no peeking before predicting)
ex01 — loop variable capture
Go ≥1.22: 0 1 2 in some order — the for statement gives each iteration a
fresh i (spec change in 1.22, one of the very few semantic changes ever made
to Go). Pre-1.22: typically 3 3 3 — all closures captured the same
variable, which was 3 by the time they ran. Old fixes: go func(i int){...}(i)
or shadowing i := i. Always mention the version boundary; it proves your
knowledge has a timestamp.
ex02 — unbuffered send deadlock
fatal error: all goroutines are asleep - deadlock! An unbuffered send blocks
until a receiver is ready; there is no other goroutine. Fix A:
make(chan int, 1). Fix B: do the send or receive in another goroutine.
Key nuance: the runtime detects only global deadlock (every goroutine
blocked). One stuck goroutine among many = silent leak, no error, ever.
ex03 — closed channel semantics
Prints 1, then 0 false, then panics: close of closed channel.
Buffered values survive close and drain normally; after drain, receive returns
zero value + ok=false immediately (never blocks). Send on closed also
panics. Recite the rules: only the sender closes; with multiple senders,
nobody closes directly (coordinate via WaitGroup); closing is signaling, not
cleanup — unclosed channels are GC'd fine.
ex04 — nil channel in select
b 42. Operations on nil channels block forever, so a nil case is simply
never ready — select ignores it. If all cases were nil (and no default):
deadlock. This is a load-bearing idiom: set a channel var to nil to disable
its case dynamically (see exercise 07's README for it powering a fan-in).
ex05 — slice aliasing via append
[1 2 99 4] and [1 2 3 4]. First: a[:2] has len 2, cap 4 — append
fits within cap, writes into the shared backing array, clobbers a[2].
Second: full slice expression a[:2:2] caps the cap at 2 — append must
reallocate, a untouched. Corollaries: this is why append returns a value;
why functions receiving slices can visibly mutate the caller's data sometimes
(only when no realloc happened — the worst kind of bug); and what
s[low:high:max] is actually for.
ex06 — defer + named returns
6 then 5; then deferred: 2, 1, 0 at main's exit, LIFO. In f,
return 5 assigns to the named return x, then defers run and mutate it →
6. In g, return x copies into an anonymous slot first → the defer's
increment is invisible. Also hiding here: defer fmt.Println("deferred:", i)
evaluates the argument i at defer time — so it's the deferred closures
that capture, plain calls snapshot. Practical use of the f-pattern: wrapping
errors on the way out via defer func() { err = ... }() with a named err.
ex07 — typed nil interface
Prints got error: boom — the err != nil branch runs. An interface value is
a (type, value) pair; here it's (*MyErr, nil) — a non-nil interface wrapping
a nil pointer. Interface == nil only when BOTH halves are nil. (The method
call then works because Error() never dereferences its nil receiver — nil
receivers are legal in Go.) Fix: return literal nil, or declare
var err error and assign. If you can whiteboard the two-word pair, you own
this question.
ex08 — concurrent map writes
Very likely dies with fatal error: concurrent map writes — and recovered:
never prints, because it's a runtime fatal error (throw), not a panic:
recover() cannot catch it, the process is gone. (Deliberate design: a
corrupted map is unrecoverable state.) Fixes: mutex, sync.Map (append-mostly
workloads only), or sharding. -race flags it deterministically even on runs
that happen not to crash.
ex09 — WaitGroup Add placement
Race: main can reach wg.Wait() while the counter is still 0 (no goroutine
has run Add yet) → Wait returns instantly, prints count: 0..3 — any value.
Worse interleavings hit "WaitGroup misuse: Add called concurrently with Wait".
Rule: Add in the spawning goroutine, before go. Plus count++ is itself
a data race (-race flags both). And always pass *sync.WaitGroup — copying
one with nonzero state breaks the count (go vet catches the copy).
ex10 — goroutine leak on timeout
Prints ~101 goroutines instead of 1. On timeout, nobody ever receives from the
unbuffered ch; the worker goroutine blocks on ch <- "body" forever. At
100 rps against a slow upstream that's OOM with a countdown. The
one-character-ish fix: make(chan string, 1) — the send always succeeds, the
orphaned result gets GC'd with the channel. The structural fix: pass ctx into
the work and select the send against ctx.Done(). Detection in prod:
go_goroutines metric trending up + pprof goroutine profile showing an army
parked in chan send at one line.
ex11 — method sets
Compile error: T does not implement Stringer (method String has pointer receiver). Method set of T = value-receiver methods only; method set of
*T = both. Fix: var s Stringer = &T{}. The why: interface values can
hold unaddressable copies (map elements, temporaries), so the compiler can't
conjure a *T from a T for you. Related asymmetry people forget: calling
t.String() directly on an addressable t works fine — the compiler inserts
(&t) — it's only interface satisfaction that's strict.