Section 11
Channels
Channels are typed pipes between goroutines. They carry both the data and the synchronisation. Go's design principle: "Do not communicate by sharing memory; share memory by communicating."
Java concurrent code typically shares mutable objects protected by locks. Go channels transfer ownership — the sender gives up the value, the receiver takes it. This makes data flow explicit and eliminates many classes of race conditions.
Creating and using channels
make, send, receive — the three operations
// Create a channel — make(chan Type, bufferSize)
ch := make(chan Message) // unbuffered — capacity 0
ch2 := make(chan Message, 10) // buffered — capacity 10
// Send: ch <- value
// Receive: value := <-ch
// Simple example: one goroutine sends, this goroutine receives
go func() {
ch <- Message{Topic: "orders.created"} // send — blocks until receiver ready
}()
msg := <-ch // receive — blocks until sender sends
fmt.Println(msg.Topic)Unbuffered vs Buffered — the critical difference
unbuffered = rendezvous, buffered = queue
// ─── UNBUFFERED ──────────────────────────────────────────────
ch := make(chan Message) // capacity = 0
// Send BLOCKS until a receiver is waiting
// Receive BLOCKS until a sender sends
// Both goroutines must be ready at the SAME TIME
// Think of it as a baton handoff — both runner and receiver must be at the line
// ─── BUFFERED ────────────────────────────────────────────────
ch2 := make(chan Message, 100) // capacity = 100
// Send DOES NOT block until buffer is FULL
ch2 <- Message{Topic: "a"} // returns immediately — space in buffer
ch2 <- Message{Topic: "b"} // returns immediately
// ... 98 more sends without blocking ...
// ch2 <- msg on the 101st would block until someone reads
// Receive DOES NOT block when buffer has items
msg := <-ch2 // returns immediately — gets "a" (FIFO)
fmt.Println(len(ch2)) // items currently in buffer
fmt.Println(cap(ch2)) // total buffer capacityWhich to use?
Unbuffered — when you need synchronisation: "I need to know the receiver has this value before I continue." Handoff semantics.
Buffered — when producer and consumer run at different speeds. Buffer absorbs bursts. Size for your maximum burst, not average throughput. Too small = producer blocks under load. Too large = memory waste and hidden backpressure.
Buffered — when producer and consumer run at different speeds. Buffer absorbs bursts. Size for your maximum burst, not average throughput. Too small = producer blocks under load. Too large = memory waste and hidden backpressure.
Closing channels — signalling completion
close() — no more values. range detects close automatically.
ch := make(chan Message, 10)
// Producer goroutine: send then close
go func() {
for _, msg := range batch {
ch <- msg // send each message
}
close(ch) // signal: no more messages will ever come
// Sending to a closed channel PANICS — only sender closes
}()
// Consumer: range over channel stops when channel is closed AND empty
for msg := range ch {
process(msg) // runs for each message, exits when closed+drained
}
// Loop exits cleanly — no need to check for a sentinel value
// Two-value receive — check close manually
msg, ok := <-ch
if !ok {
fmt.Println("channel closed and empty")
}!Only the sender should close a channel. Never close from the receiver side. Closing twice panics. Sending to a closed channel panics.
Select — wait on multiple channels simultaneously
select is to channels what switch is to values
highCh := make(chan Message, 10) // urgent messages
normalCh := make(chan Message, 100) // normal messages
doneCh := make(chan struct{}) // stop signal — struct{} costs 0 bytes
for {
select {
case msg := <-highCh:
// highCh had a message — handle it
processUrgent(msg)
case msg := <-normalCh:
// normalCh had a message — handle it
process(msg)
case <-doneCh:
// doneCh was closed/sent to — time to stop
return
// If NONE are ready: select blocks until one becomes ready
// If MULTIPLE are ready: one is chosen at random (not priority!)
}
}select with default — non-blocking check
// default case runs immediately if no channel is ready
// Non-blocking receive — check without waiting:
select {
case msg := <-ch:
process(msg) // channel had something
default:
doOtherWork() // channel was empty — don't wait
}
// Non-blocking send — drop if buffer full:
select {
case ch <- msg:
// sent successfully
default:
// buffer full — apply backpressure or drop
log.Println("channel full, dropping")
}Directional channel types
send-only and receive-only narrow the contract
// chan T = bidirectional (read and write)
// chan<- T = send-only (can only write: ch <- val)
// <-chan T = receive-only (can only read: val := <-ch)
// Producer — returns receive-only so caller cannot accidentally send or close
func startProducer() <-chan Message {
ch := make(chan Message, 100) // bidirectional inside
go func() {
defer close(ch) // close when done producing
for {
ch <- generateMessage()
}
}()
return ch // narrowed to receive-only — caller can only read
}
// Consumer — parameter is receive-only — compiler prevents it from sending
func consume(in <-chan Message) {
for msg := range in {
process(msg)
}
}
// Usage
msgs := startProducer()
consume(msgs)⚙In engine code:
chan struct{} fields named stopCh or doneCh are signal-only channels. No data — just the act of closing them broadcasts to all receivers. <-chan Message as a return type marks a producer.