Go concurrency
Unbuffered vs buffered channels
An unbuffered channel is a handoff. A send blocks until another goroutine is ready to receive, and the receive blocks until someone sends, so the two goroutines meet at that line. That synchronization is the point, not a side effect.
ch := make(chan int) // unbuffered: send waits for a receiver
ch := make(chan int, 2) // buffered: two sends fit before anyone reads
A buffered channel holds up to its capacity. Sends block only when the buffer is full, receives block only when it is empty. So the buffer decouples the sender and receiver by that many items.
Wrong "a buffered channel is just a faster unbuffered channel." It changes the synchronization semantics, not the speed. With a buffer, a send can complete before any receiver exists, which is exactly what you do not want when you were relying on the handoff to order two goroutines.
The closed and nil channel rules
This matrix is the most-tested thing in the topic, so learn it cold:
- Receive from a closed channel: returns the zero value with
ok == false, immediately. It never blocks and never panics. Buffered values drain first, then you get zeros. - Send to a closed channel: panics.
- Close a closed channel, or close a nil channel: panics.
- Send or receive on a nil channel: blocks forever.
The comma-ok form is how you tell a real value from a closed channel:
v, ok := <-ch // ok == false means the channel is closed and drained
for v := range ch uses the same signal under the hood: it reads until the channel is closed and empty, then exits. Only the sender should close, and only once, because close is a broadcast that every receiver observes.
Bugs this topic is really testing
Three failures come up again and again. A deadlock from an unbuffered send that no goroutine will ever receive (the runtime detects the all-goroutines-blocked case and aborts). A send on a channel you already closed, which panics in production, not at compile time. And the loop-variable capture bug:
for _, v := range items {
go func() { fmt.Println(v) }() // pre-1.22: all print the last item
}
Go 1.22 gave each iteration its own v, so this is fixed for modules on go 1.22+. Interviewers still ask it because the mechanism (a closure capturing one shared variable) is the real lesson, and passing v as an argument reads clearly regardless of Go version.
Why goroutines are cheap, and where they stop being cheap
A goroutine starts with about a 2KB stack that grows and shrinks by copying, against roughly 1MB for an OS thread. Creation costs a couple hundred nanoseconds, not the tens of microseconds a thread costs. The reason is that the runtime multiplexes many goroutines onto a small set of OS threads and schedules them in user space, so a switch avoids the kernel (the M:N machinery belongs to go-runtime). Spawning a hundred thousand is normal.
Cheap is not free. Every goroutine parked on a channel is a live root: it and everything it captured stay pinned until it unblocks or the program exits. A blocking syscall parks its whole OS thread, and GOMAXPROCS caps how many run in parallel. Unbounded spawning, one goroutine per request with no limit, becomes memory pressure and scheduler churn. That is why worker pools exist.
How goroutines leak
A leak is a goroutine blocked on a channel operation that will never complete. The canonical shape: a worker sends its result, but the caller already returned on a timeout, so nobody receives.
func fetch(ctx context.Context) (int, error) {
ch := make(chan int, 1) // buffered so the send can't block after we return
go func() { ch <- expensive() }()
select {
case v := <-ch:
return v, nil
case <-ctx.Done():
return 0, ctx.Err() // worker's send lands in the buffer, no leak
}
}
An unbuffered ch here leaks the worker on every timeout. The two standard fixes: a size-1 result buffer so the send always has somewhere to go, or plumbing ctx into the worker so it can abandon its own send. context is the propagation idiom. Pass it as the first argument down the call chain, select on ctx.Done(), and always defer cancel() so you do not leak the context's timer.
Wrong "a leaked goroutine gets garbage-collected once nothing references it." It references itself by being runnable-in-waiting; the GC treats a parked goroutine as reachable. Leaks accumulate silently until you read a goroutine profile.
Channels or a mutex
Two more senior reflexes. A nil channel blocks forever, which reads like a bug but is a tool: setting a select case's channel to nil disables that case, letting one loop turn inputs on and off. And time.After inside a looping select allocates a timer that lives until it fires, so a hot loop leaks timers; use time.NewTimer with Stop().
The channel-versus-mutex question separates bands. Channels transfer ownership of data and signal between goroutines. A plain sync.Mutex around a struct field is simpler and faster when you are guarding shared state, like a counter or a cache. Reaching for a channel to protect a single integer is the tell that someone learned "share memory by communicating" without learning when it applies. What gets graded: reciting the closed/nil matrix without hesitation, spotting the leak in a producer/consumer sketch, and defending each primitive against the concrete case rather than a maxim.
beta
The interviewer part is in the works.
The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.