Channels: Communication Between Goroutines
Channels: Communication Between Goroutines
Go's most famous proverb is: "Don't communicate by sharing memory; share memory by communicating." This phrase comes from Rob Pike's 2012 talk "Concurrency is not Parallelism," and its theoretical foundation traces back to Tony Hoare's 1978 paper "Communicating Sequential Processes" (CSP). Channels are the core implementation of this philosophy in Go—they let goroutines coordinate through message passing rather than locks.
In this chapter, we'll start with basic channel operations, progress through select multiplexing and classic concurrency patterns, and finally examine the subtle pitfalls that trip up even experienced Go programmers.
Level 1: What You Need to Know
What Is a Channel
A channel is a conduit for communication between goroutines. Think of it as a conveyor belt: one end puts items on (send), the other end takes items off (receive). The belt is typed—it can only carry values of a specific type.
// Create a channel that carries int values
ch := make(chan int)
The core property of channels is synchronization—by default, send and receive operations block until the other side is ready. This synchronization naturally guarantees data safety without additional locks.
Creating Channels: make
Channels are reference types and must be created with make before use. An uninitialized channel is nil, and operations on nil channels have special behavior (discussed in Level 4).
// Unbuffered channel (synchronous)
ch1 := make(chan int)
// Buffered channel (capacity = 5)
ch2 := make(chan int, 5)
// Declared but not initialized — this is a nil channel
var ch3 chan int // ch3 == nil
Send and Receive: The <- Operator
Go uses the <- operator to indicate the direction of data flow:
ch <- value // Send: data flows into channel
v := <-ch // Receive: data flows out of channel
<-ch // Receive and discard (used for signal waiting)
A complete example:
func main() {
ch := make(chan string)
go func() {
ch <- "hello from goroutine"
}()
msg := <-ch
fmt.Println(msg) // Output: hello from goroutine
}
Here the main goroutine blocks at <-ch until the other goroutine sends data. This blocking isn't waste—it's a synchronization point ensuring sender and receiver exchange data at the same moment.
Unbuffered vs Buffered
This is the most important distinction for channels.
Unbuffered Channel: The send operation must wait for a receiver to be ready, and vice versa. It's like a hand-off window with no intermediate storage—you must physically pass the item directly.
ch := make(chan int) // unbuffered
// goroutine A
ch <- 42 // blocks until someone receives
// goroutine B
v := <-ch // blocks until someone sends
Unbuffered channels provide the strongest synchronization guarantee: when a send completes, you know the receiver has the data.
Buffered Channel: Has an internal fixed-size queue. Send only blocks when the queue is full; receive only blocks when the queue is empty.
ch := make(chan int, 3) // buffer capacity of 3
ch <- 1 // doesn't block, queue: [1]
ch <- 2 // doesn't block, queue: [1, 2]
ch <- 3 // doesn't block, queue: [1, 2, 3]
ch <- 4 // blocks! queue is full, waiting for receiver
When to use which?
| Scenario | Choice | Reason |
|---|---|---|
| Strict synchronization needed | Unbuffered | Guarantees send-complete = receive-complete |
| Signal notification (done/quit) | Unbuffered | Clear semantics, no buffer needed |
| Producer rate > consumer rate | Buffered | Buffer absorbs short-term spikes |
| Limiting concurrency (semaphore) | Buffered | Capacity = max concurrent count |
Closing Channels: close
The sender can close a channel to signal "I won't send any more data."
ch := make(chan int, 5)
ch <- 1
ch <- 2
ch <- 3
close(ch)
// Can still receive already-buffered data after close
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3
fmt.Println(<-ch) // 0 (zero value), channel closed and empty
Detecting whether a channel is closed:
v, ok := <-ch
if !ok {
fmt.Println("channel is closed")
}
Key rules:
- Only the sender should close a channel (receiver closing causes sender panic)
- Closing an already-closed channel panics
- Sending to a closed channel panics
- Receiving from a closed channel returns zero value immediately (no blocking)
Iterating Over Channels with for-range
for range continuously receives from a channel until it's closed:
func producer(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // Must close, or range blocks forever
}
func consumer(ch <-chan int) {
for v := range ch {
fmt.Println(v)
}
// Reaching here means channel is closed
fmt.Println("done")
}
func main() {
ch := make(chan int, 2)
go producer(ch)
consumer(ch)
}
This pattern is extremely common—the producer closes the channel after sending all data, and the consumer gracefully reads everything with for range.
Common Mistakes
Mistake 1: Forgetting to close a channel causes goroutine leak
func bad() <-chan int {
ch := make(chan int)
go func() {
for i := 0; ; i++ {
ch <- i // If no one receives, blocks forever
}
}()
return ch
}
func main() {
ch := bad()
fmt.Println(<-ch) // 0
fmt.Println(<-ch) // 1
// Stop receiving—goroutine leak!
}
Fix: Use a done channel to signal exit
func good(done <-chan struct{}) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; ; i++ {
select {
case ch <- i:
case <-done:
return
}
}
}()
return ch
}
func main() {
done := make(chan struct{})
ch := good(done)
fmt.Println(<-ch) // 0
fmt.Println(<-ch) // 1
close(done) // Signal exit
}
Mistake 2: Multiple goroutines closing the same channel
// Wrong! May panic
func bad() {
ch := make(chan int)
for i := 0; i < 3; i++ {
go func() {
// ... do some work
close(ch) // Second goroutine to close will panic
}()
}
}
Fix: Use sync.Once to ensure single close
func good() {
ch := make(chan int)
var once sync.Once
closeCh := func() { once.Do(func() { close(ch) }) }
for i := 0; i < 3; i++ {
go func() {
// ... do some work
closeCh() // Safe: only executes once
}()
}
}
Practical Example: Concurrent Downloader
func download(urls []string) []string {
results := make(chan string, len(urls))
for _, url := range urls {
go func(u string) {
resp, err := http.Get(u)
if err != nil {
results <- fmt.Sprintf("error: %s", u)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
results <- string(body)
}(url)
}
var bodies []string
for range urls {
bodies = append(bodies, <-results)
}
return bodies
}
Note the buffer capacity equals the number of URLs—this ensures all goroutines can send without blocking, even if the receiver hasn't started consuming yet.
Level 2: How It Works Under the Hood
select Multiplexing
The select statement lets a goroutine wait on multiple channel operations simultaneously, similar to UNIX's select(2) system call for file descriptor multiplexing.
select {
case v := <-ch1:
fmt.Println("received from ch1:", v)
case ch2 <- 42:
fmt.Println("sent to ch2")
case <-time.After(1 * time.Second):
fmt.Println("timeout")
default:
fmt.Println("no channel ready")
}
Core semantics of select:
- All cases evaluated simultaneously: When entering select, all channel expressions and send values are evaluated
- Random selection: If multiple cases are ready, the Go runtime picks one pseudo-randomly (not in order)
- Blocking wait: If no case is ready and there's no default, select blocks
- default executes immediately: If there's a default and no case is ready, default runs immediately
Why random selection? This is an intentional design choice by the Go team (Rob Pike has emphasized this in multiple talks). If selection were sequential, earlier cases would "starve" later ones, causing unfair scheduling. Random selection guarantees all ready channels get a fair chance of being serviced.
Timeout Control
func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
ch := make(chan string, 1) // Buffer of 1 to avoid goroutine leak
go func() {
resp, err := http.Get(url)
if err != nil {
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
ch <- string(body)
}()
select {
case result := <-ch:
return result, nil
case <-time.After(timeout):
return "", fmt.Errorf("timeout after %v", timeout)
}
}
Note: the buffer capacity of 1 is critical. If the goroutine completes after timeout, it can still write to the channel without blocking forever. With an unbuffered channel, the goroutine would leak after timeout because nobody receives.
Non-blocking Operations
// Non-blocking send
select {
case ch <- value:
// Sent successfully
default:
// Channel full or no receiver, skip
}
// Non-blocking receive
select {
case v := <-ch:
// Received successfully
default:
// Channel empty or no sender, skip
}
Channel Direction
Go allows declaring channel direction in function signatures—this is compile-time type safety:
// Write-only channel (send only)
func producer(ch chan<- int) {
ch <- 42
// <-ch // Compile error! Can't receive from send-only channel
}
// Read-only channel (receive only)
func consumer(ch <-chan int) {
v := <-ch
// ch <- 1 // Compile error! Can't send to receive-only channel
// close(ch) // Compile error! Can't close receive-only channel
fmt.Println(v)
}
func main() {
ch := make(chan int) // Bidirectional channel
go producer(ch) // Implicit conversion to chan<- int
consumer(ch) // Implicit conversion to <-chan int
}
Why declare direction? Two reasons:
- Documentation: Function signature immediately shows who sends and who receives
- Compile-time checking: Prevents sending where you shouldn't, closing where you shouldn't
Conversion rule: bidirectional channels implicitly convert to unidirectional, but not the reverse.
for-range Channel Exit Condition
for v := range ch {
process(v)
}
This loop is equivalent to:
for {
v, ok := <-ch
if !ok {
break // Channel closed and empty
}
process(v)
}
Key point: for range only exits when the channel is closed AND the buffer is empty. If the channel isn't closed, even if the buffer is temporarily empty, for range blocks waiting.
Channel Internal Structure
The channel implementation in Go runtime lives in runtime/chan.go. Its core data structure is hchan:
// runtime/chan.go (simplified)
type hchan struct {
qcount uint // Number of elements currently in queue
dataqsiz uint // Ring buffer capacity
buf unsafe.Pointer // Ring buffer pointer
elemsize uint16 // Size of each element
closed uint32 // Whether closed
elemtype *_type // Element type
sendx uint // Send index
recvx uint // Receive index
recvq waitq // Queue of waiting receivers
sendq waitq // Queue of waiting senders
lock mutex // Mutex
}
Buffered channels use a Ring Buffer:
sendx
v
buf: [ 5 | 7 | _ | _ | 3 ]
^
recvx
capacity = 5, currently 3 elements: 3, 5, 7
When sendx catches up to recvx (queue full), sending goroutines are suspended and placed in sendq; when recvx catches up to sendx (queue empty), receiving goroutines are suspended and placed in recvq.
Unbuffered channels have nil buf and dataqsiz of 0. Every communication is a direct copy from sender's stack to receiver's stack (with zero-copy optimization in some cases).
Complete Send and Receive Flow
Send operation ch <- value:
- Lock
hchan.lock - If
recvqhas waiting receivers:- Copy data directly to receiver (skip buffer)
- Wake the receiver
- Otherwise, if buffer not full:
- Copy data to
buf[sendx] sendx++
- Copy data to
- Otherwise (buffer full or unbuffered):
- Package current goroutine as
sudogintosendq - Call
goparkto suspend current goroutine
- Package current goroutine as
- Unlock
Receive operation v := <-ch:
- Lock
hchan.lock - If
sendqhas waiting senders:- For unbuffered channel: copy data directly from sender
- For buffered channel: take from
buf[recvx], put sender's data intobuf[sendx] - Wake the sender
- Otherwise, if buffer non-empty:
- Take from
buf[recvx] recvx++
- Take from
- Otherwise (buffer empty):
- Package current goroutine as
sudogintorecvq - Call
goparkto suspend current goroutine
- Package current goroutine as
- Unlock
The optimization in step 2 is important—when there's a waiting party, bypassing the buffer and transferring directly saves one memory copy.
select Implementation
The Go compiler transforms select statements into calls to runtime.selectgo(). Its main steps:
- Shuffle case order (Fisher-Yates shuffle)—implements random selection semantics
- Sort by channel address for locking order (prevents deadlock)
- First pass: iterate all cases to check if any are ready
- If multiple are ready, pick the first (which is effectively random due to shuffle)
- If no case is ready:
- If there's a default: execute default
- Otherwise: register current goroutine on all cases' channel wait queues, then
gopark
- After waking: determine which case triggered, clean up other channels' wait queues
Step 2 is a clever design: multiple goroutines may wait on the same channels in select, and inconsistent lock ordering would cause deadlock. Sorting by address guarantees globally consistent lock ordering.
Performance Characteristics
Channel operation costs come mainly from:
- Mutex lock: Every send/receive requires locking
- Memory copy: Data goes from sender stack -> channel buffer -> receiver stack
- Goroutine scheduling: Blocking requires park/unpark
Operation Approx. Time (ns)
----------------------------------------------
Unbuffered send+recv ~200
Buffered send (not full) ~60
Buffered recv (not empty) ~60
select with 2 cases ~300
select with 4 cases ~500
mutex lock+unlock ~20
Data from benchmarks on Go 1.21, Apple M1. Exact numbers depend on hardware and Go version, but relative magnitudes are stable.
Conclusion: Channels are 3-10x slower than mutexes. If you just need to protect a shared variable, mutex is more efficient. Channels shine when orchestrating complex goroutine interaction patterns.
Level 3: What the Specification Says
CSP Theoretical Foundation
Go's channel model derives directly from Tony Hoare's CSP (Communicating Sequential Processes), first published in 1978 ("Communicating Sequential Processes," Communications of the ACM, Vol. 21, No. 8).
Core CSP ideas:
- Process: Independent sequential computation unit (maps to Go's goroutine)
- Channel: The only way processes communicate; communication is synchronous
- Choice: A process can wait on multiple communication events (maps to Go's select)
Hoare defined synchronous communication semantics for channels: send and receive must happen simultaneously (rendezvous). This is exactly the behavior of Go's unbuffered channels. Buffered channels are an extension of CSP—they only degrade to synchronous communication when the buffer is full.
Rob Pike and Ken Thompson's work before Go—Newsqueak (1989) and Plan 9's Alef language (1995)—already practiced CSP-style concurrency. Go's channels are the final product of 30 years of evolution along this lineage.
Channel Definition in Go Language Specification
From the Go Language Specification (https://go.dev/ref/spec), "Channel types" section:
A channel provides a mechanism for concurrently executing functions to communicate by sending and receiving values of a specified element type.
Key definitions for channel operations in the spec:
Send statements:
A send statement sends a value on a channel. The channel expression's core type must be a channel, the channel direction must permit send operations, and the type of the value to be sent must be assignable to the channel's element type.
Both the channel and the value expression are evaluated before communication begins. Communication blocks until the send can proceed. A send on an unbuffered channel can proceed if a receiver is ready. A send on a buffered channel can proceed if there is room in the buffer.
Receive operator:
For an operand ch whose core type is a channel, the value of the receive operation <-ch is the value received from the channel ch. The channel direction must permit receive operations. The type of a receive operation is the element type of the channel.
The expression blocks until a value is available.
Close:
The spec explicitly states these behaviors cause runtime panic:
- Sending to a closed channel
- Closing a nil channel
- Closing an already-closed channel
nil channel behavior:
A receive from a nil channel blocks forever. A send to a nil channel blocks forever.
happens-before Semantics
The Go Memory Model (https://go.dev/ref/mem) defines happens-before relationships for channel operations—the foundation of concurrency correctness:
- Rule 1: The nth send on a channel with capacity C happens-before the (n+C)th receive from that channel completes
- Rule 2: A receive from an unbuffered channel happens-before the corresponding send completes
- Rule 3: The closing of a channel happens-before a receive that returns a zero value because the channel is closed
Rule 2 is particularly interesting—for unbuffered channels, the receive happens-before the send completes (not send before receive). This is because the receiver must be ready first before the sender can copy data across and return.
These rules guarantee visibility of data passed through channels:
var a string
func main() {
ch := make(chan struct{})
go func() {
a = "hello" // (1)
<-ch // (2) happens-before (3)
}()
ch <- struct{}{} // (3) send completing means (2) has executed
fmt.Println(a) // Guaranteed to see "hello"
}
Classic Concurrency Patterns
These patterns are Go concurrency's "design patterns," from Rob Pike's "Go Concurrency Patterns" (Google I/O 2012) and Sameer Ajmani's "Advanced Go Concurrency Patterns" (Google I/O 2013).
Pipeline
// Each stage: receive input -> process -> send output
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
for n := range in {
out <- n * n
}
close(out)
}()
return out
}
func main() {
// Assemble pipeline: gen -> sq -> print
for v := range sq(sq(gen(2, 3, 4))) {
fmt.Println(v) // 16, 81, 256 ((2^2)^2, (3^2)^2, (4^2)^2)
}
}
Pipeline pattern advantages:
- Each stage runs independently and can be tested in isolation
- Natural back-pressure: upstream automatically blocks when downstream is slow
- Easy to parallelize: each stage can launch multiple goroutines
Fan-Out / Fan-In
Fan-Out: Multiple goroutines read from the same channel, sharing the work.
Fan-In: Multiple channels' outputs merge into one channel.
// Fan-out: launch multiple workers reading from jobs
func fanOut(jobs <-chan int, numWorkers int) []<-chan int {
workers := make([]<-chan int, numWorkers)
for i := 0; i < numWorkers; i++ {
workers[i] = worker(jobs)
}
return workers
}
func worker(jobs <-chan int) <-chan int {
results := make(chan int)
go func() {
defer close(results)
for j := range jobs {
results <- process(j)
}
}()
return results
}
// Fan-in: merge multiple channels into one
func fanIn(channels ...<-chan int) <-chan int {
var wg sync.WaitGroup
merged := make(chan int)
for _, ch := range channels {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
merged <- v
}
}(ch)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}
Worker Pool
The most common pattern in production—a fixed number of worker goroutines processing tasks from a queue:
func workerPool(numWorkers int, jobs <-chan Job, results chan<- Result) {
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for job := range jobs {
result := processJob(job)
results <- result
}
}(i)
}
go func() {
wg.Wait()
close(results)
}()
}
func main() {
jobs := make(chan Job, 100)
results := make(chan Result, 100)
workerPool(5, jobs, results)
// Send tasks
go func() {
for _, j := range getAllJobs() {
jobs <- j
}
close(jobs) // Closing jobs channel, workers will exit automatically
}()
// Collect results
for r := range results {
handleResult(r)
}
}
Worker pool capacity planning:
- CPU-bound tasks: workers = number of CPU cores (
runtime.NumCPU()) - IO-bound tasks: more workers possible, depends on IO latency and system resource limits
- Queue buffer: absorbs burst traffic, typically 2-10x the worker count
Done Channel (Cancellation Pattern)
func cancellableWork(done <-chan struct{}) <-chan int {
results := make(chan int)
go func() {
defer close(results)
for i := 0; ; i++ {
select {
case results <- compute(i):
case <-done:
fmt.Println("cancelled, cleaning up...")
return
}
}
}()
return results
}
func main() {
done := make(chan struct{})
results := cancellableWork(done)
for i := 0; i < 5; i++ {
fmt.Println(<-results)
}
close(done) // Broadcast cancellation signal
}
close(done) is a broadcast mechanism—closing a channel causes all pending receive operations on that channel to immediately return zero value. This is far more efficient than sending cancellation signals to each goroutine individually.
Or Channel (Multiple Cancellation Source Merge)
When there are multiple cancellation signal sources, the or function merges them into one:
func or(channels ...<-chan struct{}) <-chan struct{} {
switch len(channels) {
case 0:
return nil
case 1:
return channels[0]
}
orDone := make(chan struct{})
go func() {
defer close(orDone)
switch len(channels) {
case 2:
select {
case <-channels[0]:
case <-channels[1]:
}
default:
select {
case <-channels[0]:
case <-channels[1]:
case <-channels[2]:
case <-or(append(channels[3:], orDone)...):
}
}
}()
return orDone
}
This recursive implementation comes from Katherine Cox-Buday's Concurrency in Go (O'Reilly, 2017). Its elegance lies in: when any input channel closes, orDone also closes, and orDone itself is passed to the recursive call, ensuring all goroutines can exit.
Buffered Channel as Semaphore
type Semaphore chan struct{}
func NewSemaphore(n int) Semaphore {
return make(Semaphore, n)
}
func (s Semaphore) Acquire() { s <- struct{}{} }
func (s Semaphore) Release() { <-s }
func main() {
sem := NewSemaphore(3) // Max 3 concurrent
for i := 0; i < 10; i++ {
go func(id int) {
sem.Acquire()
defer sem.Release()
// At most 3 goroutines here simultaneously
doWork(id)
}(i)
}
}
Comparison with Other Concurrency Models
| Feature | Go Channel (CSP) | Erlang (Actor) | Java (Locks) |
|---|---|---|---|
| Communication | Named channel | Process mailbox | Shared memory + locks |
| Communication target | Channel (many-to-many) | Process PID (one-to-one) | Any shared variable |
| Synchronization | send/recv blocking | send async, recv blocking | lock/unlock |
| Selection mechanism | select | receive pattern matching | No built-in |
| Error handling | panic/recover | link/monitor | exception |
| Distributed | Not built-in | Native support | Requires framework |
Go's channels chose CSP's synchronous communication model (rendezvous), while Erlang's Actor model uses asynchronous message passing. Synchronous models are easier to reason about (send completing means receiver has the data), but unfriendly to high-latency scenarios; asynchronous models are more flexible but require additional handling for mailbox overflow.
Level 4: Edge Cases and Pitfalls
Pitfall 1: Sending to a Closed Channel Causes Panic
This is Go channels' most infamous pitfall—it's a runtime panic, not a compile error, and typically appears randomly in concurrent scenarios, making it hard to reproduce.
func danger() {
ch := make(chan int, 1)
go func() {
time.Sleep(10 * time.Millisecond)
ch <- 42 // PANIC: send on closed channel
}()
close(ch)
time.Sleep(20 * time.Millisecond)
}
Why did Go design it this way? In Go's design philosophy, sending to a closed channel represents a logic error—your program has a bug in channel lifecycle management. For logic errors, Go chooses to panic immediately to surface the problem, rather than silently ignoring it (which is more dangerous because data would be lost).
Safe pattern: Only the sender should close the channel. With multiple senders, use a separate coordination mechanism:
func safeSenders() {
ch := make(chan int)
done := make(chan struct{})
// Multiple senders
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < 5; j++ {
select {
case ch <- id*10 + j:
case <-done:
return
}
}
}(i)
}
// Separate goroutine responsible for closing
go func() {
wg.Wait()
close(ch)
}()
// Receiver
for v := range ch {
fmt.Println(v)
}
}
Pitfall 2: nil Channel Blocks Forever
Send and receive on a nil channel both block forever—they don't panic:
var ch chan int // nil
go func() {
ch <- 42 // Blocks forever, not panic
}()
// <-ch // Also blocks forever
This is a feature, not a bug. nil channels have an important use in select—dynamically disabling 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
continue
}
out <- v
case v, ok := <-ch2:
if !ok {
ch2 = nil // Disable this case
continue
}
out <- v
}
}
}()
return out
}
After ch1 is set to nil, case v, ok := <-ch1 will never be selected (because it's never ready). This is an elegant way to handle "one input closed, keep processing the other."
Pitfall 3: select's Random Selection
ch := make(chan int, 1)
ch <- 42
select {
case v := <-ch:
fmt.Println("received:", v)
case ch <- 100:
fmt.Println("sent")
}
The result of this code is nondeterministic. The channel is both readable (has data) and writable (has space), and select randomly picks one.
Common bug: Relying on case order in select to implement priority.
// Wrong: assuming done has priority
select {
case <-done:
return
case result <- value:
// ...
}
If both done and result are ready simultaneously (e.g., done just closed while result has space), Go doesn't guarantee selecting done.
Correct approach: If you need priority, use nested select or check the high-priority channel first:
// Approach 1: Check done first
select {
case <-done:
return
default:
}
// If done isn't ready, enter normal select
select {
case <-done:
return
case result <- value:
// ...
}
// Approach 2: Priority check within loop
for {
select {
case <-done:
return
case job := <-jobs:
// Check done again before processing
select {
case <-done:
return
default:
}
process(job)
}
}
Pitfall 4: Goroutine Leaks
Goroutine leaks are the most common resource leak in Go programs—leaked goroutines are never garbage collected (because their stack may reference other objects), eventually causing memory exhaustion.
// Classic leak: goroutine nobody consumes from
func leak() {
ch := make(chan int)
go func() {
val := expensiveComputation()
ch <- val // If nobody receives, blocks forever
}()
// Suppose we return early due to error
return
}
Diagnostic tools:
import "runtime"
// Periodically check goroutine count
fmt.Println("goroutines:", runtime.NumGoroutine())
// Get all goroutine stacks—find what leaked goroutines are waiting on
buf := make([]byte, 1<<16)
runtime.Stack(buf, true)
fmt.Println(string(buf))
In production, use net/http/pprof to view goroutine profiles:
import _ "net/http/pprof"
go func() {
http.ListenAndServe(":6060", nil)
}()
// Visit http://localhost:6060/debug/pprof/goroutine?debug=1
Best practice: every goroutine must have a clear exit condition. When launching a goroutine, always consider:
- When does it terminate?
- If it blocks on a channel, who "frees" it?
- If the program needs to shut down, can it receive notification?
Pitfall 5: Improper Channel Size Selection
Problems with buffer 0 (unbuffered):
// If handler is slow, all senders block
func eventLoop(events <-chan Event) {
for e := range events {
handleEvent(e) // If this is slow...
}
}
Problems with oversized buffer:
// 10000 buffer—masks the problem that consumer can't keep up
ch := make(chan *BigStruct, 10000)
// Everything works fine until buffer fills, then sudden stall
// Worse: 10000 BigStructs consume massive memory
Correct thinking:
- Default to unbuffered: Forces synchronous sender-receiver, easier to spot problems
- When buffer needed, start small: Try 1 or numWorkers, adjust with benchmarks
- Buffer is not "bigger is better": It only postpones the problem, doesn't solve it
- When in doubt, use unbuffered: Quoting Rob Pike—"Channels are synchronization devices. The buffered channel is an optimization."
Pitfall 6: time.After in select Causing Memory Leak
// Creates a new Timer every iteration—memory leak!
for {
select {
case v := <-ch:
process(v)
case <-time.After(5 * time.Second):
fmt.Println("timeout")
return
}
}
time.After creates a new time.Timer on every call. In high-frequency loops, if ch continuously has data, each iteration creates a new Timer while old Timers only get GC'd after their timeout. If loop frequency is 1000/s, a 5-second Timer means 5000 Timers alive simultaneously.
Fix: Reuse Timer
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case v := <-ch:
// Received data, reset timer
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(5 * time.Second)
process(v)
case <-timer.C:
fmt.Println("timeout")
return
}
}
Pitfall 7: Timing of range over closed channel
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
// This is safe—reads 1, 2, 3 then exits
for v := range ch {
fmt.Println(v)
}
But:
ch := make(chan int)
close(ch)
v := <-ch // v = 0, returns immediately
v = <-ch // v = 0, returns immediately again
// Reading from a closed empty channel always returns zero value, no blocking
This means if you forget to check ok, you might process tons of zero values:
// Bug: after ch closes, prints 0 frantically
for {
fmt.Println(<-ch)
}
Real-World Case: Channel Usage in Kubernetes
Kubernetes' controller-runtime library heavily uses channel patterns. Take workqueue as an example (simplified):
type Queue struct {
queue []interface{}
dirty set
processing set
cond *sync.Cond
shutdown bool
}
func (q *Queue) Get() (interface{}, bool) {
q.cond.L.Lock()
for len(q.queue) == 0 && !q.shutdown {
q.cond.Wait()
}
if len(q.queue) == 0 {
q.cond.L.Unlock()
return nil, true // shutdown
}
item := q.queue[0]
q.queue = q.queue[1:]
q.processing.insert(item)
q.cond.L.Unlock()
return item, false
}
Note Kubernetes chose sync.Cond over channels here—because it needs finer-grained control (dirty set, processing set) that channels can't conveniently express. This demonstrates that channels aren't universal; choosing the right synchronization tool matters.
Interview Questions
-
What's the difference between unbuffered and buffered channels? When to use which?
- Unbuffered provides synchronization guarantee (rendezvous), buffered provides async decoupling
- Signal notification uses unbuffered, peak smoothing uses buffered
-
What happens when you send to a closed channel? Receive from a closed channel?
- Send: panic
- Receive: immediately returns zero value, ok = false
-
How to implement a "close only once" safe channel?
- sync.Once + close, or have a single owner responsible for closing
-
What happens when multiple cases are ready in select?
- Random selection (Go runtime implements via Fisher-Yates shuffle)
-
What is a goroutine leak? How to prevent it?
- A goroutine blocked on a channel operation that will never be resolved
- Use context or done channel to ensure cancellability
-
What's the underlying data structure of a channel?
- hchan: contains ring buffer buf, send/receive indices, wait queues sendq/recvq, mutex
Summary
Channels are the core abstraction of Go's concurrency model. They're not just a data structure—they're a way of thinking: decompose concurrent programs into independent sequential processes, coordinating through message passing.
Key takeaways:
- Unbuffered channel = synchronization point (rendezvous)
- Buffered channel = async queue (bounded queue)
- Only the sender should close a channel
- Every goroutine must have a clear exit path
- Channels are slower than mutexes, but express more complex coordination patterns
- When you need to protect shared state, use mutex; when you need to orchestrate goroutine interaction, use channels