Chapter 10

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:

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:

  1. All cases evaluated simultaneously: When entering select, all channel expressions and send values are evaluated
  2. Random selection: If multiple cases are ready, the Go runtime picks one pseudo-randomly (not in order)
  3. Blocking wait: If no case is ready and there's no default, select blocks
  4. 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:

  1. Documentation: Function signature immediately shows who sends and who receives
  2. 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:

  1. Lock hchan.lock
  2. If recvq has waiting receivers:
    • Copy data directly to receiver (skip buffer)
    • Wake the receiver
  3. Otherwise, if buffer not full:
    • Copy data to buf[sendx]
    • sendx++
  4. Otherwise (buffer full or unbuffered):
    • Package current goroutine as sudog into sendq
    • Call gopark to suspend current goroutine
  5. Unlock

Receive operation v := <-ch:

  1. Lock hchan.lock
  2. If sendq has waiting senders:
    • For unbuffered channel: copy data directly from sender
    • For buffered channel: take from buf[recvx], put sender's data into buf[sendx]
    • Wake the sender
  3. Otherwise, if buffer non-empty:
    • Take from buf[recvx]
    • recvx++
  4. Otherwise (buffer empty):
    • Package current goroutine as sudog into recvq
    • Call gopark to suspend current goroutine
  5. 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:

  1. Shuffle case order (Fisher-Yates shuffle)โ€”implements random selection semantics
  2. Sort by channel address for locking order (prevents deadlock)
  3. 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)
  4. If no case is ready:
    • If there's a default: execute default
    • Otherwise: register current goroutine on all cases' channel wait queues, then gopark
  5. 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:

  1. Mutex lock: Every send/receive requires locking
  2. Memory copy: Data goes from sender stack -> channel buffer -> receiver stack
  3. 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:

  1. Process: Independent sequential computation unit (maps to Go's goroutine)
  2. Channel: The only way processes communicate; communication is synchronous
  3. 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:

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:

  1. Rule 1: The nth send on a channel with capacity C happens-before the (n+C)th receive from that channel completes
  2. Rule 2: A receive from an unbuffered channel happens-before the corresponding send completes
  3. 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:

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:

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:

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:

  1. Default to unbuffered: Forces synchronous sender-receiver, easier to spot problems
  2. When buffer needed, start small: Try 1 or numWorkers, adjust with benchmarks
  3. Buffer is not "bigger is better": It only postpones the problem, doesn't solve it
  4. 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

  1. 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
  2. What happens when you send to a closed channel? Receive from a closed channel?

    • Send: panic
    • Receive: immediately returns zero value, ok = false
  3. How to implement a "close only once" safe channel?

    • sync.Once + close, or have a single owner responsible for closing
  4. What happens when multiple cases are ready in select?

    • Random selection (Go runtime implements via Fisher-Yates shuffle)
  5. 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
  6. 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:

Rate this chapter
4.5  / 5  (42 ratings)

๐Ÿ’ฌ Comments