Go Guide

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
  1. 01Goroutines
  2. 02WaitGroup: waiting for a group
  3. 03Channels
  4. 04select: waiting on several channels
  5. 05Mutex: protecting shared memory
  6. 06Patterns you will use
  7. 07More examples
  8. 08The full program
  9. 09Summary
  10. 10Check your understanding
  11. 11Practice in Go

Goroutines#

Put go before a function call and it runs concurrently:

Go
go fetch(url) // returns immediately; fetch runs on its own

A 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#

Go
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 zero

Call 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:

Go
ch := make(chan int)
go func() { ch <- 42 }() // send
v := <-ch                // receive: waits until a value arrives

Unbuffered vs buffered

KindCreated withSend blocks until…Use for
unbufferedmake(chan T)a receiver takes the valuehanding off and synchronizing
bufferedmake(chan T, n)there is free space in the bufferqueues, smoothing bursts

Trace an unbuffered handoff:

TimeGoroutine AGoroutine B
1ch <- 1 (blocks: no receiver)busy
2still blockedv := <-ch
3send completes, continuesreceives 1, continues

Closing and ranging

The sender calls close(ch) to say "no more values". Receivers can then detect it:

Go
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:

Go
func produce(out chan<- int) { ... } // can only send
func consume(in <-chan int)  { ... } // can only receive

This 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:

Go
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 after d, which gives you timeouts.
  • A default case 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:

StepGoroutine AGoroutine Bcount
1reads 55
2reads 55
3writes 66
4writes 66 (one update lost)

That is a data race. A sync.Mutex lets only one goroutine into the critical section at a time:

Go
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:

pingpong.go
Go
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)
	}
}
Output
ping
pong
ping
pong
ping
pong

Without 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.

pool.go
Go
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 > p && !peak.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)
}
Output
timed out waiting
results: [1 4 9 16 25 36 49 64]
never more than 3 at once: true

atomic.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#

concurrency.go
Go
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)
}
Output
sum of squares: 285
count: 1000
2 2 a b
nothing ready yet
fast result
slowAnswer: context deadline exceeded

Trace 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; main returning ends them all, so wait with a WaitGroup or channels.
  • Unbuffered channels synchronize; buffered channels queue up to their capacity.
  • The sender closes; range over a channel stops at close; direction types prevent misuse.
  • select waits on several channels, with time.After for timeouts and default to avoid blocking.
  • Shared variables need a sync.Mutex; find races with -race.
  • Use context to cancel work and set deadlines.

Check your understanding

Answer before scrolling back up. If you miss one, reread the matching section, then try again.

  1. Q1.main starts go work() and then returns immediately. What happens to work?

  2. Q2.What happens when you send on an unbuffered channel and nobody is receiving?

  3. Q3.Who should close a channel?

  4. Q4.100 goroutines each run count++ on a shared int without a lock. The result is:

  5. Q5.In a select with a default case, 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.