Go's concurrency model is one of the language's defining features, yet it's also one of the most misunderstood. Developers coming from thread-based languages tend to apply the wrong mental model and end up with code that's either too conservative or subtly broken.
The Cost of a Thread
In most operating systems, a thread costs around 1–8 MB of stack space at creation. The kernel scheduler manages threads, which means context switches involve a full ring transition. At a few hundred concurrent threads, you're already burning significant memory and scheduler overhead.
A goroutine starts at 2–8 KB of stack, managed by Go's cooperative scheduler in user space. The runtime multiplexes goroutines onto OS threads (M:N threading). Stack frames grow dynamically — they're not pre-allocated.
// Spawning a million goroutines is practical.
// Spawning a million threads is not.
for i := range 1_000_000 {
go func(n int) {
time.Sleep(time.Second)
}(i)
}Channels Are Not Just Queues
The standard framing — "channels are thread-safe queues" — misses the point. Channels enforce a communication discipline: the sender blocks until the receiver is ready (unbuffered), or until there's buffer space (buffered). This synchronization property is what makes channels useful for coordination, not just data transfer.
func pipeline(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
out <- v * v
}
}()
return out
}The defer close(out) is critical. Forgetting to close a channel leaves the downstream goroutine blocked forever — a goroutine leak.
Select: Non-Deterministic Multiplexing
select lets a goroutine wait on multiple channels. When multiple cases are ready simultaneously, Go picks one at random. This is intentional — it prevents starvation.
func fanIn(a, b <-chan string) <-chan string {
merged := make(chan string)
go func() {
defer close(merged)
for {
select {
case v, ok := <-a:
if !ok { a = nil; continue }
merged <- v
case v, ok := <-b:
if !ok { b = nil; continue }
merged <- v
}
if a == nil && b == nil { return }
}
}()
return merged
}Setting an exhausted channel to nil removes it from future select evaluation — a clean pattern for draining multiple inputs.
When Channels Are Wrong
Channels add overhead: each send/receive involves a goroutine park/unpark cycle. For tight loops where you're just protecting a counter, a sync/atomic operation or a sync.Mutex is faster and clearer.
// Don't do this
counter := make(chan int, 1)
counter <- 0
// Do this
var counter atomic.Int64
counter.Add(1)The Go proverb — "Do not communicate by sharing memory; instead, share memory by communicating" — is a design guideline, not a law. Channels model ownership transfer. If you're not transferring ownership, you probably don't need one.
The Scheduler's Preemption Model
Prior to Go 1.14, goroutines could only yield at function calls. A tight loop with no function calls would spin indefinitely, starving other goroutines. Since 1.14, the scheduler uses signal-based preemption — it sends SIGURG to a thread, which forces a preemption point anywhere in the code.
This matters for latency-sensitive systems: without preemption, a garbage collection cycle or a compute-heavy goroutine could cause multi-millisecond pauses for other goroutines on the same thread.
Practical Rules
- Own your goroutines. Every goroutine should have a clear lifetime and a path to termination — use
context.Contextfor cancellation. - Close channels from the sender. Only the goroutine that writes to a channel should close it.
- Prefer
errgroupover rawsync.WaitGroupwhen you need to collect errors from concurrent work. - Profile before optimizing.
go tool pprofand the goroutine stacktrace dump (kill -SIGQUIT) are your first tools when something looks stuck.
The concurrency model is simple. What's hard is applying it with the same discipline you'd apply to any stateful system design.