Session 17 · Channels and select#
The big idea#
Locks protect shared data. Channels avoid sharing in the first place: goroutines pass values to each other through a pipe. One goroutine sends, another receives, and the pipe makes them meet at exactly the right moment. This is Go’s famous idea: don’t communicate by sharing memory; share memory by communicating.
Everyday example: a conveyor belt in a factory. One worker puts items on the belt, another takes them off. They never touch each other’s tools, and if the belt is full the first worker waits.
In plain words
ch := make(chan int) makes a channel. ch <- 5 sends, v := <-ch receives. An unbuffered channel
makes sender and receiver wait for each other; make(chan int, 3) is a buffered channel that
holds 3 values before a sender has to wait. close(ch) says “no more values”. select waits on
several channels at once.
Reach for it when:
goroutines hand work or results to each other (pipelines, worker pools)
you need a timeout or cancel signal
you want to wait for the first of several things
Send and receive#
basics.go
package main
import "fmt"
func main() {
ch := make(chan string)
go func() {
ch <- "ping" // send: waits until someone receives
}()
msg := <-ch // receive: waits until someone sends
fmt.Println(msg)
// buffered: holds up to 2 values without a receiver
buf := make(chan int, 2)
buf <- 1
buf <- 2
fmt.Println(len(buf), cap(buf))
fmt.Println(<-buf, <-buf)
}
ping
2 2
1 2
The unbuffered send ch <- "ping" could only finish when main was ready to receive. That meeting
is called a handshake. The buffered channel took two values with nobody receiving.
A pipeline#
pipeline.go: one stage generates numbers, the next squares them. Each stage closes its output when done.
package main
import "fmt"
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // closing tells the receiver "no more values"
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in { // loop ends when in is closed
out <- n * n
}
}()
return out
}
func main() {
for result := range square(generate(1, 2, 3, 4)) {
fmt.Println(result)
}
}
1
4
9
16
for n := range chreceives until the channel is closed, then stops.Only the sender should close a channel. Sending on a closed channel panics.
<-chan intmeans receive-only,chan<- intsend-only. The compiler enforces the direction.
A worker pool#
workerpool.go: three workers share one jobs queue and report on a results channel. This is the
usual way to limit how much runs at once.
package main
import (
"fmt"
"sort"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- fmt.Sprintf("job %d -> %d", j, j*j)
}
}
func main() {
jobs := make(chan int, 10)
results := make(chan string, 10)
var wg sync.WaitGroup
for id := 1; id <= 3; id++ { // three workers share one queue
wg.Add(1)
go worker(id, jobs, results, &wg)
}
for j := 1; j <= 6; j++ {
jobs <- j
}
close(jobs)
wg.Wait()
close(results)
var lines []string
for r := range results {
lines = append(lines, r)
}
sort.Strings(lines) // workers finish in any order, so sort for stable output
for _, l := range lines {
fmt.Println(l)
}
}
job 1 -> 1
job 2 -> 4
job 3 -> 9
job 4 -> 16
job 5 -> 25
job 6 -> 36
Which worker takes which job changes from run to run, so the program collects the lines and sorts them before printing. That keeps the output stable.
select: waiting on many things#
select.go: take whichever channel is ready first, with a timeout and a “don’t wait” case.
package main
import (
"fmt"
"time"
)
func main() {
fast := make(chan string)
slow := make(chan string)
go func() { time.Sleep(50 * time.Millisecond); fast <- "fast answer" }()
go func() { time.Sleep(400 * time.Millisecond); slow <- "slow answer" }()
// take whichever is ready first, but give up after 200ms
for i := 0; i < 2; i++ {
select {
case m := <-fast:
fmt.Println(m)
case m := <-slow:
fmt.Println(m)
case <-time.After(200 * time.Millisecond):
fmt.Println("timeout")
}
}
// default: don't wait at all
empty := make(chan int)
select {
case v := <-empty:
fmt.Println(v)
default:
fmt.Println("nothing ready")
}
}
fast answer
timeout
nothing ready
The first loop turn gets the fast answer at 50 ms. The second turn waits for either the slow answer
(due at 400 ms) or the 200 ms timer, and the timer wins. default makes select non-blocking.
When it goes wrong: deadlock#
deadlock.go
package main
import "fmt"
func main() {
ch := make(chan int)
ch <- 1 // nobody is receiving: main waits forever
fmt.Println(<-ch)
}
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
<course>/17-channels/deadlock.go:7 +0x36
Nobody could ever receive, so Go detects that every goroutine is stuck and stops with a clear message.
The line [chan send] tells you what main was waiting on.
Common mistakes#
Sending on an unbuffered channel with no receiver. Deadlock.
Forgetting to
close. Arangeover the channel never ends.Closing from the receiver side, or closing twice. Both panic.
Goroutine leaks. A goroutine blocked on a channel nobody reads lives forever.
Using a channel where a mutex is simpler. A single counter needs an atomic or a lock, not a goroutine.
Assuming result order. Workers finish in any order; sort or index results.
Hands-on exercises#
Exercise 1 · Ping-pong. One goroutine receives a number on ping, prints it and replies on pong with the number plus one. main sends 1, 3 and 5 and prints each reply.
Solution
package main
import "fmt"
func main() {
ping := make(chan int)
pong := make(chan int)
go func() {
for n := range ping {
fmt.Println("pong", n)
pong <- n + 1
}
}()
for n := 1; n <= 5; n += 2 {
ping <- n
fmt.Println("reply", <-pong)
}
close(ping) // lets the goroutine's range loop end
}
pong 1
reply 2
pong 3
reply 4
pong 5
reply 6
Each send waits for the receiver and each receive waits for the sender, so the order is fixed.
Exercise 2 · Sum with a channel. Split [1..10] into two halves, sum each in a goroutine, and send the partial sums to a channel. Print the total.
Solution
package main
import "fmt"
func sum(nums []int, out chan<- int) {
total := 0
for _, n := range nums {
total += n
}
out <- total
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
out := make(chan int)
go sum(nums[:5], out)
go sum(nums[5:], out)
a, b := <-out, <-out
fmt.Println(a+b, "(addition does not care which half arrived first)")
}
55 (addition does not care which half arrived first)
Exercise 3 · Timeout. Wait up to 100 ms for a slow operation (300 ms). Print timed out if it takes too long.
Solution
package main
import (
"fmt"
"time"
)
func slow() <-chan string {
ch := make(chan string, 1) // buffered, so the sender never blocks forever
go func() {
time.Sleep(300 * time.Millisecond)
ch <- "finished"
}()
return ch
}
func main() {
select {
case r := <-slow():
fmt.Println(r)
case <-time.After(100 * time.Millisecond):
fmt.Println("timed out")
}
}
timed out
For real cancellation across many goroutines, look at context.WithTimeout.