Go, chapter by chapter · Lesson 11 of 62 · about 60 min
Concurrency
Goroutines, channels, select, WaitGroups and mutexes: how Go runs many things at once safely, and the patterns that real services use.
On this page · 11 sections
Goroutines#
Put go before a function call and it runs concurrently:
go fetch(url) // returns immediately; fetch runs on its ownA goroutine is not an operating-system thread. It starts with a few kilobytes of stack and Go's scheduler multiplexes thousands, even millions, of them onto a handful of threads. That is why it is normal in Go to start one per request or per connection.
The catch: when main returns, the program exits and every goroutine dies with it. You must wait for the work you started.
WaitGroup: waiting for a group#
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1) // one more to wait for
go func() {
defer wg.Done() // this one is finished
fetch(url)
}()
}
wg.Wait() // blocks until the counter is back to zeroCall Add before starting the goroutine, never inside it, or Wait may run before the counter goes up.
Channels#
A channel is a typed pipe between goroutines:
ch := make(chan int)
go func() { ch <- 42 }() // send
v := <-ch // receive: waits until a value arrivesUnbuffered vs buffered
| Kind | Created with | Send blocks until… | Use for |
|---|---|---|---|
| unbuffered | make(chan T) | a receiver takes the value | handing off and synchronizing |
| buffered | make(chan T, n) | there is free space in the buffer | queues, smoothing bursts |
Trace an unbuffered handoff:
| Time | Goroutine A | Goroutine B |
|---|---|---|
| 1 | ch <- 1 (blocks: no receiver) | busy |
| 2 | still blocked | v := <-ch |
| 3 | send completes, continues | receives 1, continues |
Closing and ranging
The sender calls close(ch) to say "no more values". Receivers can then detect it:
v, ok := <-ch // ok is false once the channel is closed and empty
for v := range ch { // loops until the channel is closed
fmt.Println(v)
}See it: when sends block
Set the buffer size to 0, then to 2, and compare when the producer has to wait.
Channel direction
Function parameters can restrict a channel to one direction, and the compiler enforces it:
func produce(out chan<- int) { ... } // can only send
func consume(in <-chan int) { ... } // can only receiveThis documents intent and prevents bugs like a consumer closing the channel.
select: waiting on several channels#
select waits until one of its cases can proceed, then runs that case:
select {
case msg := <-results:
fmt.Println(msg)
case <-time.After(500 * time.Millisecond):
fmt.Println("timeout")
}- If several are ready, one is chosen at random.
time.After(d)returns a channel that receives afterd, which gives you timeouts.- A
defaultcase runs immediately when nothing is ready, making the select non-blocking.
Mutex: protecting shared memory#
Sometimes goroutines really do share a variable, such as a counter or cache. count++ is three steps (read, add, write), and two goroutines can interleave:
| Step | Goroutine A | Goroutine B | count |
|---|---|---|---|
| 1 | reads 5 | 5 | |
| 2 | reads 5 | 5 | |
| 3 | writes 6 | 6 | |
| 4 | writes 6 | 6 (one update lost) |
That is a data race. A sync.Mutex lets only one goroutine into the critical section at a time:
var mu sync.Mutex
mu.Lock()
count++
mu.Unlock()Run tests with go test -race and Go reports races it observes.
Patterns you will use#
Pipeline: stage goroutines connected by channels (generate, then square, then sum).
Worker pool: a fixed number of workers read jobs from one channel and write results to another. This bounds how much work runs at once.
Fan-out, fan-in: many goroutines produce results into one channel; one loop collects them.
Cancellation: context.Context carries a deadline or cancel signal through your calls. Every HTTP request has one (r.Context()), and well-behaved functions stop when ctx.Done() closes.
More examples#
The book's pinger, ponger and printer
The book's channel example has two goroutines sending "ping" and "pong" into the same channel and a third printing what arrives. The original loops forever with time.Sleep; this version sends a fixed number of messages so it ends. Each player also has its own turn channel and hands the turn to the other after sending, which forces strict alternation:
package main
import (
"fmt"
"sync"
)
func player(name string, myTurn <-chan struct{}, next chan<- struct{}, out chan<- string, n int, wg *sync.WaitGroup) {
defer wg.Done()
for range n {
<-myTurn // wait until it is our turn
out <- name // blocks until the printer receives it (unbuffered)
next <- struct{}{} // hand the turn to the other player
}
}
func main() {
out := make(chan string)
pingTurn := make(chan struct{}, 1) // buffered, so the final hand-off never blocks
pongTurn := make(chan struct{}, 1)
pingTurn <- struct{}{} // ping goes first
var wg sync.WaitGroup
wg.Add(2)
go player("ping", pingTurn, pongTurn, out, 3, &wg)
go player("pong", pongTurn, pingTurn, out, 3, &wg)
go func() {
wg.Wait()
close(out) // both players are done: nothing more will arrive
}()
for msg := range out {
fmt.Println(msg)
}
}ping
pong
ping
pong
ping
pongWithout the turn channels, the order of pings and pongs would depend on the scheduler and could change from run to run. That is normal for goroutines: if order matters, you must enforce it with channels or locks.
Timeouts and a worker pool
select with time.After gives up after a deadline; a worker pool limits how many jobs run at once. Results come back in completion order, so they are collected into a slice indexed by job number to get a deterministic result.
package main
import (
"fmt"
"sync"
"sync/atomic"
"time"
)
func slowSquare(n int) int {
time.Sleep(time.Duration(10-n) * time.Millisecond) // later jobs finish sooner
return n * n
}
func main() {
// 1. A timeout with select.
result := make(chan int, 1)
go func() { result <- slowSquare(1) }() // takes about 9ms
select {
case v := <-result:
fmt.Println("got", v)
case <-time.After(2 * time.Millisecond):
fmt.Println("timed out waiting")
}
// 2. A pool of 3 workers processing 8 jobs.
type job struct{ idx, n int }
jobs := make(chan job)
out := make([]int, 8)
var wg sync.WaitGroup
var busy, peak atomic.Int32
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
now := busy.Add(1)
for p := peak.Load(); now > ppeak.CompareAndSwap(p, now); p = peak.Load() {
}
out[j.idx] = slowSquare(j.n) // each worker writes a different index: no race
busy.Add(-1)
}
}()
}
for i := range 8 {
jobs <- job{i, i + 1}
}
close(jobs)
wg.Wait()
fmt.Println("results:", out)
fmt.Println("never more than 3 at once:", peak.Load() <= 3)
}timed out waiting
results: [1 4 9 16 25 36 49 64]
never more than 3 at once: trueatomic.Int32 (Go 1.19) updates a counter safely from many goroutines without a mutex. Writing to different indexes of a slice from different goroutines is safe; writing to the same index, or appending to a shared slice, is a data race.
The full program#
package main
import (
"context"
"fmt"
"sync"
"time"
)
// Stage 1: send 1..n, then close.
func generate(n int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 1; i <= n; i++ {
out <- i
}
}()
return out
}
// Stage 2: a pool of workers squaring numbers.
func squareAll(in <-chan int, workers int) <-chan int {
out := make(chan int, 10)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for v := range in {
out <- v * v
}
}()
}
go func() {
wg.Wait()
close(out) // only after every worker is done sending
}()
return out
}
func slowAnswer(ctx context.Context) (int, error) {
select {
case <-time.After(2 * time.Second):
return 42, nil
case <-ctx.Done():
return 0, ctx.Err()
}
}
func main() {
// Pipeline + worker pool
sum := 0
for sq := range squareAll(generate(9), 3) {
sum += sq
}
fmt.Println("sum of squares:", sum)
// Mutex
var mu sync.Mutex
count := 0
var wg sync.WaitGroup
for range 1000 {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("count:", count)
// Buffered channel as a queue
queue := make(chan string, 2)
queue <- "a"
queue <- "b" // no receiver needed: buffer has room
fmt.Println(len(queue), cap(queue), <-queue, <-queue)
// select with timeout and default
results := make(chan string)
select {
case r := <-results:
fmt.Println(r)
default:
fmt.Println("nothing ready yet")
}
go func() {
time.Sleep(50 * time.Millisecond)
results <- "fast result"
}()
select {
case r := <-results:
fmt.Println(r)
case <-time.After(time.Second):
fmt.Println("timeout")
}
// Cancellation with context
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, err := slowAnswer(ctx)
fmt.Println("slowAnswer:", err)
}sum of squares: 285
count: 1000
2 2 a b
nothing ready yet
fast result
slowAnswer: context deadline exceededTrace the pipeline: generate sends 1 to 9 and closes; three workers square them in whatever order they get them; the closer goroutine waits for all workers before closing out, so main's range ends exactly when every square has been summed: 1 + 4 + 9 + … + 81 = 285.
Summary#
go f()starts a goroutine;mainreturning ends them all, so wait with a WaitGroup or channels.- Unbuffered channels synchronize; buffered channels queue up to their capacity.
- The sender closes;
rangeover a channel stops at close; direction types prevent misuse. selectwaits on several channels, withtime.Afterfor timeouts anddefaultto avoid blocking.- Shared variables need a
sync.Mutex; find races with-race. - Use
contextto cancel work and set deadlines.
Check your understanding
Answer before scrolling back up. If you miss one, reread the matching section, then try again.
Q1.
mainstartsgo work()and then returns immediately. What happens to work?Q2.What happens when you send on an unbuffered channel and nobody is receiving?
Q3.Who should close a channel?
Q4.100 goroutines each run
count++on a shared int without a lock. The result is:Q5.In a
selectwith adefaultcase, what happens if no channel is ready?
Practice in Go
Write a table test first, then the solution. On LeetCode, choose Go as the language. Open a hint only after 15 minutes stuck.
- Biggest home pagebuild
Fetch several URLs at once with
http.Get, measure each body's length, and print the largest. Use a channel of results.Show hint
One goroutine per URL sends a
struct{url string; size int}to a channel; main receives len(urls) results. - Parallel sumbuild
Split 10 million ints across
runtime.NumCPU()goroutines, sum each part, combine, and benchmark against a plain loop.Show hint
Each goroutine writes to its own slot in a results slice, so no lock is needed.
- Rate limiterbuild
Process 10 jobs but never more than 2 per second, using
time.Tick.Show hint
Receive from the ticker channel before each job.
- Timeout with contextbuild
Call a function that sleeps 2 seconds, but give up after 500ms using
context.WithTimeout.Show hint
select on
ctx.Done()and the result channel.
Finished this lesson?
Mark it done to track your progress. It is saved in this browser only.