07 — Channels

How goroutines talk to each other: channels, closing and ranging, select, and small pipelines — up to a fan-in merge. Where module 06 shared data under a lock, here goroutines pass data between themselves. Every example is live; edit and Run.

1. Send and receive

A channel is a typed, thread-safe queue. You make one with make, and move values with the <- arrow, which points the way the value travels: ch <- v sends, v := <-ch receives.

By default a channel is unbuffered: a send blocks until some goroutine is ready to receive. So the two goroutines meet, hand over the value, and continue — the channel carries data and synchronises timing at once.

package main

import "fmt"

func main() {
    messages := make(chan string)

    go func() { messages <- "ping" }()

    msg := <-messages
    fmt.Println(msg)
}

Output:

ping

The send runs in a goroutine because an unbuffered send blocks until someone receives — doing it on the main goroutine with no receiver yet would deadlock.

2. Closing and ranging

A sender signals "no more values" by closing the channel with close(ch). A receiver usually drains it with for ... range, which keeps receiving until the channel is closed, then ends. Two rules keep this safe: only the sender closes (once), and you never send after closing.

So a producer sends everything, then closes. Here Generate emits 1..n from a goroutine and closes; Collect ranges until closed:

package main

import "fmt"

func Generate(n int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := 1; i <= n; i++ {
            ch <- i
        }
    }()
    return ch
}

func Collect(ch <-chan int) []int {
    var out []int
    for v := range ch {
        out = append(out, v)
    }
    return out
}

func main() {
    fmt.Println(Collect(Generate(5)))
}

Output:

[1 2 3 4 5]

The return type <-chan int is a receive-only channel: callers may read but not send or close. If the producer forgets to close, a range over it blocks forever — a deadlock.

3. Pipelines

Because a stage can take a channel in and return a channel out, you can chain stages, each running concurrently. Square reads each value, sends its square, and closes its output when the input is exhausted:

package main

import "fmt"

func Generate(n int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := 1; i <= n; i++ {
            ch <- i
        }
    }()
    return ch
}

func Square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for v := range in {
            out <- v * v
        }
    }()
    return out
}

func Collect(ch <-chan int) []int {
    var out []int
    for v := range ch {
        out = append(out, v)
    }
    return out
}

func main() {
    fmt.Println(Collect(Square(Generate(4))))
}

Output:

[1 4 9 16]

Closing propagates down the line: when Generate closes, Square's range ends, which triggers its own close(out), which ends Collect's loop.

4. select, and fan-in

select waits on several channel operations at once and proceeds with whichever is ready first (if several are ready, it picks one at random). It's how you merge streams.

Fan-in merges two inputs into one output and closes it once both inputs are done. The elegant trick: a receive on a closed channel returns ok == false, and a receive on a nil channel blocks forever — so when an input closes, set its variable to nil and the select stops choosing it. Loop until both are nil.

package main

import (
    "fmt"
    "sort"
)

func Generate(n int) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        for i := 1; i <= n; i++ {
            ch <- i
        }
    }()
    return ch
}

func Square(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for v := range in {
            out <- v * v
        }
    }()
    return out
}

func FanIn(a, b <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for a != nil || b != nil {
            select {
            case v, ok := <-a:
                if !ok {
                    a = nil
                    continue
                }
                out <- v
            case v, ok := <-b:
                if !ok {
                    b = nil
                    continue
                }
                out <- v
            }
        }
    }()
    return out
}

func main() {
    merged := FanIn(Generate(3), Square(Generate(3)))
    var got []int
    for v := range merged {
        got = append(got, v)
    }
    sort.Ints(got) // merge order is nondeterministic; sort for a stable result
    fmt.Println(got)
}

Output:

[1 1 2 3 4 9]

Why we sort before printing. Fan-in interleaves two streams, and the exact order depends on the scheduler — it changes run to run. The set of values is always the same ({1,2,3} from one input, {1,4,9} from the other), so sorting gives one stable output to check against. Remove the sort.Ints and run a few times to see the order shift.

Recap

OperationSyntax
Make / send / receivemake(chan T) · ch <- v · v := <-ch
Close (sender only)close(ch)
Drain until closedfor v := range ch
Closed checkv, ok := <-ch
Wait on manyselect { case ... }
Disable a select caseset its channel to nil

Back to 06 — Goroutines · all modules.