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.
// ─── 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 FULLch2 <- Message{Topic: "a"} // returns immediately — space in bufferch2 <- 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 itemsmsg := <-ch2 // returns immediately — gets "a" (FIFO)fmt.Println(len(ch2)) // items currently in bufferfmt.Println(cap(ch2)) // total buffer capacity
Which 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.
Closing channels — signalling completion
close() — no more values. range detects close automatically.
ch := make(chan Message, 10)// Producer goroutine: send then closego 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 emptyfor 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 manuallymsg, ok := <-chif !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 messagesnormalCh := make(chan Message, 100) // normal messagesdoneCh := make(chan struct{}) // stop signal — struct{} costs 0 bytesfor { 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 somethingdefault: doOtherWork() // channel was empty — don't wait}// Non-blocking send — drop if buffer full:select {case ch <- msg: // sent successfullydefault: // 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 closefunc 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 sendingfunc consume(in <-chan Message) { for msg := range in { process(msg) }}// Usagemsgs := 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.