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
| Operation | Syntax |
|---|---|
| Make / send / receive | make(chan T) · ch <- v · v := <-ch |
| Close (sender only) | close(ch) |
| Drain until closed | for v := range ch |
| Closed check | v, ok := <-ch |
| Wait on many | select { case ... } |
| Disable a select case | set its channel to nil |
Back to 06 — Goroutines · all modules.