Goroutines and channels, the deep dive
Goroutines are cheap. Channels are how they talk. Everything else in Go's concurrency story is built on these two primitives. This lesson goes deep on both because every senior Go interview goes deep on both.
The interviewer is not testing whether you know go func(). They are testing whether you know when NOT to use it, when channels are the right answer vs a sync.Mutex, and how to design a worker pool that does not leak goroutines.
Goroutines: cheap, but not free
A goroutine starts at around 2 KB of stack and grows on demand. The runtime multiplexes thousands of them onto a small pool of OS threads (the GOMAXPROCS number, usually equal to your CPU count).
go func() {
fmt.Println("running in a goroutine")
}()Senior rule: never launch a goroutine you do not know how to stop. Every
go func()should either complete on its own, be bounded by a channel, or accept acontext.Contextfor cancellation. Leaked goroutines are the most common Go memory leak.
The classic leak:
func work() {
ch := make(chan int) // unbuffered
go func() {
ch <- 42 // blocks forever if no receiver
}()
// function returns, goroutine is stuck
}The goroutine is now alive forever, holding its 2 KB of stack and whatever else it captured. Multiply by thousands of requests and you have an outage.
Unbuffered channels: synchronisation primitives
ch := make(chan int) // capacity 0 = unbufferedAn unbuffered channel is a rendezvous. The sender blocks until a receiver is ready. The receiver blocks until a sender shows up. They meet in the middle, the value is handed over, both continue.
Mental model: a courier handing a package directly to the recipient. No drop box.
done := make(chan struct{})
go func() {
doWork()
done <- struct{}{} // I am done
}()
<-done // wait until the goroutine signalsThis is the cleanest "wait for one goroutine" pattern in the standard library style. The empty struct is the conventional zero-byte signal.
Buffered channels: bounded queues
ch := make(chan int, 10) // capacity 10The sender only blocks when the buffer is full. The receiver only blocks when it is empty. This is your bounded queue.
Interview trap: when asked "when should I use a buffered channel?", the wrong answer is "for performance". The right answer is "when I have a known bound on in-flight work, like a worker pool's job queue".
Performance is a side effect, not the goal. If you size the buffer wrong, you either get the same blocking behaviour (too small) or you mask backpressure problems (too large).
select: the heart of Go concurrency
select lets one goroutine wait on multiple channel operations at once.
select {
case msg := <-ch1:
fmt.Println("got from ch1:", msg)
case msg := <-ch2:
fmt.Println("got from ch2:", msg)
case <-time.After(time.Second):
fmt.Println("timeout")
default:
fmt.Println("nothing ready, not blocking")
}- If multiple cases are ready, one is picked at random (this is a deliberate design choice to prevent starvation).
defaultmakes the select non-blocking.- The
time.Aftercase is the canonical timeout pattern, but for anything serious usecontext.Contextinstead (next lesson).
Pattern 1: Fan-in
Many goroutines produce results, one channel consumes them.
func fanIn(chs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, c := range chs {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(c)
}
go func() {
wg.Wait()
close(out)
}()
return out
}The WaitGroup ensures we close out only after every producer has finished. Forgetting to close means downstream code waits forever.
Pattern 2: Fan-out / worker pool
One channel feeds N worker goroutines, each does the work, results go to an output channel. This is the most-asked Go concurrency interview question.
func workerPool(jobs <-chan int, results chan<- int, n int) {
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
results <- doWork(j)
}
}()
}
wg.Wait()
close(results)
}The directional channel types (<-chan int for receive-only, chan<- int for send-only) are not just decoration. They are the compiler enforcing that workers cannot accidentally write to the job queue or read from the result queue.
Pattern 3: Cancellation via close
Closing a channel is broadcast: every goroutine reading from it receives the zero value AND a false from the ok ack.
done := make(chan struct{})
// ... many goroutines doing: select { case <-done: return; case ... }
close(done) // every reader unblocksThis is the pre-context way of cancelling a fan-out. The next lesson on context packages this up with deadlines and propagation, which is what you actually use in production. But understand close(chan) first because context is built on it.
Concurrency primitives
- goroutine
- Lightweight thread of execution managed by the Go runtime. Starts at ~2 KB stack, grows on demand.
- channel
- Typed pipe between goroutines. Unbuffered = synchronous handoff. Buffered = bounded queue.
- select
- Block waiting on any of N channel operations. Picks a ready case at random if more than one is ready.
- GOMAXPROCS
- Max OS threads the Go runtime will use to run goroutines. Defaults to runtime.NumCPU().
- leaked goroutine
- A goroutine that the program never asks to stop. Lives forever, holding memory it captured.
What we will cover next
The next lesson is context.Context. It is what you actually wrap around every request, every database call, every outbound HTTP call in production Go. After that: the sync primitives (Mutex, WaitGroup, atomic) and when each beats channels.
Senior rule: channels for communicating between goroutines, mutexes for protecting shared state. Both are correct in different situations. The "Don't communicate by sharing memory; share memory by communicating" proverb is a guideline, not a religion.
Common questions
Q.When should I use a channel vs a sync.Mutex?›
Q.What is a goroutine leak?›
Watching quietly. Tap me if you want a tip.
Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.
Try this (0 of 2 done)
- 1
Predict the output (one number per line).
show answer
// already in the editor - 2
What happens if you remove the close(ch) call? (Predict the runtime error name.)
hint
Range over a channel waits for more values until the channel is closed.
show answer
// fatal error: all goroutines are asleep - deadlock!