Day 20 — Channels

Updated

July 30, 2026

Day 20 — Channels

Stage III · ~3h (theory-heavy)
Goal: Use channels as typed pipes with ownership rules—not as a substitute for every mutex—and implement a correct producer/consumer pipeline.

Note

Channels coordinate goroutines that pass values. Prefer them when ownership transfers; prefer mutexes when many readers/writers share a structure in place (Day 22).

Why this day exists

Channels look simple (ch <- v, v := <-ch) and then surprise you with:

  • Deadlocks on unbuffered sends
  • Panic on send to closed channel
  • Zero-value receives after close
  • Goroutine leaks when nobody receives
  • “Who closes?” ownership fights

Master the rules, then the patterns (Days 21–27) compose cleanly.

Producer sends and closes channel consumer ranges

Producer channel consumer

Theory 1 — Channel as a value and a type

var ch chan int          // nil channel
ch = make(chan int)      // unbuffered
ch = make(chan int, 8)   // buffered capacity 8
Kind Behavior
Unbuffered Send blocks until a receiver is ready (and vice versa)—rendezvous
Buffered Send blocks only when buffer is full; receive blocks when empty
Nil Send and receive block forever (useful in select—Day 21)
Closed Receive gets remaining values then zero value + ok==false; send panics

Channels are reference types: assignment copies the handle, not the queue.

a := make(chan int, 1)
b := a
b <- 1
fmt.Println(<-a) // 1 — same channel

Directional types (API clarity)

func produce(out chan<- int) { out <- 1 } // send-only
func consume(in <-chan int) int { return <-in } // receive-only

Converters: a bidirectional chan T can be passed where chan<- T or <-chan T is expected; not the reverse.


Theory 2 — Close semantics and the comma-ok idiom

v, ok := <-ch
// ok == true  → v is a real send
// ok == false → ch is closed and drained; v is zero value of T

Range over a channel:

for v := range ch {
    // receives until ch is closed and empty
}

Who closes?

Rule: the sender (or the last of N senders, carefully) closes. Receivers almost never close.

Why: close is a signal “no more values.” Multiple closers → panic. Receivers closing while senders still send → panic.

Multiple senders

Usually do not let each sender close. Options:

  • One coordinator closes after WaitGroup of producers
  • Use sync.Once around close
  • Prefer a single producer goroutine

Theory 3 — Ownership and “don’t communicate by sharing memory”

The slogan is directional advice, not law:

Prefer passing ownership of data through channels over mutating shared structures.

Good fit for channels:

  • Pipeline stages (read → parse → write)
  • Task queues with backpressure (buffer size)
  • Fan-in of completed results

Poor fit for channels:

  • Incrementing a counter (use atomic/mutex)
  • Shared caches with random access (mutex map / specialized store)
  • Every function parameter “just in case”

Theory 4 — Blocking, deadlock, and main

Classic deadlock:

ch := make(chan int)
ch <- 1          // blocks forever: no receiver
fmt.Println(<-ch)

Unbuffered send in main before a receiver exists → hang.

Buffered escape hatch (limited):

ch := make(chan int, 1)
ch <- 1 // ok
fmt.Println(<-ch)

Buffer is not a free pass to avoid design—it only absorbs bursts of size N.

Detecting deadlocks

The runtime detects some global deadlocks (all goroutines asleep). Partial deadlocks (one leaked G while others run) need discipline and later tooling (Day 25, Day 36, Go 1.26 leak profile).


Theory 5 — Zero values after close (silent bugs)

ch := make(chan int, 2)
ch <- 1
close(ch)
fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 0, ok=false if checked
fmt.Println(<-ch) // 0 forever after close

If you ignore ok, a closed channel looks like an infinite stream of zeros—dangerous for int/bool protocols. Prefer:

for v := range ch { ... }
// or
v, ok := <-ch
if !ok { return }

Theory 6 — Producer/consumer contracts

Write contracts down:

