Chapter 12 showed how Go treats errors as values. This chapter closes Section 2 with the other major concept every Go backend developer needs: concurrency. We will look at goroutines, channels, sync.WaitGroup, and sync.Mutex. By the end, you will understand exactly why an in-memory task store needs a mutex lock, and how a Go HTTP server handles hundreds of simultaneous requests without you writing a single thread.
Go builds concurrency directly into the language. You do not import a threading library or configure a thread pool. You simply prefix a function call with go to run it concurrently. Behind the scenes, the Go runtime multiplexes many goroutines onto a small number of OS threads.

This chapter covers three tools that work together:
go func()sync.WaitGroup and sync.Mutex — tools for waiting and protecting shared stateCheck out the finish branch to follow along:
bashgit checkout 13-goroutines-and-concurrency-finish
Goroutine. A function running concurrently with the rest of the program. It is not an OS thread. The Go runtime schedules many goroutines onto a small number of OS threads. Starting one only costs a few kilobytes of memory, so you can easily run thousands of them at once.
Channel. A typed pipe for sending values between goroutines. Calling make(chan int) creates one. A goroutine sends a value with ch <- value, and a receiver reads it with value := <-ch. Channels synchronize your code by making sends and receives meet.
Unbuffered channel. A channel with no internal queue. A send blocks until another goroutine is ready to receive. The two goroutines meet at the channel. This is useful when you want to hand off a value synchronously.
Buffered channel. A channel with a fixed internal queue, created like make(chan int, 3). Sends succeed without a receiver waiting, as long as the buffer has space. A send only blocks when the buffer is full. This is useful when you want to decouple the sender's pace from the receiver's.
sync.WaitGroup. A counter that lets one goroutine wait for others to finish. You call wg.Add(1) before launching each goroutine, call wg.Done() when it finishes, and call wg.Wait() to block until the count reaches zero.
sync.Mutex. A mutual-exclusion lock. Calling mu.Lock() lets only one goroutine into a protected section of code at a time. Every other caller blocks until mu.Unlock() is called. You use this to protect shared data from concurrent reads and writes.
Data race. This happens when two goroutines access the same memory concurrently, at least one is writing, and there is no synchronization. The result is undefined. You might get a corrupted value, a crash, or silently wrong output. A mutex prevents data races.
When you run an HTTP server using Go's standard library, each incoming request is handled in its own goroutine. The runtime creates the goroutine, runs your handler, and tears it down when the response is sent. You write a single handler function, and the language handles the concurrency.
This design means two requests arriving at the same moment run at the exact same time. If both try to write to the same map, you create a data race. Go's runtime watches for concurrent map writes and usually stops the program with a fatal error when it catches one.
The in-memory task store we build in Chapter 20 holds tasks in a map[int]Task. Without a mutex, two concurrent requests creating a task would race on that map. The fix relies on the pattern covered in this chapter: a sync.Mutex field, locked on every read and write. The code you see here is exactly what makes that future data store safe.
The goroutinesDemo function in concurrency.go launches four goroutines and waits for all of them to finish:
go// concurrency.gofunc goroutinesDemo() {fmt.Println("\n-- Goroutines & WaitGroup --")const workers = 4results := make([]string, workers)var wg sync.WaitGroupfor i := 0; i < workers; i++ {wg.Add(1)go func() {defer wg.Done()results[i] = fmt.Sprintf("worker %d finished", i)}()}wg.Wait()for _, line := range results {fmt.Println(" " + line)}}
Calling go func() launches the function body as a new goroutine. The for loop does not wait for any of them to complete. It keeps running immediately and might finish before any goroutine does. This is why we need wg.Wait().
We call wg.Add(1) to increment the counter before launching each goroutine. Inside the function, defer wg.Done() decrements the counter when the goroutine returns. Finally, wg.Wait() blocks the main goroutine until the counter reaches zero.
Each goroutine writes into its own index slot at results[i]. Because no two goroutines write to the same slot, there is no shared write and no data race. The results are printed in index order after all goroutines finish. This makes the final output deterministic, even though the goroutines ran in an unspecified order.
A common beginner mistake is forgetting wg.Wait(). Without it, the main function can return before the goroutines finish, and their work is lost.
A channel is a typed pipe between goroutines. The channelsDemo function shows both kinds:
go// concurrency.gofunc channelsDemo() {fmt.Println("\n-- Channels --")nums := make(chan int)go func() {for i := 1; i <= 3; i++ {nums <- i}close(nums)}()fmt.Print(" received from unbuffered channel:")for n := range nums {fmt.Printf(" %d", n)}fmt.Println()letters := make(chan string, 3)letters <- "a"letters <- "b"letters <- "c"close(letters)fmt.Print(" received from buffered channel:")for s := range letters {fmt.Printf(" %s", s)}fmt.Println()}
Calling make(chan int) creates an unbuffered channel. Each nums <- i send in the goroutine blocks until the main function is ready to receive. The for n := range nums loop receives values until close(nums) signals there are no more. You must close a channel when you are done sending if the receiver uses range. Otherwise, the range loop will never exit.
Calling make(chan string, 3) creates a buffered channel that holds up to three strings. The three sends complete without a receiver waiting because the buffer absorbs them. A fourth send would block until someone received a value. Because the sends all happen on the main goroutine in this example, no WaitGroup is needed.
The output matches the send order:
received from unbuffered channel: 1 2 3
received from buffered channel: a b c
The SafeCounter type in concurrency.go protects a single integer with a mutex:
go// concurrency.gotype SafeCounter struct {mu sync.Mutexvalue int}func (c *SafeCounter) Inc() {c.mu.Lock()defer c.mu.Unlock()c.value++}func (c *SafeCounter) Value() int {c.mu.Lock()defer c.mu.Unlock()return c.value}
Calling c.mu.Lock() acquires the lock. Any other goroutine calling Lock() will block until the first goroutine calls Unlock(). Using defer c.mu.Unlock() ensures the unlock happens even if the function panics.
The mutexCounterDemo function fires 100 goroutines at the same counter:
go// concurrency.gofunc mutexCounterDemo() {fmt.Println("\n-- Mutex-protected counter --")const goroutines = 100counter := &SafeCounter{}var wg sync.WaitGroupfor i := 0; i < goroutines; i++ {wg.Add(1)go func() {defer wg.Done()counter.Inc()}()}wg.Wait()fmt.Printf(" %d goroutines each +1 -> final total: %d\n", goroutines, counter.Value())}
Without the mutex, 100 goroutines writing value++ concurrently would race. The increment operation is not atomic. Each goroutine reads value, adds one, and writes the result back. If two goroutines read the same value before either writes, one increment is lost. The final total would be less than 100, and the exact number would vary from run to run.
With the mutex, only one goroutine can increment the value at a time. The final total is always exactly 100.

