Skip to content

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:

// SAFE: only one sender, close after all sends
go func() {
    for _, item := range items {
        ch <- item
    }
    close(ch)
}()


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

  1. Only the sender should close a channel -- closing from the receiver side risks sending on a closed channel (panic)
  2. Use directional channel types in function signatures for compile-time safety
  3. Prefer chan struct{} for signaling (done channels, semaphores) -- zero memory per signal
  4. Use range to drain channels -- it automatically stops when the channel is closed
  5. Don't close channels unnecessarily -- only close when receivers need to know that sending is complete
  6. Use buffered channels sparingly -- unbuffered channels provide stronger synchronization guarantees
  7. Document which goroutine owns the channel -- owner creates, sends, and closes; consumers only receive
  8. Use context.Context alongside channels for cancellation in production code

Common Pitfalls

Send on Closed Channel

ch := make(chan int)
close(ch)
ch <- 1  // PANIC: send on closed channel
Ensure all senders finish before closing. With multiple senders, use a sync.WaitGroup and close in a separate goroutine after Wait().

Deadlock: Unbuffered Channel in Same Goroutine

ch := make(chan int)
ch <- 42   // DEADLOCK: blocks forever -- no other goroutine to receive
val := <-ch
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
}
If a consumer uses 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
Always use the comma-ok idiom (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 range to drain until closure
  • Nil channels block forever -- useful for disabling select cases
  • 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