# Concurrency

> Run work concurrently with goroutines, coordinate it with channels and select, protect shared state, and cancel it with context.

Backend with Go roadmap · Foundations · 18 min read · https://levelcode.org/roadmaps/go-backend/go-concurrency

A backend spends most of its time waiting: for the network, for the database, for other services. Go's answer is cheap concurrency built into the language. A Go HTTP server already runs every request in its own goroutine, so even if you never write the `go` keyword yourself, your code runs concurrently. This lesson covers goroutines, channels, `select`, protecting shared memory with `sync`, cancelling work with `context`, and the patterns and pitfalls you will meet in real services.

## Goroutines and WaitGroup

A **goroutine** is a function running concurrently with the rest of your program. Start one by putting `go` before a function call. Goroutines are managed by the Go runtime, not the operating system, and start with a few kilobytes of stack, so running tens of thousands is normal.

The catch: when `main` returns, the program exits, taking any running goroutines with it. To wait for goroutines to finish, use a `sync.WaitGroup`:

```go
package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var wg sync.WaitGroup
	for i := range 3 {
		wg.Add(1) // before starting the goroutine, never inside it
		go func() {
			defer wg.Done()
			time.Sleep(time.Duration(3-i) * 10 * time.Millisecond)
			fmt.Println("worker", i, "done")
		}()
	}
	wg.Wait() // blocks until the counter drops to zero
	fmt.Println("all done")
}
```

```text
worker 2 done
worker 1 done
worker 0 done
all done
```

Each goroutine captures its own `i` because loop variables are per-iteration since Go 1.22. The output order comes from the different sleeps; without them, the order would be unpredictable. **Never assume goroutines run in any particular order.** (Go 1.25 adds `wg.Go(func() { ... })`, which does the `Add` and `Done` for you.)

## Channels: communicating between goroutines

A **channel** is a typed pipe that goroutines use to pass values and to synchronise. Create one with `make`, send with `ch <- v`, receive with `v := <-ch`:

```go
func squares(n int, out chan<- int) { // chan<- means send-only
	for i := range n {
		out <- i * i
	}
	close(out) // no more values coming
}

func main() {
	ch := make(chan int)
	go squares(4, ch)
	for v := range ch { // receives until ch is closed
		fmt.Println(v) // 0, 1, 4, 9
	}
}
```

The key behaviours:

- An **unbuffered** channel (`make(chan int)`) has no storage. A send blocks until another goroutine receives, so a send and receive are a hand-off that synchronises both sides.
- A **buffered** channel (`make(chan int, 10)`) accepts sends without a receiver until the buffer is full, then blocks.
- `close(ch)` signals "no more values". Receivers drain the remaining values, then receive the zero value immediately. Use `v, ok := <-ch` to tell: `ok` is `false` once the channel is closed and empty.
- Only the **sender** closes, and only when receivers need to know it is finished. Sending on a closed channel panics. Channels do not need to be closed for garbage collection.
- Direction types (`chan<- int` send-only, `<-chan int` receive-only) in function signatures document intent and let the compiler catch misuse.

Try these rules on a live channel below. Start unbuffered (capacity 0): press **Send next** and watch the producer block until you press **Receive**, and notice the value never sits in the channel. Then raise the capacity, fill the buffer, close it, and keep receiving until you get `0 false`.

> **Interactive: Unbuffered and buffered channels.** An unbuffered channel is a hand-off: each send waits for its receive. A buffer lets the producer run ahead by up to cap values. After close, receives drain what is left and then return the zero value with ok == false; sending or closing again panics. Try it: https://levelcode.org/labs/channel-sim

If every goroutine is blocked, the runtime detects it and crashes with a clear message:

```go
func main() {
	ch := make(chan int)
	ch <- 1 // nobody will ever receive
}
```

```text
fatal error: all goroutines are asleep - deadlock!
```

## select: waiting on several things

`select` waits on several channel operations and runs whichever is ready first. Its most common job in backend code is a timeout:

```go
func slowLookup() <-chan string {
	ch := make(chan string, 1) // buffered: see the warning below
	go func() {
		time.Sleep(2 * time.Second)
		ch <- "result"
	}()
	return ch
}

func main() {
	select {
	case res := <-slowLookup():
		fmt.Println(res)
	case <-time.After(500 * time.Millisecond):
		fmt.Println("timed out")
	}
}
```

If several cases are ready, `select` picks one at random. A `default` case runs immediately when nothing else is ready, which turns a blocking operation into a non-blocking attempt.

> [!WARNING]
> If `slowLookup` used an unbuffered channel, the goroutine would block forever on `ch <- "result"` after the timeout fired, because nobody is left to receive. That is a **goroutine leak**: it holds memory until the process exits, and in a server it accumulates with every timed-out request. Give every goroutine a guaranteed way to finish, such as a buffer of one or a cancellation signal.