Role Responsibility
Producer Creates values; closes when done
Consumer Reads until closed; does not close
Buffer size Max in-flight items (backpressure)

Backpressure

A full buffered channel slows producers (send blocks). That is a feature: it prevents unbounded memory growth. Unbounded go spawn without a full buffer is the real memory bomb (Day 23 pools).


Worked example — single producer, single consumer

package main

import (
    "fmt"
    "sync"
)

func producer(out chan<- int, n int) {
    defer close(out)
    for i := 1; i <= n; i++ {
        out <- i
    }
}

func consumer(in <-chan int, sum *int) {
    for v := range in {
        *sum += v
    }
}

func main() {
    ch := make(chan int, 4)
    var sum int
    var wg sync.WaitGroup

    wg.Add(1)
    go func() {
        defer wg.Done()
        consumer(ch, &sum)
    }()

    producer(ch, 10) // runs in main; closes when done
    wg.Wait()
    fmt.Println(sum) // 55
}

Note: sum is only written by the consumer goroutine after join—no race. If both sides mutated sum, you would need synchronization.


Worked example — multi-producer, single closer

package main

import (
    "fmt"
    "sync"
)

func main() {
    out := make(chan int, 16)
    var wg sync.WaitGroup

    for id := 0; id < 4; id++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for i := 0; i < 3; i++ {
                out <- id*10 + i
            }
        }(id)
    }

    go func() {
        wg.Wait()
        close(out) // single closer
    }()

    var n int
    for range out {
        n++
    }
    fmt.Println("received", n) // 12
}

Lab 1 — Unbuffered rendezvous

Workspace: ~/lab/90daysofx/01-go/day20

mkdir -p ~/lab/90daysofx/01-go/day20 && cd ~/lab/90daysofx/01-go/day20
go mod init example.com/day20
  1. Two goroutines: one sends "ping", the other receives and replies "pong" on a second channel.
  2. Join both; print the exchange.
  3. Run with -race.
go run -race .

Lab 2 — Producer/consumer with buffer sizing

Build a pipeline:

  1. Producer generates integers 1..N (N=1000).
  2. Stage squares values.
  3. Consumer sums squares.

Requirements:

  • Close channels correctly
  • Use directional types in function signatures
  • Experiment with buffer sizes 0, 1, 64
  • Journal qualitative backpressure observations
go run -race .

Optional: print stage progress every 100 items (careful of races on shared counters—use atomic or print only from one G).


Lab 3 — Deliberate panics and hangs (safe learning)

In throwaway files:

  1. Send on closed channel → observe panic.
  2. Double close → panic.
  3. Unbuffered send with no receiver → hang (Ctrl-C).
  4. Receive loop without close → hang.

Write the error messages into your notes. Fear of close goes away when rules are mechanical.


Lab 4 — Nil channel behavior

var ch chan int
// <-ch  // blocks forever
// ch <- 1 // blocks forever

Confirm with a program that uses select with default (preview Day 21) or a timeout parent. Journal: “nil channel never ready.”


Common gotchas

Gotcha Fix
Send on closed channel Only senders close; track lifecycle
Forgetting to close range never ends; leak or hang
Closing from receivers Don’t—transfer ownership to sender side
Using buffer to “fix” deadlocks Fix structure; buffer only for burst
Sharing channel and mutating payload race Prefer copy or hand off exclusive ownership
len(ch) for control logic Unreliable for synchronization decisions

Checkpoint

  • Explain unbuffered vs buffered in terms of blocking
  • Explain nil and closed channel behavior
  • Implemented producer/consumer with correct close
  • Used chan<- / <-chan in at least one API
  • Multi-producer uses a single closer
  • go run -race clean on your pipeline
  • Documented one panic and one hang you caused on purpose

Commit

git add .
git commit -m "day20: channels — producer/consumer, close ownership, -race"

Write three personal gotchas before continuing.


Tomorrow

Day 21 — select & timeouts: multiplex channels, non-blocking ops, cancellation previews, and fan-in without busy loops.