Go ships a built-in race detector. Run your program with:
bashgo run -race .
If two goroutines access the same variable concurrently without synchronization, the detector prints a DATA RACE report. It also provides stack traces showing exactly where the conflict happened. The -race flag works with tests as well:
bashgo test -race ./...
The code on the 13-goroutines-and-concurrency-finish branch passes with no data races reported. The goroutine results use separate slice slots, and the shared counter is fully protected by the mutex.
A good habit is to run go test -race ./... in your continuous integration pipeline. A race condition that survives development will often crash in production under real load.
Goroutine leaks. A goroutine that blocks forever on a channel receive, waiting for a send that never comes, is a leak. The garbage collector never cleans it up. Over time, these leaks add up and consume memory. Channels in Go should usually have a clear path to being closed or having every message received.
Data races on a shared map. Reading and writing the same map from multiple goroutines concurrently is unsafe. The Go runtime usually catches concurrent map access and stops the program with a fatal error. Always protect a map with a mutex if more than one goroutine touches it.
Forgetting to Wait(). If the main function returns before the goroutines finish, the program exits and all background goroutines are killed mid-work. Remember to call wg.Wait() before returning from the function that launched the goroutines.
context.ContextThe context.Context package is closely related to concurrency. A context carries a cancellation signal. When a request is cancelled because a client disconnected or a deadline expired, the context is cancelled. Goroutines working on that request can check this signal and stop early. We will cover context.Context fully in Chapter 18, once we are building the HTTP server and have a real request context to work with.
Section 3 starts with Chapter 14, where we write our first HTTP server. The fact that every incoming request runs in its own goroutine is exactly what allows that server to handle real concurrent traffic.