Channels Complete

Updated

September 8, 2026

Channels Complete

Overview

Channels are multi-purpose: data transfer, completion (done), and (with care) cancellation. This chapter covers close semantics, range, directional types, deadlocks, and nil-channel tricks.

End-of-stream: why close exists

Infinite receive loops deadlock when the producer finishes:

producer sends all words ──► consumer for { recv }
producer exits
consumer blocks forever on recv  →  "all goroutines are asleep - deadlock"

Magic sentinel values ("__EOF__") are fragile when the sentinel is also valid data. Close is the structured signal.

Closing a channel

// writer
for _, w := range words {
    in <- w
}
close(in)

// reader
for {
    w, ok := <-in
    if !ok {
        break // closed and drained
    }
    // use w
}
Operation Open channel Closed channel
Send may block panic
Receive value, ok=true zero value, ok=false (repeatable)
Close ok once panic on second close
state machine:
  start --> Open
  Open --> Closed
  Closed --> Closed
  Open --> Open

Rules

  1. Only the writer closes (sole ownership).
  2. Close is a signal, not a resource free—GC collects unused channels either way.
  3. Close when readers need end-of-data; otherwise optional.

Range over channel

for w := range in {
    // until closed
}

Unlike slice range, channel range yields one value (not index).

Directional channels

Catch misuse at compile time:

func submit(str string, stream chan<- string) { /* send + close */ }
func receive(stream <-chan string) { /* range only */ }

stream := make(chan string) // bidirectional at creation
go submit(str, stream)      // converts to send-only param
receive(stream)             // converts to recv-only param
Type Allowed
chan T send, recv, close
chan<- T send, close
<-chan T recv only

Done channel

Wait for work without a WaitGroup:

func say(done chan<- struct{}, id int, phrase string) {
    // ... work ...
    done <- struct{}{} // or close(done) for multi-waiter broadcast
}

done := make(chan struct{})
go say(done, 1, "hello")
<-done
done: worker ──► waiter   "finished"

struct{} uses zero bytes of payload—signal only.

Deadlocks

Go runtime detects when all goroutines are blocked:

fatal error: all goroutines are asleep - deadlock!

Common causes:

  • Unbuffered send without receiver
  • Receive without sender
  • WaitGroup counter never reaches zero
  • Mutual wait (A waits B, B waits A)

Nil channels

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

Useful in select: set a case’s channel to nil to disable that case after it closes (see pipelines merge).

Buffered channels

ch := make(chan int, 3) // send up to 3 without a receiver
capacity 3:

send s1 s2 s3  →  buffer full  →  4th send blocks
recv frees a slot

Buffer size is a capacity / decoupling choice, not free async magic.

Runnable example

go mod init example && go run .
package main

import "fmt"

func main() {
    in := make(chan string)
    go func() {
        for _, w := range []string{"one", "two", "", "four"} {
            in <- w
        }
        close(in)
    }()
    for w := range in {
        if w != "" {
            fmt.Print(w, " ")
        }
    }
    fmt.Println()
    // closed receives
    v, ok := <-in
    fmt.Printf("after close: %q ok=%v\n", v, ok)
}

Expected:

one two four 
after close: "" ok=false

Try next: Make receive take <-chan string and try to close inside it—confirm compile error.