## Sharing memory safely: sync.Mutex and the race detector

Channels are ideal for passing ownership of data. When several goroutines need to read and update the *same* data, such as an in-memory cache shared by all request handlers, use a lock instead. Here is the bug you are protecting against:

```go
func main() {
	total := 0
	var wg sync.WaitGroup
	for range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1000 {
				total++ // read, add, write: three steps that can interleave
			}
		}()
	}
	wg.Wait()
	fmt.Println(total) // should be 100000; prints e.g. 84788, different each run
}
```

This is a **data race**: two goroutines access the same memory at the same time and at least one writes. The result is undefined, and with maps it is worse: concurrent map writes crash the program with `fatal error: concurrent map writes`. Go ships a race detector that finds these bugs while your code runs. Use it in tests and during development:

```bash
go run -race .
go test -race ./...
```

```text
==================
WARNING: DATA RACE
Read at 0x00c000012158 by goroutine 11:
  main.main.func1()
      /home/you/race/main.go:16 +0x99
...
```

The fix is a `sync.Mutex`, which only one goroutine can hold at a time. Put the mutex next to the data it protects and keep the locked section short:

```go
type Counter struct {
	mu     sync.Mutex
	counts map[string]int
}

func NewCounter() *Counter { return &Counter{counts: make(map[string]int)} }

func (c *Counter) Inc(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.counts[key]++
}

func (c *Counter) Get(key string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.counts[key]
}
```

Methods use pointer receivers because copying a struct copies its mutex, and a copied lock protects nothing (`go vet` warns about this). Related tools: `sync.RWMutex` allows many concurrent readers or one writer, `sync/atomic` types such as `atomic.Int64` handle single counters without a lock, and `sync.Once` runs initialisation exactly once.

## context: cancellation and deadlines

Suppose a client disconnects while your handler is waiting on a slow database query. Ideally the query stops too. The `context` package carries that cancellation signal, plus deadlines, through every function involved in a piece of work.

```go
func slowQuery(ctx context.Context) (string, error) {
	select {
	case <-time.After(2 * time.Second): // pretend work
		return "rows", nil
	case <-ctx.Done(): // closed when ctx is cancelled or times out
		return "", ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	_, err := slowQuery(ctx)
	fmt.Println(err)                                      // context deadline exceeded
	fmt.Println(errors.Is(err, context.DeadlineExceeded)) // true
}
```

How it fits together:

- `context.Background()` is the empty root. `WithTimeout`, `WithDeadline` and `WithCancel` derive a child that is cancelled when its parent is, when the deadline passes, or when you call `cancel`.
- `ctx.Done()` returns a channel that is closed on cancellation, so it slots straight into `select`. `ctx.Err()` then reports `context.Canceled` or `context.DeadlineExceeded`.
- **Always call `cancel`**, usually with `defer`, even if the work finishes early. It releases the timer and the context's resources.
- Pass the context as the **first parameter**, named `ctx`. Do not store it in a struct.

Standard library functions that may block accept a context: `http.NewRequestWithContext`, `db.QueryContext`, and so on. In an HTTP handler you get one from `r.Context()`; the server cancels it when the client goes away.

> [!TIP]
> Cancellation is cooperative. A context cannot stop a goroutine by force; your code must check `ctx.Done()` or pass `ctx` to functions that do. A long CPU-bound loop should check `ctx.Err()` between iterations.

## Worker pools: bounding concurrency

Starting one goroutine per item is fine for ten items and dangerous for a million: you can exhaust memory, database connections, or a partner API's rate limit. A **worker pool** runs a fixed number of goroutines that pull jobs from a channel:

```go
func worker(id int, jobs <-chan int, results chan<- string, wg *sync.WaitGroup) {
	defer wg.Done()
	for j := range jobs {
		results <- fmt.Sprintf("worker %d processed job %d", id, j)
	}
}

func main() {
	jobs := make(chan int)
	results := make(chan string)

	var wg sync.WaitGroup
	for id := range 3 {
		wg.Add(1)
		go worker(id, jobs, results, &wg)
	}

	go func() { // feed jobs, then signal there are no more
		for j := range 9 {
			jobs <- j
		}
		close(jobs)
	}()

	go func() { // close results once every worker has exited
		wg.Wait()
		close(results)
	}()

	for r := range results {
		fmt.Println(r)
	}
}
```

Follow the closes: the feeder closes `jobs`, which ends each worker's `range`; when all workers are done, a helper closes `results`, which ends the main loop. Every goroutine has a clear exit, so nothing leaks. The `golang.org/x/sync/errgroup` package packages this pattern with error propagation and a concurrency limit, and is worth adopting once you are comfortable with the pieces.

## Practice

Write `checkAll(ctx context.Context, urls []string, limit int) map[string]error` that "checks" every URL concurrently, running at most `limit` checks at once and stopping when `ctx` is cancelled. Use this fake check, whose delay depends on the URL's length:

