Channels Intermediate¶
Introduction¶
Channels are Go's primary mechanism for communication between goroutines. They are typed conduits that enforce synchronization: sending and receiving are blocking operations by default. Channels embody Go's concurrency philosophy -- "Don't communicate by sharing memory; share memory by communicating." Understanding unbuffered vs buffered channels, directionality, closing semantics, nil channel behavior, and the channel axioms is essential for writing correct concurrent Go and acing interviews.
Creating Channels¶
// Unbuffered channel -- send blocks until a receiver is ready
ch := make(chan int)
// Buffered channel -- send blocks only when buffer is full
ch := make(chan int, 10)
// Channel of structs (zero-size signal channel)
done := make(chan struct{})
// Channel of channels (advanced patterns)
requests := make(chan chan int)
Unbuffered Channels¶
An unbuffered channel has no capacity. Send and receive must happen simultaneously -- the sender blocks until a receiver is ready, and vice versa.
func main() {
ch := make(chan string)
go func() {
ch <- "hello" // blocks until main goroutine receives
}()
msg := <-ch // blocks until sender goroutine sends
fmt.Println(msg) // "hello"
}
Unbuffered Channel (synchronous handoff):
Sender goroutine Channel Receiver goroutine
───────────────── ───────── ────────────────────
ch <- "hello" ──────► [ sync ] ──────► msg := <-ch
(blocks until (no buffer) (blocks until
receiver ready) sender ready)
Buffered Channels¶
A buffered channel has a fixed capacity. Sends only block when the buffer is full; receives only block when the buffer is empty.
func main() {
ch := make(chan int, 3)
// These don't block because buffer has space
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 // would block -- buffer is full
fmt.Println(<-ch) // 1 (FIFO order)
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
}
Buffered Channel (capacity 3):
State after 3 sends: [ 1 | 2 | 3 ] ← full, next send blocks
State after 1 receive: [ 2 | 3 | ] ← space available
When to Use Each¶
| Type | Use Case |
|---|---|
| Unbuffered | Synchronization, guaranteed handoff, request-response |
| Buffered (cap 1) | Signal that can be "pre-loaded" once |
| Buffered (cap N) | Decouple producer/consumer speeds, batch processing, semaphore |
Channel Direction¶
Functions can restrict channels to send-only or receive-only for type safety.
// send-only channel
func producer(out chan<- int) {
for i := 0; i < 5; i++ {
out <- i
}
close(out)
}
// receive-only channel
func consumer(in <-chan int) {
for val := range in {
fmt.Println(val)
}
}
func main() {
ch := make(chan int, 5) // bidirectional
// Bidirectional channels implicitly convert to directional
go producer(ch) // ch converts to chan<- int
consumer(ch) // ch converts to <-chan int
}
| Declaration | Direction | Can Send | Can Receive | Can Close |
|---|---|---|---|---|
chan T |
Bidirectional | Yes | Yes | Yes |
chan<- T |
Send-only | Yes | No | Yes |
<-chan T |
Receive-only | No | Yes | No |
Closing Channels¶
Closing a channel signals that no more values will be sent. Receivers can detect closure.
func generator(n int) <-chan int {
ch := make(chan int)
go func() {
for i := 0; i < n; i++ {
ch <- i
}
close(ch) // signal completion
}()
return ch
}
func main() {
// Range over channel -- loops until channel is closed
for val := range generator(5) {
fmt.Println(val) // 0, 1, 2, 3, 4
}
// Comma-ok idiom -- detect closure
ch := make(chan int, 1)
ch <- 42
close(ch)
val, ok := <-ch
fmt.Println(val, ok) // 42 true
val, ok = <-ch
fmt.Println(val, ok) // 0 false -- channel closed and drained
}
Closing Rules
- Only the sender should close a channel -- never the receiver
- Closing a channel is a broadcast signal to all receivers
- Don't close a channel unless necessary -- it's not required for garbage collection. Only close when the receiver needs to know sending is done.
The Channel Axioms¶
These four rules govern channel behavior in edge cases. Memorize them.
| Operation | Nil Channel | Closed Channel | Active Channel |
|---|---|---|---|
Send ch <- v |
Blocks forever | Panics | Blocks until received (unbuffered) or buffer has space (buffered) |
Receive <-ch |
Blocks forever | Returns zero value immediately | Blocks until value available |
Close close(ch) |
Panics | Panics | Succeeds; signals all receivers |
// Demonstrate the axioms
var nilCh chan int // nil channel
// go func() { nilCh <- 1 }() // blocks forever
// go func() { <-nilCh }() // blocks forever
// close(nilCh) // panic: close of nil channel
ch := make(chan int)
close(ch)
// ch <- 1 // panic: send on closed channel
val := <-ch // 0 (zero value, immediately)
val, ok := <-ch // 0, false
// close(ch) // panic: close of closed channel
Send on Closed Channel Panics
This is the most common channel-related panic in production code. Always ensure senders finish before closing:
Nil Channels: Deliberate Blocking¶
Nil channels block forever on both send and receive. This is useful in select statements to disable a case.
func merge(ch1, ch2 <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for ch1 != nil || ch2 != nil {
select {
case v, ok := <-ch1:
if !ok {
ch1 = nil // disable this case -- nil channel blocks forever
continue
}
out <- v
case v, ok := <-ch2:
if !ok {
ch2 = nil
continue
}
out <- v
}
}
}()
return out
}
Channels for Signaling vs Data Transfer¶
Signal Channel (chan struct{})¶
// Use chan struct{} when you only need to signal an event (no data)
done := make(chan struct{})
go func() {
// ... do work ...
close(done) // broadcast: "I'm done"
}()
<-done // wait for signal
struct{} has zero size, so signal channels consume no extra memory per message.
Data Channel¶
// Transfer actual data between goroutines
results := make(chan Result, 100)
go func() {
for _, url := range urls {
result := fetch(url)
results <- result
}
close(results)
}()
for r := range results {
process(r)
}
Buffered Channel as Semaphore¶
A buffered channel can limit concurrency by acting as a counting semaphore.
func processAll(urls []string) {
sem := make(chan struct{}, 10) // max 10 concurrent operations
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
sem <- struct{}{} // acquire semaphore (blocks if 10 goroutines are active)
go func(u string) {
defer wg.Done()
defer func() { <-sem }() // release semaphore
resp, err := http.Get(u)
if err != nil {
log.Printf("error: %v", err)
return
}
resp.Body.Close()
}(url)
}
wg.Wait()
}
Fan-Out / Fan-In Pattern¶
// Fan-out: distribute work to multiple goroutines
func fanOut(input <-chan int, workers int) []<-chan int {
channels := make([]<-chan int, workers)
for i := 0; i < workers; i++ {
channels[i] = worker(input)
}
return channels
}
func worker(input <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range input {
out <- n * n // process
}
}()
return out
}
// Fan-in: merge multiple channels into one
func fanIn(channels ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
Quick Reference¶
| Concept | Syntax | Notes |
|---|---|---|
| Create unbuffered | make(chan T) |
Send blocks until receive |
| Create buffered | make(chan T, cap) |
Send blocks when full |
| Send | ch <- value |
Blocks on nil, panics on closed |
| Receive | val := <-ch |
Blocks on nil, returns zero on closed |
| Receive with check | val, ok := <-ch |
ok == false means closed |
| Range | for v := range ch |
Loops until closed |
| Close | close(ch) |
Only sender closes; broadcasts to all receivers |
| Send-only | chan<- T |
Function parameter constraint |
| Receive-only | <-chan T |
Function parameter/return constraint |
| Signal channel | chan struct{} |
Zero-size, for signaling only |
| Semaphore | make(chan struct{}, N) |
Limits concurrency to N |
Best Practices¶
- Only the sender should close a channel -- closing from the receiver side risks sending on a closed channel (panic)
- Use directional channel types in function signatures for compile-time safety
- Prefer
chan struct{}for signaling (done channels, semaphores) -- zero memory per signal - Use
rangeto drain channels -- it automatically stops when the channel is closed - Don't close channels unnecessarily -- only close when receivers need to know that sending is complete
- Use buffered channels sparingly -- unbuffered channels provide stronger synchronization guarantees
- Document which goroutine owns the channel -- owner creates, sends, and closes; consumers only receive
- Use
context.Contextalongside channels for cancellation in production code
Common Pitfalls¶
Send on Closed Channel
Ensure all senders finish before closing. With multiple senders, use async.WaitGroup and close in a separate goroutine after Wait().
Deadlock: Unbuffered Channel in Same Goroutine
Unbuffered sends and receives must happen in different goroutines.Forgetting to Close Causes Hanging range
ch := make(chan int)
go func() {
ch <- 1
ch <- 2
// forgot close(ch)
}()
for v := range ch {
fmt.Println(v) // prints 1, 2 then hangs forever
}
range, the producer must close the channel when done.
Receiving from Closed Channel Returns Zero Values
ch := make(chan int, 1)
ch <- 42
close(ch)
fmt.Println(<-ch) // 42
fmt.Println(<-ch) // 0 -- zero value, NOT an error
fmt.Println(<-ch) // 0 -- keeps returning zero values forever
val, ok := <-ch) when you need to distinguish between a real zero and a closed channel.
Performance Considerations¶
| Scenario | Impact |
|---|---|
| Unbuffered channel operation | ~50-100 ns per send/receive (includes goroutine scheduling) |
| Buffered channel operation | ~20-50 ns when buffer has space (no goroutine scheduling needed) |
| Channel vs mutex | Channels are slower than mutexes for simple shared state protection; use mutexes for guarding data, channels for communication |
| Buffer size | Too small → goroutines block frequently; too large → wasted memory and hidden backpressure |
struct{} channels |
Zero allocation per element -- ideal for signaling |
| Channel of pointers vs values | Sending pointers avoids copying large structs but beware of shared ownership |
When to Use Mutex vs Channel
- Mutex: protecting access to shared state (counters, maps, caches)
- Channel: passing data between goroutines, signaling events, coordinating pipelines
Rule of thumb: if you're protecting data, use a mutex. If you're transferring ownership or coordinating work, use a channel.
Interview Tips¶
Interview Tip
Memorize the channel axioms: send to nil blocks, receive from nil blocks, send to closed panics, receive from closed returns zero value. These four rules explain almost every channel edge case.
Interview Tip
When asked "unbuffered vs buffered?", explain: unbuffered provides synchronization (guaranteed handoff), while buffered provides decoupling (producer and consumer can run at different speeds). Unbuffered is the default choice; use buffered only when you need to decouple or limit concurrency.
Interview Tip
The "send on closed channel" panic is a top interview gotcha. Explain the ownership model: the goroutine that sends on a channel should also close it. With multiple senders, use a WaitGroup and a dedicated goroutine to close after all senders finish.
Interview Tip
If asked about the nil channel trick, explain: in a select, a nil channel case is effectively disabled (it blocks forever and is never selected). This is useful for dynamically disabling input sources in a merge or multiplexer without restructuring the select.
Key Takeaways¶
- Channels are Go's primitive for goroutine communication -- typed, synchronized conduits
- Unbuffered channels synchronize sender and receiver; buffered channels decouple them
- Use directional types (
chan<-,<-chan) in function signatures for safety - The channel axioms (nil blocks, closed panics on send, closed returns zero on receive) are fundamental
- Only the sender should close a channel; use
rangeto drain until closure - Nil channels block forever -- useful for disabling
selectcases chan struct{}is the idiomatic zero-size signal channel- Buffered channels can serve as semaphores to limit concurrency
- Use channels for communication and mutexes for shared state protection