Go Concurrency Foundations
Rules derived from "100 Go Mistakes" #55-#60. Apply whenever writing or reviewing
concurrent Go code.
1. Concurrency vs Parallelism (#55)
- Concurrency is about structure -- decomposing a problem into independently
executing steps that coordinate.
- Parallelism is about execution -- running the same step on multiple cores
simultaneously.
- Concurrency enables parallelism but is not the same thing.
- When restructuring code, ask: "Am I changing the structure (concurrency) or
adding more workers to the same step (parallelism)?"
2. Concurrency Is Not Always Faster (#56)
Go scheduling essentials
- Goroutines are multiplexed onto OS threads (M) by the Go runtime, not the OS.
GOMAXPROCS limits the number of OS threads executing user-level Go code
simultaneously (defaults to logical CPU count since Go 1.5).
- The scheduler uses per-P local queues, a global queue, and work stealing.
- Since Go 1.14 the scheduler is preemptive (10 ms time slice).
Key rule: small workloads kill parallelism
Spinning up a goroutine per tiny unit of work makes things slower -- the
goroutine creation and scheduling overhead dominates. Always use a threshold
to fall back to sequential execution for small inputs.
const threshold = 2048 // tune via benchmarks on target hardware
func parallelMergesort(s []int) {
if len(s) <= 1 {
return
}
if len(s) <= threshold {
sequentialMergesort(s) // fall back to sequential
return
}
middle := len(s) / 2
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
parallelMergesort(s[:middle])
}()
go func() {
defer wg.Done()
parallelMergesort(s[middle:])
}()
wg.Wait()
merge(s, middle)
}
Checklist before adding concurrency
- Start with a correct sequential version.
- Profile and benchmark to confirm the bottleneck.
- Introduce concurrency with a tunable threshold or pool size.
- Benchmark the concurrent version -- if it is not measurably faster, keep the
sequential one.
3. Channels vs Mutexes (#57)
Use this decision guide:
| Situation |
Prefer |
| Parallel goroutines accessing/mutating a shared resource |
sync.Mutex (or sync/atomic) |
| Concurrent goroutines that need to coordinate, signal, or transfer ownership |
Channels |
| Signaling completion or readiness (with or without data) |
Channels (chan struct{} for no data) |
| Protecting a critical section (read/write to shared state) |
sync.Mutex / sync.RWMutex |
| Transferring ownership of a resource from one stage to the next |
Channels |
- Do NOT force channels everywhere just because Go says "share memory by
communicating." Mutexes and channels are complementary.
- If goroutines are parallel (same step, multiple workers): think mutexes.
- If goroutines are concurrent (different steps in a pipeline): think channels.
4. Data Races vs Race Conditions (#58)
Definitions
- Data race: two+ goroutines access the same memory location concurrently and
at least one writes. Detected by
go test -race / go run -race.
- Race condition: behavior depends on uncontrolled timing of events. A
data-race-free program can still have race conditions.
Eliminating data races does NOT guarantee deterministic results.
Preventing data races
Choose one of:
- Atomic operations --
sync/atomic for simple numeric types.
- Mutex --
sync.Mutex / sync.RWMutex to guard a critical section.
- Channel communication -- ensure only one goroutine writes to the variable.
// BAD -- data race
i := 0
go func() { i++ }()
go func() { i++ }()
// GOOD -- atomic
var i int64
go func() { atomic.AddInt64(&i, 1) }()
go func() { atomic.AddInt64(&i, 1) }()
// GOOD -- mutex
var mu sync.Mutex
i := 0
go func() { mu.Lock(); i++; mu.Unlock() }()
go func() { mu.Lock(); i++; mu.Unlock() }()
// GOOD -- channel (only parent writes)
ch := make(chan int)
go func() { ch <- 1 }()
go func() { ch <- 1 }()
i := <-ch + <-ch
Race condition (data-race-free but non-deterministic)
// No data race, but i is unpredictably 1 or 2
var mu sync.Mutex
i := 0
go func() { mu.Lock(); i = 1; mu.Unlock() }()
go func() { mu.Lock(); i = 2; mu.Unlock() }()
To enforce ordering, use channels for coordination, not just mutexes.
Go memory model guarantees
Memorize these ordering rules:
- Goroutine creation happens-before the goroutine starts executing.
- Goroutine exit is NOT guaranteed to happen before any event -- always
synchronize if the parent reads state written by the child.
- Channel send happens-before the corresponding receive completes.
- Channel close happens-before a receive observing the closure.
- Unbuffered channel receive happens-before the send completes.
- This means with an unbuffered channel, a write before the receive is
guaranteed visible after the send returns.
- This guarantee does NOT hold for buffered channels.
// SAFE -- unbuffered channel guarantees ordering
i := 0
ch := make(chan struct{})
go func() {
i = 1
<-ch
}()
ch <- struct{}{}
fmt.Println(i) // guaranteed to print 1
// UNSAFE -- buffered channel, data race on i
ch := make(chan struct{}, 1)
go func() {
i = 1
<-ch
}()
ch <- struct{}{}
fmt.Println(i) // data race
5. Worker Pool Sizing by Workload Type (#59)
| Workload |
Pool size guideline |
| CPU-bound |
runtime.GOMAXPROCS(0) (number of OS threads, defaults to logical CPUs) |
| I/O-bound |
Depends on the external system's capacity; tune via load testing |
- Use
runtime.GOMAXPROCS(0) (read-only call) to get the current value.
- Do NOT use
runtime.NumCPU() for pool sizing -- GOMAXPROCS may be set lower
than the CPU count (e.g., in containers).
- For CPU-bound work, more goroutines than
GOMAXPROCS causes unnecessary
context switching with no throughput gain.
Worker pool template
func process(r io.Reader) (int, error) {
var count int64
n := runtime.GOMAXPROCS(0) // CPU-bound: match available threads
ch := make(chan []byte, n)
var wg sync.WaitGroup
wg.Add(n)
for i := 0; i < n; i++ {
go func() {
defer wg.Done()
for b := range ch {
v := task(b)
atomic.AddInt64(&count, int64(v))
}
}()
}
for {
b := make([]byte, 1024)
_, err := r.Read(b)
if err != nil {
if err == io.EOF {
break
}
close(ch)
return 0, err
}
ch <- b
}
close(ch)
wg.Wait()
return int(count), nil
}
6. Go Contexts (#60)
When to create which context
| Constructor |
Use case |
context.WithTimeout(parent, d) |
Cancel after a duration (e.g., RPC deadline) |
context.WithDeadline(parent, t) |
Cancel at an absolute time |
context.WithCancel(parent) |
Manual cancellation signal (e.g., graceful shutdown) |
context.WithValue(parent, k, v) |
Carry request-scoped metadata (trace IDs, auth) |
context.Background() |
Top-level / main / test entry point |
context.TODO() |
Placeholder when the correct context is not yet available |
Mandatory rules
Always defer cancel() after WithTimeout, WithDeadline, or
WithCancel. Forgetting leaks the internal timer goroutine until the timeout
fires.
ctx, cancel := context.WithTimeout(ctx, 4*time.Second)
defer cancel() // always, even if the function returns early
Use unexported key types for context values to prevent cross-package
collisions.
type ctxKey string
const traceIDKey ctxKey = "traceID"
ctx = context.WithValue(ctx, traceIDKey, "abc-123")
Never block on channel send/receive in a context-aware function without
selecting on ctx.Done().
// BAD -- blocks even if context is canceled
ch <- msg
v := <-ch
// GOOD -- respects context cancellation
select {
case <-ctx.Done():
return ctx.Err()
case ch <- msg:
}
select {
case <-ctx.Done():
return ctx.Err()
case v := <-ch:
// use v
}
Check ctx.Err() to distinguish cancellation causes:
context.Canceled -- explicit cancel.
context.DeadlineExceeded -- timeout or deadline passed.
Functions that users wait for should accept a context.Context as the first
parameter so upstream callers can control cancellation.
Prefer context.TODO() over context.Background() when the right context
is unclear or not yet propagated -- it signals intent to revisit.
1---2name: go-concurrency-foundations3description: Guides the agent to avoid foundational concurrency mistakes in Go: confusing concurrency with parallelism, assuming concurrency is always faster, misusing channels vs mutexes, ignoring data races and race conditions, mis-sizing worker pools for CPU- vs I/O-bound work, and misunderstanding Go contexts. Use when writing, reviewing, or refactoring any concurrent Go code, goroutines, channels, mutexes, worker pools, or context usage.4---56# Go Concurrency Foundations78Rules derived from "100 Go Mistakes" #55-#60. Apply whenever writing or reviewing9concurrent Go code.1011---1213## 1. Concurrency vs Parallelism (#55)1415- **Concurrency** is about *structure* -- decomposing a problem into independently16 executing steps that coordinate.17- **Parallelism** is about *execution* -- running the same step on multiple cores18 simultaneously.19- Concurrency *enables* parallelism but is not the same thing.20- When restructuring code, ask: "Am I changing the structure (concurrency) or21 adding more workers to the same step (parallelism)?"2223---2425## 2. Concurrency Is Not Always Faster (#56)2627### Go scheduling essentials2829- Goroutines are multiplexed onto OS threads (M) by the Go runtime, not the OS.30- `GOMAXPROCS` limits the number of OS threads executing user-level Go code31 simultaneously (defaults to logical CPU count since Go 1.5).32- The scheduler uses per-P local queues, a global queue, and **work stealing**.33- Since Go 1.14 the scheduler is **preemptive** (10 ms time slice).3435### Key rule: small workloads kill parallelism3637Spinning up a goroutine per tiny unit of work makes things *slower* -- the38goroutine creation and scheduling overhead dominates. Always use a **threshold**39to fall back to sequential execution for small inputs.4041```go42const threshold = 2048 // tune via benchmarks on target hardware4344func parallelMergesort(s []int) {45 if len(s) <= 1 {46 return47 }48 if len(s) <= threshold {49 sequentialMergesort(s) // fall back to sequential50 return51 }5253 middle := len(s) / 254 var wg sync.WaitGroup55 wg.Add(2)56 go func() {57 defer wg.Done()58 parallelMergesort(s[:middle])59 }()60 go func() {61 defer wg.Done()62 parallelMergesort(s[middle:])63 }()64 wg.Wait()65 merge(s, middle)66}67```6869### Checklist before adding concurrency70711. Start with a correct sequential version.722. Profile and benchmark to confirm the bottleneck.733. Introduce concurrency with a tunable threshold or pool size.744. Benchmark the concurrent version -- if it is not measurably faster, keep the75 sequential one.7677---7879## 3. Channels vs Mutexes (#57)8081Use this decision guide:8283| Situation | Prefer |84|---|---|85| **Parallel** goroutines accessing/mutating a shared resource | `sync.Mutex` (or `sync/atomic`) |86| **Concurrent** goroutines that need to coordinate, signal, or transfer ownership | Channels |87| Signaling completion or readiness (with or without data) | Channels (`chan struct{}` for no data) |88| Protecting a critical section (read/write to shared state) | `sync.Mutex` / `sync.RWMutex` |89| Transferring ownership of a resource from one stage to the next | Channels |9091- Do NOT force channels everywhere just because Go says "share memory by92 communicating." Mutexes and channels are **complementary**.93- If goroutines are *parallel* (same step, multiple workers): think mutexes.94- If goroutines are *concurrent* (different steps in a pipeline): think channels.9596---9798## 4. Data Races vs Race Conditions (#58)99100### Definitions101102- **Data race**: two+ goroutines access the same memory location concurrently and103 at least one writes. Detected by `go test -race` / `go run -race`.104- **Race condition**: behavior depends on uncontrolled timing of events. A105 data-race-free program can still have race conditions.106107Eliminating data races does NOT guarantee deterministic results.108109### Preventing data races110111Choose one of:1121131. **Atomic operations** -- `sync/atomic` for simple numeric types.1142. **Mutex** -- `sync.Mutex` / `sync.RWMutex` to guard a critical section.1153. **Channel communication** -- ensure only one goroutine writes to the variable.116117```go118// BAD -- data race119i := 0120go func() { i++ }()121go func() { i++ }()122123// GOOD -- atomic124var i int64125go func() { atomic.AddInt64(&i, 1) }()126go func() { atomic.AddInt64(&i, 1) }()127128// GOOD -- mutex129var mu sync.Mutex130i := 0131go func() { mu.Lock(); i++; mu.Unlock() }()132go func() { mu.Lock(); i++; mu.Unlock() }()133134// GOOD -- channel (only parent writes)135ch := make(chan int)136go func() { ch <- 1 }()137go func() { ch <- 1 }()138i := <-ch + <-ch139```140141### Race condition (data-race-free but non-deterministic)142143```go144// No data race, but i is unpredictably 1 or 2145var mu sync.Mutex146i := 0147go func() { mu.Lock(); i = 1; mu.Unlock() }()148go func() { mu.Lock(); i = 2; mu.Unlock() }()149```150151To enforce ordering, use channels for coordination, not just mutexes.152153### Go memory model guarantees154155Memorize these ordering rules:1561571. **Goroutine creation** happens-before the goroutine starts executing.1582. **Goroutine exit** is NOT guaranteed to happen before any event -- always159 synchronize if the parent reads state written by the child.1603. **Channel send** happens-before the corresponding receive completes.1614. **Channel close** happens-before a receive observing the closure.1625. **Unbuffered channel receive** happens-before the send completes.163 - This means with an unbuffered channel, a write before the receive is164 guaranteed visible after the send returns.165 - This guarantee does NOT hold for buffered channels.166167```go168// SAFE -- unbuffered channel guarantees ordering169i := 0170ch := make(chan struct{})171go func() {172 i = 1173 <-ch174}()175ch <- struct{}{}176fmt.Println(i) // guaranteed to print 1177178// UNSAFE -- buffered channel, data race on i179ch := make(chan struct{}, 1)180go func() {181 i = 1182 <-ch183}()184ch <- struct{}{}185fmt.Println(i) // data race186```187188---189190## 5. Worker Pool Sizing by Workload Type (#59)191192| Workload | Pool size guideline |193|---|---|194| **CPU-bound** | `runtime.GOMAXPROCS(0)` (number of OS threads, defaults to logical CPUs) |195| **I/O-bound** | Depends on the external system's capacity; tune via load testing |196197- Use `runtime.GOMAXPROCS(0)` (read-only call) to get the current value.198- Do NOT use `runtime.NumCPU()` for pool sizing -- `GOMAXPROCS` may be set lower199 than the CPU count (e.g., in containers).200- For CPU-bound work, more goroutines than `GOMAXPROCS` causes unnecessary201 context switching with no throughput gain.202203### Worker pool template204205```go206func process(r io.Reader) (int, error) {207 var count int64208 n := runtime.GOMAXPROCS(0) // CPU-bound: match available threads209210 ch := make(chan []byte, n)211 var wg sync.WaitGroup212 wg.Add(n)213 for i := 0; i < n; i++ {214 go func() {215 defer wg.Done()216 for b := range ch {217 v := task(b)218 atomic.AddInt64(&count, int64(v))219 }220 }()221 }222223 for {224 b := make([]byte, 1024)225 _, err := r.Read(b)226 if err != nil {227 if err == io.EOF {228 break229 }230 close(ch)231 return 0, err232 }233 ch <- b234 }235 close(ch)236 wg.Wait()237 return int(count), nil238}239```240241---242243## 6. Go Contexts (#60)244245### When to create which context246247| Constructor | Use case |248|---|---|249| `context.WithTimeout(parent, d)` | Cancel after a duration (e.g., RPC deadline) |250| `context.WithDeadline(parent, t)` | Cancel at an absolute time |251| `context.WithCancel(parent)` | Manual cancellation signal (e.g., graceful shutdown) |252| `context.WithValue(parent, k, v)` | Carry request-scoped metadata (trace IDs, auth) |253| `context.Background()` | Top-level / main / test entry point |254| `context.TODO()` | Placeholder when the correct context is not yet available |255256### Mandatory rules2572581. **Always `defer cancel()`** after `WithTimeout`, `WithDeadline`, or259 `WithCancel`. Forgetting leaks the internal timer goroutine until the timeout260 fires.261262 ```go263 ctx, cancel := context.WithTimeout(ctx, 4*time.Second)264 defer cancel() // always, even if the function returns early265 ```2662672. **Use unexported key types** for context values to prevent cross-package268 collisions.269270 ```go271 type ctxKey string272 const traceIDKey ctxKey = "traceID"273274 ctx = context.WithValue(ctx, traceIDKey, "abc-123")275 ```2762773. **Never block on channel send/receive in a context-aware function** without278 selecting on `ctx.Done()`.279280 ```go281 // BAD -- blocks even if context is canceled282 ch <- msg283 v := <-ch284285 // GOOD -- respects context cancellation286 select {287 case <-ctx.Done():288 return ctx.Err()289 case ch <- msg:290 }291292 select {293 case <-ctx.Done():294 return ctx.Err()295 case v := <-ch:296 // use v297 }298 ```2993004. **Check `ctx.Err()`** to distinguish cancellation causes:301 - `context.Canceled` -- explicit cancel.302 - `context.DeadlineExceeded` -- timeout or deadline passed.3033045. Functions that users wait for should accept a `context.Context` as the first305 parameter so upstream callers can control cancellation.3063076. Prefer `context.TODO()` over `context.Background()` when the right context308 is unclear or not yet propagated -- it signals intent to revisit.