```go
func check(ctx context.Context, url string) error {
	select {
	case <-time.After(time.Duration(len(url)*20) * time.Millisecond):
		if strings.Contains(url, "bad") {
			return errors.New("status 500")
		}
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}
```

Call it with a 300 ms timeout, a limit of 2, and the URLs `a.example`, `bad.example` and `slow-service.example`, then print the results in sorted order. Run it with `-race` to confirm there are no data races.

Hint: a buffered channel of capacity `limit` works as a semaphore: send to acquire a slot, receive to release it.

**Solution**

```go
package main

import (
	"context"
	"errors"
	"fmt"
	"maps"
	"slices"
	"strings"
	"sync"
	"time"
)

func check(ctx context.Context, url string) error {
	select {
	case <-time.After(time.Duration(len(url)*20) * time.Millisecond):
		if strings.Contains(url, "bad") {
			return errors.New("status 500")
		}
		return nil
	case <-ctx.Done():
		return ctx.Err()
	}
}

func checkAll(ctx context.Context, urls []string, limit int) map[string]error {
	var (
		mu      sync.Mutex
		wg      sync.WaitGroup
		results = make(map[string]error, len(urls))
		sem     = make(chan struct{}, limit)
	)
	for _, u := range urls {
		sem <- struct{}{} // acquire a slot; blocks while `limit` checks are running
		wg.Add(1)
		go func() {
			defer wg.Done()
			defer func() { <-sem }() // release the slot
			err := check(ctx, u)
			mu.Lock()
			results[u] = err
			mu.Unlock()
		}()
	}
	wg.Wait()
	return results
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
	defer cancel()

	urls := []string{"a.example", "bad.example", "slow-service.example"}
	res := checkAll(ctx, urls, 2)
	for _, u := range slices.Sorted(maps.Keys(res)) {
		fmt.Printf("%-22s %v\n", u, res[u])
	}
}
```

```text
a.example              <nil>
bad.example            status 500
slow-service.example   context deadline exceeded
```

Acquiring the slot in the loop, before `go`, means at most `limit` goroutines exist at once and slots are handed out in order. `a.example` (180 ms) and `bad.example` (220 ms) run first and finish within the deadline. `slow-service.example` gets a slot at 180 ms but needs 400 ms, so `ctx.Done()` fires at 300 ms. The mutex protects the shared map, and `-race` reports nothing.

## Key takeaways

- `go f()` starts a goroutine; use a `sync.WaitGroup` (calling `Add` before `go`) to wait for goroutines to finish.
- Channels pass values and synchronise goroutines; only the sender closes, and `range` reads until close.
- `select` waits on several channel operations, which is how you implement timeouts and react to cancellation.
- Protect shared mutable state with a `sync.Mutex`, and run tests with `-race` to catch data races.
- Pass `context.Context` as the first parameter, check `ctx.Done()` in blocking work, and always `defer cancel()`.
- Bound concurrency with worker pools or a semaphore, and make sure every goroutine has a way to exit.

## Quiz

1. In main, you run ch := make(chan int) and then ch <- 1, with no other goroutines. What happens?

   A. The value is stored and the program continues
   B. The send is skipped because nobody is listening
   C. The program panics with 'send on closed channel'
   D. The runtime crashes with 'all goroutines are asleep - deadlock!'

   **Answer: D.** An unbuffered send blocks until a receiver arrives. With no other goroutine, nothing can ever receive, so the runtime detects that every goroutine is blocked and aborts. 'send on closed channel' only happens after close.

2. A goroutine produces values on a channel that main ranges over. Who should close the channel, and when?

   A. The producer, after its last send
   B. main, as soon as it receives the first value
   C. Nobody; channels must never be closed
   D. Either side, whenever convenient

   **Answer: A.** Only the sender knows when no more values are coming, and sending on a closed channel panics, so the sender closes. Closing is what ends the receiver's range loop.

3. What does a receive from a closed, empty channel do?

   A. Blocks forever
   B. Returns the zero value immediately, with ok == false in the v, ok := <-ch form
   C. Panics
   D. Returns the last value that was sent

   **Answer: B.** Receives on a closed channel never block: once buffered values are drained they return the zero value, and the comma-ok form reports false. Sending on a closed channel is what panics.

4. Why should you write defer cancel() right after ctx, cancel := context.WithTimeout(parent, 2*time.Second), even if the work usually finishes in 10 ms?

   A. Without it the timeout never fires
   B. cancel() makes the work finish faster
   C. It releases the context's timer and resources as soon as the function returns instead of waiting for the deadline
   D. The compiler rejects code that does not call cancel

   **Answer: C.** The context and its timer live until cancelled or expired. Calling cancel releases them promptly and also cancels any children derived from ctx. The compiler does not enforce it, but go vet's lostcancel check warns when cancel is not called.
