Goroutines and channels
Starting work that runs at the same time, sending values between the parts instead of sharing them, and the two tools for waiting: a WaitGroup and a closing channel.
go, and the work runs beside you
Putting go in front of a call starts it on its own goroutine — a function running at the same time as the one that started it. Goroutines are cheap: thousands are ordinary, and the runtime schedules them onto the machine's cores without you managing threads.
The catch is the same as for any concurrency: the program does not wait for a goroutine it started, and the main function ending ends the program, running goroutine or not. Something has to wait — and that is what the rest of this chapter is about.
Channels move values between goroutines
A channel is a pipe with a type: jobs := make(chan int). One goroutine sends — jobs <- 7 — another receives — n := <-jobs — and the send blocks until somebody is ready, unless the channel has a buffer: make(chan int, 10) holds ten values before anyone receives.
Go's advice is to share memory by communicating rather than the other way round. A value sent on a channel is owned by the receiver; nothing needs a lock, because there is nothing shared. When several goroutines are done, closing the channel is how you say so: close(done) lets a receiver's range loop finish naturally instead of blocking forever.
Waiting, together or in turn
sync.WaitGroup counts work in flight: wg.Add(1) before starting, wg.Done() when a goroutine finishes — usually deferred — and wg.Wait() where the program needs everything to be done. It is the usual way to wait for a batch of goroutines and then close their channel.
select waits on several channels at once and runs whichever is ready, which is how timeouts are written: one case for the value, one for time.After(...). When the parts of a program do have to share one value, sync.Mutex with Lock and Unlock is the other tool — but reach for it second: a channel is usually the simpler design.
Three workers, one collector
package main
import (
"fmt"
"sort"
"sync"
)
func square(n int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
results <- n * n
}
func main() {
results := make(chan int)
var wg sync.WaitGroup
for n := 1; n <= 5; n++ {
wg.Add(1)
go square(n, results, &wg)
}
// Close once every worker has finished, so the range below can end.
go func() {
wg.Wait()
close(results)
}()
var squares []int
for value := range results {
squares = append(squares, value)
}
sort.Ints(squares)
fmt.Println("squares:", squares)
}Five jobs, three workers
Run it, then raise the job count and lower the worker count. Notice that the totals never change — only how many jobs each worker picks up.
An unbuffered send blocks until somebody receives, and a range over a channel blocks until it is closed. Most Go deadlocks are one of those two facts forgotten — the program is not stuck, it is waiting exactly as written.
The scheduler and the channel's rules
Goroutines are multiplexed onto operating-system threads by the Go runtime. A goroutine that blocks on a channel, a lock or a system call is parked, and the thread runs another one — which is why ten thousand goroutines are cheap and ten thousand threads are not. GOMAXPROCS decides how many run Go code at once.
A channel is a queue with a lock and lists of waiting senders and receivers. An unbuffered channel has no room, so a send hands its value straight to a waiting receiver. A buffered one stores up to its capacity and blocks only when full. Closing marks the queue finished: receives drain what is left, then return the zero value immediately.
select tries every channel operation, picks one that can proceed, and runs its case. If several can, it picks at random. That randomness is deliberate: it keeps a program from depending on a scheduling order the runtime never promised.
A worked deadlock
A small deadlock is worth reading once. An unbuffered results channel, a worker that sends to it, and a main that waits on wg.Wait() before reading: the worker blocks on the send because nobody is receiving, Done never runs, and Wait never returns. Nothing is broken; each part is waiting exactly as written. The fix is to start the collector before the workers, or to give the channel a buffer large enough for every result. When a Go program hangs, look for the send with no receiver and the wait with no Done — those two cover most of them.
A select with a default case never blocks: if no channel is ready, the default runs. That is how a non-blocking check is written, and it is also how a loop can spin at full speed if the default does nothing — a busy wait is easy to write by accident.
Never close a channel from the receiving side, and never close it twice: a send on a closed channel panics. Call wg.Add before starting the goroutine, not inside it, or Wait can return before the work is counted. And a goroutine blocked forever on a send is a leak the program will not report — give every send a receiver, or a buffer and a reason.
Try these next
- Run the worker example with one worker and then five, and compare the sorted output.
- Add a buffered results channel and explain whether the collector goroutine is still needed.
- Write two producers feeding one channel, and close it only after both have finished.
Two producers, one total
Run it, then add a third producer with its own numbers. Change the multiplier and check the total without reading the values list first.
Check what you learned
One question at a time. A wrong answer explains itself — take another go.
Part 1 of 5
What does the program do when main returns while a goroutine is still running?
