TL;DR Link to heading
A Go channel is one primitive covering what other languages split across locks, condition variables, semaphores, and queues. That consolidation is the thread I kept pulling on while reading.
Motivation Link to heading
Channels come up in every Go pitch. I wanted to understand why.
What a channel actually is Link to heading
In most languages you get shared memory plus coordination tools: mutexes for exclusion, condition variables for waiting, semaphores for counting, queues for handoff. Synchronisation and data are separate problems with separate primitives.
A channel is a point where two goroutines meet: the sender hands a value to the receiver, and both continue. The synchronisation and the data transfer happen in the same step.
The memory model makes this explicit: a send happens-before the matching receive, so the receiver sees every write the sender did before the send. That one event covers exclusion, waiting, counting, handoff, and visibility.
Syntax in 30 seconds Link to heading
If Go isn’t your day-to-day language, skim the idioms below.
make(chan int)is unbuffered.make(chan int, N)has a buffer of N.ch <- vsends.v := <-chreceives. The two-value formv, ok := <-chsetsoktofalseonce the channel is closed and drained.chan<- Tis send-only,<-chan Tis receive-only. The arrow direction matches the data direction.for v := range chreads values until the channel closes.chan struct{}is a zero-byte signal channel. Use it when only the send or close matters and you don’t need to carry a value.close(ch)means “no more sends coming”. It isn’t a destructor.context.Contextis Go’s cancellation type.ctx.Done()returns a channel that closes when the context is cancelled, so you can drop<-ctx.Done()into aselect.
Four things that clicked Link to heading
select composes waits
Link to heading
With select, you wait on sends, receives, and timeouts at once, and the runtime picks whichever is ready first.
select {
case msg := <-incoming:
handle(msg)
case outgoing <- reply:
case <-ctx.Done():
return ctx.Err()
}
Mutex acquires don’t compose like this. You can’t say “lock whichever one comes free first” without wrapping things in goroutines yourself. select does it in one statement, and this was the bit that made the rest of the design make sense for me.
close as broadcast
Link to heading
Call close and every receiver unblocks at once.
done := make(chan struct{})
for i := 0; i < 10; i++ {
go func(id int) {
<-done
fmt.Printf("worker %d stopping\n", id)
}(i)
}
close(done)
Compare with a condition variable, where you call Broadcast() on every state change. You close the channel once and every receiver wakes. In real code you’d reach for context.Context, whose ctx.Done() is a channel that closes when the context is cancelled.
nil channels disable select cases
Link to heading
A nil channel blocks forever on both send and receive, so select never picks that case.
func merge(a, b <-chan int, out chan<- int) {
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil
continue
}
out <- v
case v, ok := <-b:
if !ok {
b = nil
continue
}
out <- v
}
}
close(out)
}
Go has no dedicated syntax for disabling a select case. The channel variable holds the state, and setting it to nil turns the case off.
Channels of channels Link to heading
Channels can carry other channels. Put a reply channel inside your request struct and the response comes back through it, so you skip the correlation table you’d otherwise build.
type request struct {
query string
reply chan string
}
func client(reqs chan<- request) string {
r := request{query: "hello", reply: make(chan string, 1)}
reqs <- r
return <-r.reply
}
func server(reqs <-chan request) {
for r := range reqs {
r.reply <- process(r.query)
}
}
When a mutex is still simpler Link to heading
Use channels for coordination and ownership handoff. A counter or a config map is simpler with sync.Mutex. Bryan Mills argues people overuse the standard channel patterns (Rethinking Classical Concurrency Patterns).