Channels, and the select statement
Goroutines run independently.
Goroutines run independently. Channels are how they talk. The slogan Go is famous for is *do not communicate by sharing memory; share memory by communicating*, which sounds like a fortune cookie until you have written both versions of the same program.
A channel is a typed pipe
ch := make(chan int) // unbufferedch <- 42 // sendv := <-ch // receive
The arrow always points in the direction the value travels. ch <- v puts a value in, <-ch takes one out.
func main() {ch := make(chan string)go func() {ch <- "hello from the goroutine"}()msg := <-ch // blocks until something arrivesfmt.Println(msg)}
No time.Sleep this time. The receive blocks until the send happens, which is synchronisation and message passing in one operation.
If you know JavaScript
The closest JavaScript analogy is an async generator or a stream, with one crucial difference: a channel receive blocks the goroutine. There is no callback, no await, and no promise. The code reads top to bottom, and the runtime parks the goroutine while it waits.
If you have used postMessage between workers, the mental model is closer: separate lines of execution passing values, except channels are typed and synchronous by default.
Unbuffered channels are a handshake
An unbuffered channel has no room. A send blocks until a receiver is ready, and a receive blocks until a sender is ready. The two goroutines meet.
ch := make(chan int)ch <- 1 // deadlock: nobody is receiving
fatal error: all goroutines are asleep - deadlock!
Go detects when every goroutine is blocked and stops the program rather than hanging forever. This is a feature, and it is how you will find most channel bugs.
Buffered channels have room
ch := make(chan int, 3) // capacity 3ch <- 1 // does not blockch <- 2ch <- 3ch <- 4 // now it blocks: the buffer is full
A send blocks only when the buffer is full; a receive blocks only when it is empty.
Watch out
Buffering is not a performance trick to sprinkle on. The capacity is a design decision: it is how many items may be in flight before the producer must slow down. That back pressure is usually what you want, because an unbounded queue in front of a slow consumer is just a memory leak with extra steps.
Choose zero when you want a handshake, and a specific number when you know how much slack the system should tolerate. "I made it 100 so it would not block" is how a queue silently grows to 100 and then blocks anyway.
Closing, and ranging
The sender closes a channel to say "no more values".
func produce(ch chan<- int) {for i := 0; i < 5; i++ {ch <- i}close(ch) // only the sender closes}func main() {ch := make(chan int)go produce(ch)for v := range ch { // ends when the channel is closedfmt.Println(v)}}
Receiving from a closed channel returns the zero value immediately. The two-value form tells you which happened:
v, ok := <-chif !ok {// the channel is closed and drained}
Watch out
Three rules that cover every close-related bug:
Only the sender closes. A receiver closing a channel causes the sender to panic on its next send.
Closing twice panics. So does sending on a closed channel.
You do not have to close. A channel that is garbage collected is fine. Close only to signal completion to a range or a select.
Note chan<- int in that signature: a directional channel type, meaning send-only. <-chan int is receive-only. Using them in function parameters makes the data flow obvious and lets the compiler stop a receiver from closing.
select: waiting on several channels
select blocks until one of its cases can proceed.
select {case msg := <-ch1:fmt.Println("from ch1:", msg)case msg := <-ch2:fmt.Println("from ch2:", msg)case ch3 <- "value":fmt.Println("sent to ch3")}
If several are ready it picks one at random, which prevents one busy channel starving another.
A default case makes it non-blocking:
select {case msg := <-ch:fmt.Println(msg)default:fmt.Println("nothing waiting") // does not block}
If you know JavaScript
select is Promise.race, with two differences worth noting. It can wait on sends as well as receives. And the losing cases are not "cancelled", they simply were not chosen: the other channels still hold their values for the next receive. Promise.race leaves the losers running and their results are discarded, which is a frequent source of leaked work in JavaScript.
Timeouts, the most common select
select {case result := <-work:fmt.Println("finished:", result)case <-time.After(2 * time.Second):fmt.Println("timed out")}
time.After returns a channel that delivers a value after the duration. This is the idiomatic timeout, and it is three lines.
Watch out
time.After leaks until it fires. In a tight loop, creating one per iteration piles up timers for the full duration. For a loop, create atime.NewTimer once and reset it, or use a context deadline, which is the next chapter.
Signalling with a done channel
A channel carrying no data, used only to signal, is a common shape:
func worker(done <-chan struct{}) {for {select {case <-done:fmt.Println("stopping")returndefault:// do a unit of work}}}func main() {done := make(chan struct{})go worker(done)time.Sleep(time.Second)close(done) // closing broadcasts to every receivertime.Sleep(10 * time.Millisecond)}
Closing is the broadcast mechanism: every goroutine blocked on <-donewakes up at once, because a closed channel always yields immediately. Sending a value would wake only one.
This pattern is so common that the standard library wrapped it, and that wrapper is context, which is chapter sixteen. Understand the done channel first and context becomes obvious rather than magical.
The nil channel, and why it is useful
Sending or receiving on a nil channel blocks forever. That sounds like a pure bug and it has one real use: disabling a select case.
for {select {case v, ok := <-in:if !ok {in = nil // disable this case, keep the loop alivecontinue}process(v)case <-done:return}}
Once in is nil its case can never be chosen, so the loop keeps serving donewithout spinning on a closed channel returning zeros forever.
Worker pools
The canonical channel program: a fixed number of workers pulling from a jobs channel.
func worker(id int, jobs <-chan int, results chan<- string,wg *sync.WaitGroup) {defer wg.Done()for j := range jobs { // ends when jobs is closedresults <- fmt.Sprintf("worker %d did job %d", id, j)}}func main() {jobs := make(chan int, 100)results := make(chan string, 100)var wg sync.WaitGroupfor w := 1; w <= 3; w++ {wg.Add(1)go worker(w, jobs, results, &wg)}for j := 1; j <= 9; j++ {jobs <- j}close(jobs) // tells the workers no more are comingwg.Wait() // wait for them to finishclose(results) // now safe to closefor r := range results {fmt.Println(r)}}
The ordering is the whole lesson. Close jobs so the workers' range ends.Wait for the workers. Then close results, because closing it earlier would panic a worker still writing.
If you know JavaScript
Compare this to the semaphore from chapter thirteen. Both bound concurrency. The semaphore starts a goroutine per item and limits how many run; the worker pool starts a fixed number and feeds them. The pool is better when goroutine startup matters or when each worker holds an expensive resource, like a database connection. The semaphore is simpler and usually enough.
Project 13: a pipeline with cancellation
Build a three-stage pipeline where each stage is a goroutine connected by channels, and where the whole thing can be stopped early. This is the shape of a great deal of real Go.
package mainimport ("fmt""strings""sync""time")// Stage 1: produce lines. Returns a channel rather than taking one,// which is the convention: the producer owns and closes what it// creates.func generate(done <-chan struct{}, lines []string) <-chan string {out := make(chan string)go func() {defer close(out)for _, l := range lines {select {case out <- l:case <-done:return // stop early without leaking}}}()return out}// Stage 2: normalise.func normalise(done <-chan struct{}, in <-chan string) <-chan string {out := make(chan string)go func() {defer close(out)for l := range in {select {case out <- strings.ToLower(strings.TrimSpace(l)):case <-done:return}}}()return out}// Stage 3: count words, fanned out across several goroutines.func count(done <-chan struct{}, in <-chan string,workers int) <-chan int {out := make(chan int)var wg sync.WaitGroupfor i := 0; i < workers; i++ {wg.Go(func() {for l := range in {time.Sleep(10 * time.Millisecond) // pretend it is slowselect {case out <- len(strings.Fields(l)):case <-done:return}}})}// One goroutine whose only job is to close out once every worker is// finished. Without this, nothing ever closes out and main hangs.go func() {wg.Wait()close(out)}()return out}func main() {lines := []string{" The quick brown fox ", "jumps over", "THE LAZY DOG","and then", "runs away quickly", "into the woods",}done := make(chan struct{})defer close(done) // every stage stops when main returnsstart := time.Now()total := 0pipeline := count(done, normalise(done, generate(done, lines)), 3)for n := range pipeline {total += n}fmt.Printf("%d words in %v\n", total,time.Since(start).Round(time.Millisecond))}
Run it and note the time: six lines at ten milliseconds each would be sixty milliseconds serially, and with three workers it is closer to twenty.
Then do the experiment that proves the cancellation works. Change the loop inmain to stop after the first two results:
n := 0for range count(done, normalise(done, generate(done, lines)), 3) {n++if n == 2 {break}}
The defer close(done) fires, every stage's select takes the <-donecase, and all the goroutines exit. Remove the done channel entirely and the producer blocks forever trying to send into a pipeline nobody is reading: a goroutine leak, which in a long-running server is a slow memory leak you will eventually have to find.
Run the whole thing with -race as well. It is clean, because every value moves through a channel and nothing is shared.
Your turn
Add a fourth stage between normalise and count that drops any line shorter than three words, and confirm the total changes. Then count how many goroutines the program runs at its peak. If your answer is not "one per stage, plus the workers, plus the closer", re-read count: that little goroutine whose only job is wg.Wait() then close(out) is the piece people forget, and forgetting it is a deadlock rather than a wrong answer.
Assignment
Commit your work with the message Channels - Assignment One, exactly as written, so the leaderboard can score it.
Try it
A worker pool that shuts down cleanly, which is the part people skip.
In part2-ideas/14-channels:
One. A pool of five workers reading jobs from one channel and writing results to another. Close the jobs channel to end it, and range in the workers.
Two. A select with a timeout, so a job that takes too long is abandoned rather than waited on.
Three. A done channel that stops everything from outside, and a run where you trigger it halfway. No goroutine may be left behind: check with
fmt.Println(runtime.NumGoroutine())
Four. Send on a closed channel on purpose, read the panic, and write inNOTES.md the rule about who closes a channel.
Spot a typo? Have an idea?
Help us improve this lesson. One click opens a GitHub issue with the lesson URL pre-filled, suggest clearer wording, report a bug, or request more depth. The course keeps improving thanks to learners like you.
Suggest an improvement on GitHub