# Go Concurrency Cheatsheet

- URL: https://karandeepsingh.ca/cheatsheets/languages/go-concurrency/
- Author: Karandeep Singh
- Published: 2025-08-05
- Tags: golang
- Summary: Goroutines, channels, select and context in the shapes I actually use, plus the race detector, worker pools, and the deadlock and goroutine-leak mistakes that are easiest to ship.

Go makes starting a goroutine so cheap that the hard part moves somewhere else: knowing when they stop, and what they are allowed to touch on the way. This sheet is the small set of shapes I reuse — worker pool, select with context, WaitGroup — and the three failures that cost me the most time: the race that prints the right answer, the deadlock, and the leak. The language basics are on the [Go Cheatsheet](https://karandeepsingh.ca/cheatsheets/languages/golang/).

**tip:**
Two rules carry most of this sheet. **Whoever creates a goroutine is responsible for it ending**, and **whoever sends on a channel is responsible for closing it**. Nearly every hang below is one of those two going unowned.

## Goroutines

> **Docs:** [Effective Go: concurrency](https://go.dev/doc/effective_go#concurrency)

```go
go doWork()                    // fire and forget — usually a bug
go func() { doWork() }()       // same, with a closure

var wg sync.WaitGroup
for _, host := range hosts {
    wg.Add(1)                  // before the go statement, never inside it
    go func() {
        defer wg.Done()        // defer, so a panic still decrements
        check(host)            // Go 1.22+: loop var is per-iteration
    }()
}
wg.Wait()
```

**warning:**
Before Go 1.22 the loop variable was shared, so every goroutine saw the final
value and you needed `go func(h string){...}(host)`. From 1.22 each iteration
gets its own copy and the plain closure is correct. Check the `go` directive in
your `go.mod` — it is the language version that decides, not the toolchain.

## The Race Detector

> **Docs:** [Data Race Detector](https://go.dev/doc/articles/race_detector)

This is the one to internalise. The program below is wrong, and it prints the
right answer anyway:

```go
restarts := 0
var wg sync.WaitGroup
for range 100 {
    wg.Add(1)
    go func() { defer wg.Done(); restarts++ }()   // unsynchronised write
}
wg.Wait()
fmt.Println("restarts:", restarts)
```

```text
$ go run race.go
# exits with status 0
| restarts: 100
| restarts: 100
# Two runs, both correct. Nothing here will ever make you suspicious.
```

```text
$ go run -race race.go
==================
WARNING: DATA RACE
Read at 0x00c000012168 by goroutine 14:
  main.main.func1()
      /home/user/fleet/race.go:13 +0x84

Previous write at 0x00c000012168 by goroutine 12:
  main.main.func1()
      /home/user/fleet/race.go:13 +0x96
Found 2 data race(s)
exit status 66
```

**danger:**
A data race is undefined behaviour, not a wrong number you can test for. It can
be correct on every run on a laptop and corrupt under load on a 64-core box.
**Run your tests with `-race` in CI** — `go test -race ./...` — because that is
the only place it will reliably be found.

The fix is a mutex or an atomic, not a bigger channel:

```go
var mu sync.Mutex
mu.Lock(); restarts++; mu.Unlock()

var restarts atomic.Int64     // simpler for a plain counter
restarts.Add(1)
restarts.Load()
```

## Channels

> **Docs:** [Go tour: channels](https://go.dev/tour/concurrency/2)

```go
ch := make(chan string)       // unbuffered: send blocks until a receive
ch := make(chan string, 10)   // buffered: blocks only when full

ch <- "deploy"                // send
v := <-ch                     // receive
v, ok := <-ch                 // ok is false once closed and drained
close(ch)                     // sender closes. Never the receiver.

for v := range ch { ... }     // ends when the channel is closed
```

| Operation | On a nil channel | On a closed channel |
|---|---|---|
| send | blocks forever | **panics** |
| receive | blocks forever | returns the zero value immediately |
| close | panics | **panics** |

```text
$ go run dead.go
# exits with status 2
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
	/home/user/fleet/dead.go:5 +0x28
exit status 2
# An unbuffered send with nobody receiving. The runtime can only detect this
# when EVERY goroutine is stuck — one leaked goroutine among many is silent.
```

## select

> **Docs:** [Go tour: select](https://go.dev/tour/concurrency/5)

```go
select {
case v := <-ch1:          // whichever is ready first
    use(v)
case ch2 <- x:
case <-time.After(5 * time.Second):   // timeout
    return errTimeout
case <-ctx.Done():                    // cancellation
    return ctx.Err()
default:                              // non-blocking: runs if nothing is ready
}
```

Several ready at once means one is chosen **at random**, not in source order.
An empty `select {}` blocks forever.

## context

> **Docs:** [context package](https://pkg.go.dev/context)

```go
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()                        // always, even on the success path

ctx, cancel := context.WithCancel(parent)
ctx, cancel := context.WithDeadline(parent, t)
ctx, cancel := context.WithTimeoutCause(parent, d, errSlowRegion)  // 1.21+
```

`context.Context` is the first parameter, named `ctx`, and never stored in a
struct. Cancelling a parent cancels every child.

```text
$ go run ctx.go
returned after 100ms: context deadline exceeded
is DeadlineExceeded: true
```

```go
func slowCall(ctx context.Context) error {
    select {
    case <-time.After(300 * time.Millisecond):   // the real work
        return nil
    case <-ctx.Done():
        return ctx.Err()      // DeadlineExceeded, or Canceled
    }
}
```

**warning:**
A `select` is the *only* thing that makes a goroutine cancellable. A goroutine
in the middle of a blocking call that does not take a context — a bare
`time.Sleep`, a read on a socket with no deadline — will not stop when the
context is cancelled. It keeps running and the caller just stops waiting.

## Worker Pool

The shape to reach for when there are more jobs than you want goroutines.

```go
jobs := make(chan string)
results := make(chan string)

var wg sync.WaitGroup
for w := 1; w <= 3; w++ {              // three workers, not one per host
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        for h := range jobs {          // ends when jobs is closed
            results <- check(id, h)
        }
    }(w)
}
go func() { wg.Wait(); close(results) }()   // close results once workers end
go func() {
    for _, h := range hosts { jobs <- h }
    close(jobs)                             // the signal to stop
}()

for r := range results { collect(r) }
```

```text
$ go run pool.go
completed: 5
```

**danger:**
Miss the `close(jobs)` and the workers block in `range` forever, `wg.Wait()`
never returns, and the program hangs with no error. Miss the
`go func(){ wg.Wait(); close(results) }()` and the final `range results`
hangs instead. Both are silent.

## sync

> **Docs:** [sync package](https://pkg.go.dev/sync)

```go
var mu sync.Mutex
mu.Lock(); defer mu.Unlock()

var rw sync.RWMutex
rw.RLock()                    // many readers, or one writer
rw.Lock()

var once sync.Once
once.Do(func() { initClient() })      // exactly once, even across goroutines
initClient := sync.OnceFunc(setup)    // 1.21+, same idea as a value

var m sync.Map                        // only for high-contention disjoint keys
```

**Mutex or channel — which one, and when**
The slogan is "share memory by communicating", which is good advice and not a
rule. In practice:

| Situation | Reach for |
|---|---|
| Protecting a struct field or a counter | `sync.Mutex`, or `atomic` |
| Handing work between stages | a channel |
| One value computed once, read by many | `sync.Once` |
| Waiting for N things to finish | `sync.WaitGroup` |
| Limiting concurrency to N | a buffered channel as a semaphore |

```go
sem := make(chan struct{}, 10)        // at most 10 in flight
for _, h := range hosts {
    sem <- struct{}{}                 // acquire; blocks at 10
    go func() {
        defer func() { <-sem }()      // release
        check(h)
    }()
}
```

A mutex guarding a map is simpler, faster and easier to read than a goroutine
owning the map behind a channel. Use the channel when the *handoff* is the
point, not when the shared state is.

## Goroutine Leaks

A goroutine blocked forever is never collected, and it holds everything it
references. The usual cause is a send on a channel nobody is reading any more:

```diff
# fetch.go
@@ -1,9 +1,11 @@ func first(urls []string) string {
-    ch := make(chan string)        // unbuffered
+    ch := make(chan string, len(urls))   // room for every sender
     for _, u := range urls {
         go func() { ch <- get(u) }()
     }
     return <-ch                    // takes one, returns, abandons the rest
 }
```

With an unbuffered channel the other goroutines block on the send forever.
Buffering to `len(urls)` lets them all finish; a context the caller cancels is
the other answer.

```bash
curl localhost:6060/debug/pprof/goroutine?debug=1   # counts, with stacks
go test -race ./...                                 # in CI, always
```

**Question:** Pick a service you run. If its goroutine count climbed slowly all week, would anything tell you — or would you find out when it was OOM-killed?

## Related Cheatsheets

- [Go Cheatsheet](https://karandeepsingh.ca/cheatsheets/languages/golang/) — syntax, toolchain and stdlib
- [Go Logging Cheatsheet](https://karandeepsingh.ca/cheatsheets/languages/go-logging/) — logging from many goroutines without interleaving
- [Python Threading & Concurrency Cheatsheet](https://karandeepsingh.ca/cheatsheets/python/python-threading-concurrency/) — the same problems with a GIL in the way

## Quiz

1. A counter incremented from 100 goroutines prints the right total every time you run it. Is the code correct?
   a) Yes, the output proves it
   b) No — an unsynchronised write is a data race whatever it prints; only `go run -race` can tell you
   c) Only if you used a buffered channel
   d) Yes, as long as GOMAXPROCS is 1
   Answer: b. A data race is undefined behaviour, not a guaranteed wrong answer. It can produce the expected number on every run on your laptop and a corrupted one under load on a bigger machine. The race detector finds it from the memory access pattern, not from the result.

2. What does sending on an unbuffered channel with no receiver do?
   a) Returns an error
   b) Blocks forever; if nothing else can run, the runtime panics with "all goroutines are asleep - deadlock!"
   c) Drops the value silently
   d) Buffers one value and continues
   Answer: b. An unbuffered send blocks until a receiver is ready. If every goroutine is blocked, the runtime detects it and exits with a deadlock fatal error. That detection only works when *all* goroutines are stuck — a leak that blocks one of many is invisible.

3. Why does a worker reading `for h := range jobs` need the sender to `close(jobs)`?
   a) It does not, range ends on its own
   b) Without a close the range blocks forever once the jobs run out, and the WaitGroup never reaches zero
   c) close frees the memory
   d) Only buffered channels need closing
   Answer: b. Ranging over a channel ends only when the channel is closed. Without close the workers sit in a receive forever, wg.Wait() never returns, and you have a goroutine leak that also hangs the program.

4. Which call tells you a context timed out rather than being cancelled by hand?
   a) ctx.Err() == nil
   b) errors.Is(err, context.DeadlineExceeded)
   c) err.Error() == "timeout"
   d) ctx.Done() returns true
   Answer: b. ctx.Err() returns context.DeadlineExceeded for a timeout and context.Canceled for an explicit cancel. Compare with errors.Is so the check still works when the error has been wrapped on the way up.


