Go channels: the hchan struct and lock-based design
- Last updated
- Prerequisites:
- Go goroutines and channels basics
- Understanding of mutexes and condition variables
- go
- channels
- hchan
- concurrency
- synchronization
- runtime
Read at your depth
The practical view
ch := make(chan int, 3) creates a buffered channel. Sends and receives block when the buffer is full/empty respectively; unbuffered channels (make(chan int)) block until the other side is ready, making them synchronization points. close(ch) signals 'no more sends'; ranging (for v := range ch) drains until close. The primary use cases: hand-off synchronization between goroutines, work queues (buffered channels + workers), and graceful shutdown signals (close(ch) as a broadcast). select multiplexes over multiple channels: select { case v := <-ch1: ...; case ch2 <- v: ...; default: ... }.
Legacy vs modern
Unbuffered handoff vs buffered work queue
Unbuffered channels synchronize two goroutines with a rendezvous; buffered channels decouple producers from workers and need explicit close for completion.
done := make(chan struct{}) // ❌ OLD: unbuffered → rendezvous, main blocks on send
go func() { work(); done <- struct{}{} }()
<-done // blocks until the goroutine sendsjobs := make(chan int, 10) // ✅ NEW: buffered(10) + close(jobs) broadcasts completion
// N workers; close(jobs) broadcasts completion, workers drainInterview gotchas
Context
This distinguishes candidates who assume channels are high-performance lock-free queues from those who know the runtime's actual design and its implications.
The mechanical answer
Every hchan operation takes the channel's mutex (hchan.lock) — sends, receives, and close all serialize on it. So a hot buffered channel is contended the way a mutex-protected queue is: throughput is bounded by lock hold time and cache-line bouncing, not by lock-free atomics. The runtime chose this for correctness and simplicity: FIFO order is guaranteed by the wait queues, memory ordering is handled by the lock, and the ring buffer keeps buffer management trivial. The practical implication: for very high-throughput work distribution, a hand-rolled sharded queue or Go 1.22+ iterators/slice-based pools can beat a single channel; channels should be used for clarity and correctness, with the lock cost accepted.
Trap
The trap answer is 'channels are implemented with atomics, so they are super fast'. The hchan uses a full mutex; only select's randomization and some fast paths avoid locking. Another trap: 'buffered channels are async, so they never block' — they block when the buffer is full (sends) or empty (receives). Candidates who mention hchan's lock field by name and the sudog wait queues demonstrate real runtime knowledge.
Context
This is the canonical channel-semantics question — the interviewer wants the close contract and its interaction with range and receive, precisely.
The mechanical answer
Sending on a closed channel panics — the runtime checks hchan.closed under the channel's lock before enqueuing. Receiving from a closed channel returns immediately with the zero value (and ok=false with the comma-ok form), so a drain loop must not mistake that for a live value. Range (for v := range ch) keeps receiving until the channel is closed, then exits — that is why close(ch) is the broadcast 'no more values' signal for fan-out workers. Double-closing panics. The rule of thumb: only the sender should close, and close should be deferred or placed where all sends are guaranteed done — closing from a receiver races sends and can panic.
Trap
The naive answer is 'receiving from a closed channel panics'. It does not — receiving is safe (zero value), sending panics. Another trap: 'closing a channel is like a boolean flag, so anyone can do it' — concurrent closers race and the second close panics. Candidates who state the sender-closes contract and the zero-value drain pitfall precisely show they have actually written concurrent Go.