Go concurrency foundations
Skill v0lka/skills/development/idiomatic-go/go-concurrency-foundations
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.From its SKILL.md
npx -y skills add v0lka/skills --skill go-concurrency-foundationsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
2 things to look at
- 15 stars15 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
- runs commandsInstructs the agent to run 2 commands, including `go test -race` and 1 more.
SKILL.md
9.1 KB, ~2.2k tokens by cl100k_base, as published. Nobody here has run it
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.
GOMAXPROCSlimits 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/atomicfor simple numeric types. - Mutex --
sync.Mutex/sync.RWMutexto 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 --GOMAXPROCSmay be set lower than the CPU count (e.g., in containers). - For CPU-bound work, more goroutines than
GOMAXPROCScauses 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()afterWithTimeout,WithDeadline, orWithCancel. 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.Contextas the first parameter so upstream callers can control cancellation. -
Prefer
context.TODO()overcontext.Background()when the right context is unclear or not yet propagated -- it signals intent to revisit.
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.