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

Channels carry values between goroutines A worker pool: main sends jobs into one channel, three workers take from it, and results come back on another. main jobs <- 1..6 jobs chan worker 1 worker 2 worker 3 results chan ch <- v send (waits for a receiver) · v := <-ch receive close(jobs) tells workers 'no more'; select picks whichever channel is ready first
A worker pool: main sends jobs into one channel, three workers take from it, and results come back on another.

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 ch receives until the channel is closed, then stops.

  • Only the sender should close a channel. Sending on a closed channel panics.

  • <-chan int means receive-only, chan<- int send-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. A range over 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.