
Golang Concurrency
- 34.4k installs
- 2.8k repo stars
- Updated July 27, 2026
- samber/cc-skills-golang
golang-concurrency is an agent skill for writing and auditing Go goroutines, channels, sync primitives, errgroup, and worker pools safely.
About
The golang-concurrency skill v1.1.4 from samber cc-skills-golang teaches structured concurrency where every goroutine has a clear owner, exit path, and error propagation. Write mode implements goroutines, channels, sync primitives, worker pools, and pipelines. Review mode inspects PR diffs for leaks, missing context propagation, ownership violations, and unprotected shared state. Audit mode parallelizes up to five sub-agents to find spawns without shutdown, unsynchronized globals, channel misuse, hot-loop time.After calls, and mutex problems. Core principles require sender-only channel closes, unbuffered channels by default, ctx.Done in select, and goleak in tests. Tables compare channels versus mutexes versus atomics and WaitGroup versus errgroup with SetLimit for bounded workers. Reference docs cover channels and select, sync primitives, and fan-out fan-in pipelines. Common mistakes include fire-and-forget goroutines, receiver-side channel closes, wg.Add inside goroutines, and mutexes held across I/O.
- Structured concurrency rules: every goroutine needs a clear exit, owner, and error propagation path.
- Write, review, and audit modes with up to five parallel sub-agents for large codebase concurrency scans.
- Channel versus mutex versus atomic decision tables plus WaitGroup versus errgroup guidance.
- Requires ctx.Done in select and goleak tracking to prevent cancellation leaks and goroutine accumulation.
- Documents pipelines, worker pools, singleflight deduplication, and Go 1.26 experimental leak profiling notes.
Golang Concurrency by the numbers
- 34,418 all-time installs (skills.sh)
- +577 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #2 of 99 Go skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
golang-concurrency capabilities & compatibility
- Capabilities
- goroutine lifecycle and shutdown design · channel ownership and direction typing · errgroup error propagation and limits · parallel concurrency codebase audits · pipeline and worker pool patterns
- Use cases
- api development · debugging · code review
What golang-concurrency says it does
Every goroutine must have a clear exit
Only the sender closes a channel
Always include `ctx.Done()` in select
npx skills add https://github.com/samber/cc-skills-golang --skill golang-concurrencyAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 34.4k |
|---|---|
| repo stars | ★ 2.8k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | samber/cc-skills-golang ↗ |
How do I implement or review Go concurrency without goroutine leaks, race conditions, or channel ownership bugs?
Write and review concurrent Go code with goroutines, channels, sync primitives, errgroup, worker pools, and leak prevention.
Who is it for?
Go developers implementing or reviewing concurrent servers, pipelines, and background workers.
Skip if: Skip for non-Go languages or purely sequential code with no goroutines or shared state.
When should I use this skill?
Writing goroutines, channels, select, locks, errgroup, worker pools, or debugging leaks and races.
What you get
Structured concurrent Go code with verified shutdown, context cancellation, and leak-free test patterns.
- Leak-free concurrent code
- Audit findings
- Pipeline implementations
By the numbers
- Metadata version 1.1.4 from samber cc-skills-golang.
- Audit mode uses up to five parallel sub-agents.
- Concurrency checklist has five pre-spawn verification questions.
Files
Persona: You are a Go concurrency engineer. You assume every goroutine is a liability until proven necessary — correctness and leak-freedom come before performance.
Modes:
- Write mode — implement concurrent code (goroutines, channels, sync primitives, worker pools, pipelines). Follow the sequential instructions below.
- Review mode — reviewing a PR's concurrent code changes. Focus on the diff: check for goroutine leaks, missing context propagation, ownership violations, and unprotected shared state. Sequential.
- Audit mode — auditing existing concurrent code across a codebase. Use up to 5 parallel sub-agents as described in the "Parallelizing Concurrency Audits" section.
Community default. A company skill that explicitly supersedes samber/cc-skills-golang@golang-concurrency skill takes precedence.Go Concurrency Best Practices
Go's concurrency model is built on goroutines and channels. Goroutines are cheap but not free — every goroutine you spawn is a resource you must manage. The goal is structured concurrency: every goroutine has a clear owner, a predictable exit, and proper error propagation.
Core Principles
1. Every goroutine must have a clear exit — without a shutdown mechanism (context, done channel, WaitGroup), they leak and accumulate until the process crashes 2. Share memory by communicating — channels transfer ownership explicitly; mutexes protect shared state but make ownership implicit 3. Send copies, not pointers on channels — sending pointers creates invisible shared memory, defeating the purpose of channels 4. Only the sender closes a channel — closing from the receiver side panics if the sender writes after close 5. Specify channel direction (chan<-, <-chan) — the compiler prevents misuse at build time 6. Default to unbuffered channels — larger buffers mask backpressure; use them only with measured justification 7. Always include `ctx.Done()` in select — without it, goroutines leak after caller cancellation 8. Avoid repeated `time.After` in hot loops — each call allocates a timer and creates unnecessary churn; use time.NewTimer + Reset for long-running loops 9. Track goroutine leaks in tests with go.uber.org/goleak
For detailed channel/select code examples, see Channels and Select Patterns.
Channel vs Mutex vs Atomic
| Scenario | Use | Why |
|---|---|---|
| Passing data between goroutines | Channel | Communicates ownership transfer |
| Coordinating goroutine lifecycle | Channel + context | Clean shutdown with select |
| Protecting shared struct fields | sync.Mutex / sync.RWMutex | Simple critical sections |
| Simple counters, flags | sync/atomic | Lock-free, lower overhead |
| Many readers, few writers on a map | sync.Map | Optimized for read-heavy workloads. Concurrent map read/write causes a hard crash |
| Caching expensive computations | sync.Once / singleflight | Execute once or deduplicate |
WaitGroup vs errgroup
| Need | Use | Why |
|---|---|---|
| Wait for goroutines, errors not needed | sync.WaitGroup | Fire-and-forget |
| Wait + collect first error | errgroup.Group | Error propagation |
| Wait + cancel siblings on first error | errgroup.WithContext | Context cancellation on error |
| Wait + limit concurrency | errgroup.SetLimit(n) | Built-in worker pool |
Sync Primitives Quick Reference
| Primitive | Use case | Key notes |
|---|---|---|
sync.Mutex | Protect shared state | Keep critical sections short; never hold across I/O |
sync.RWMutex | Many readers, few writers | Never upgrade RLock to Lock (deadlock) |
sync/atomic | Simple counters, flags | Prefer typed atomics (Go 1.19+): atomic.Int64, atomic.Bool |
sync.Map | Concurrent map, read-heavy | No explicit locking; use RWMutex+map when writes dominate |
sync.Pool | Reuse temporary objects | Always Reset() before Put(); reduces GC pressure |
sync.Once | One-time initialization | Go 1.21+: OnceFunc, OnceValue, OnceValues |
sync.WaitGroup | Waiting for simple goroutines | Go 1.25+: prefer wg.Go(func(){ ... }) for fire-and-wait tasks that do not panic and do not need error propagation. For Go <1.25 use Add/Done. For errors/cancellation/limits, use errgroup with context. |
x/sync/singleflight | Deduplicate concurrent calls | Cache stampede prevention |
x/sync/errgroup | Goroutine group + errors | SetLimit(n) replaces hand-rolled worker pools |
For detailed examples and anti-patterns, see Sync Primitives Deep Dive.
Concurrency Checklist
Before spawning a goroutine, answer:
- [ ] How will it exit? — context cancellation, channel close, or explicit signal
- [ ] Can I signal it to stop? — pass
context.Contextor done channel - [ ] Can I wait for it? —
sync.WaitGrouporerrgroup - [ ] Who owns the channels? — creator/sender owns and closes
- [ ] Should this be synchronous instead? — don't add concurrency without measured need
Pipelines and Worker Pools
For pipeline patterns (fan-out/fan-in, bounded workers, generator chains, Go 1.23+ iterators, samber/ro), see Pipelines and Worker Pools.
Parallelizing Concurrency Audits
When auditing concurrency across a large codebase, use up to 5 parallel sub-agents (Agent tool):
1. Find all goroutine spawns (go func, go method) and verify shutdown mechanisms 2. Search for mutable globals and shared state without synchronization 3. Audit channel usage — ownership, direction, closure, buffer sizes 4. Find time.After in loops, missing ctx.Done() in select, unbounded spawning 5. Check mutex usage, sync.Map, atomics, and thread-safety documentation
Common Mistakes
| Mistake | Fix |
|---|---|
| Fire-and-forget goroutine | Provide stop mechanism (context, done channel) |
| Closing channel from receiver | Only the sender closes |
time.After in hot loop | Reuse time.NewTimer + Reset |
Missing ctx.Done() in select | Always select on context to allow cancellation |
| Unbounded goroutine spawning | Use errgroup.SetLimit(n) or semaphore |
| Sharing pointer via channel | Send copies or immutable values |
wg.Add inside goroutine | Call Add before go — Wait may return early otherwise |
Forgetting -race in CI | Always run go test -race ./... |
| Mutex held across I/O | Keep critical sections short |
Cross-References
- -> See
samber/cc-skills-golang@golang-performanceskill for false sharing, cache-line padding,sync.Poolhot-path patterns - -> See
samber/cc-skills-golang@golang-contextskill for cancellation propagation and timeout patterns - -> See
samber/cc-skills-golang@golang-safetyskill for concurrent map access and race condition prevention - -> See
samber/cc-skills-golang@golang-troubleshootingskill for debugging goroutine leaks and deadlocks - -> See
samber/cc-skills-golang@golang-design-patternsskill for graceful shutdown patterns - -> See
samber/cc-skills-golang@golang-continuous-integrationskill for automated AI-driven code review in CI using these guidelines
Go 1.26 experimental goroutine leak profile
For Go 1.26 diagnostics, there is an experimental goroutine leak profile. It is useful for production-oriented leak investigation, but is gated by GOEXPERIMENT=goroutineleakprofile; do not rely on it as default stable behavior.
Typical usage when the experiment is enabled:
curl http://localhost:6060/debug/pprof/goroutineleak?debug=2
go tool pprof http://localhost:6060/debug/pprof/goroutineleakKeep existing tools:
- tests:
go.uber.org/goleak - runtime count:
runtime.NumGoroutine() - stack dump:
/debug/pprof/goroutine?debug=2 - race checks:
go test -race ./...
References
[
{
"id": 1,
"name": "time-after-in-select-loop",
"description": "Tests whether the model avoids time.After inside a select loop and uses time.NewTimer with Reset instead",
"prompt": "I have a Go worker that reads from a channel and needs a 5-second inactivity timeout. If nothing arrives for 5 seconds, it should log and continue waiting. Write the select loop.",
"trap": "Without the skill, the model uses time.After(5*time.Second) inside the for/select loop, creating repeated timer allocations/churn instead of reusing a timer",
"assertions": [
{"id": "1.1", "text": "Does NOT use time.After inside the loop body"},
{"id": "1.2", "text": "Creates time.NewTimer (or time.NewTicker) outside the loop"},
{"id": "1.3", "text": "Calls timer.Reset() after handling a message or timeout"},
{"id": "1.4", "text": "Calls timer.Stop() (or defers it) to clean up the timer"},
{"id": "1.5", "text": "For Go 1.23+, does not use the old stale-drain pattern; for Go <1.23 compatibility, drains before Reset when Stop reports a possible pending value"}
]
},
{
"id": 2,
"name": "channel-closing-ownership",
"description": "Tests whether the model follows the rule that only the sender closes a channel, not the receiver",
"prompt": "I have a producer goroutine that sends items to a channel and a consumer goroutine that reads from it. The consumer knows when it has received enough items and wants to signal completion. How should I close the channel to stop the producer?",
"trap": "Without the skill, the model closes the channel from the consumer side, which panics if the producer writes after close",
"assertions": [
{"id": "2.1", "text": "Does NOT close the channel from the consumer/receiver side"},
{"id": "2.2", "text": "Uses a separate signaling mechanism (done channel, context cancellation, or similar) for the consumer to tell the producer to stop"},
{"id": "2.3", "text": "The producer is the one that closes the data channel (or it is closed by the channel creator/sender)"},
{"id": "2.4", "text": "Explains the panic risk of closing a channel from the receiver side"},
{"id": "2.5", "text": "The producer selects on the stop signal alongside its send operation"}
]
},
{
"id": 3,
"name": "waitgroup-add-placement",
"description": "wg.Add before go statement; wg.Add(len(urls)) is preferred over per-goroutine Add(1) when count is known",
"prompt": "Review this Go code and identify all WaitGroup issues:\n\n```go\nfunc ProcessURLs(urls []string) []Result {\n var wg sync.WaitGroup\n results := make([]Result, len(urls))\n\n for i, url := range urls {\n go func(i int, url string) {\n wg.Add(1)\n defer wg.Done()\n results[i] = fetch(url)\n }(i, url)\n }\n\n wg.Wait()\n return results\n}\n```\n\nFix all issues. Also, is there a more efficient way to add to the WaitGroup when the number of goroutines is known upfront?",
"trap": "The most obvious bug (wg.Add inside goroutine) is well-known. The model should also catch the second issue: wg.Add(len(urls)) before the loop is more efficient than N separate wg.Add(1) calls (single atomic update vs N atomic updates). The model likely fixes the placement but may not suggest the batch Add optimization.",
"assertions": [
{"id": "3.1", "text": "Moves wg.Add(1) BEFORE the go statement — wg.Add inside the goroutine races with wg.Wait(), which can return before all goroutines have registered"},
{"id": "3.2", "text": "Suggests using wg.Add(len(urls)) before the loop as a more efficient alternative — a single atomic operation instead of N separate wg.Add(1) calls in the loop"},
{"id": "3.3", "text": "Keeps defer wg.Done() inside the goroutine"},
{"id": "3.4", "text": "Notes the race condition: wg.Wait() could return before some goroutines have called wg.Add(1), causing ProcessURLs to return with goroutines still running"}
]
},
{
"id": 4,
"name": "channel-direction-in-signatures",
"description": "Tests whether the model specifies channel direction (send-only, receive-only) in function signatures",
"prompt": "Write a Go pipeline with two functions: one that generates integers from a slice and sends them to a channel, and one that reads integers from a channel, doubles them, and sends to an output channel. Wire them together in main.",
"trap": "Without the skill, the model uses bidirectional chan int in function parameters instead of chan<- and <-chan",
"assertions": [
{"id": "4.1", "text": "The generator function returns <-chan int (receive-only for callers)"},
{"id": "4.2", "text": "The doubler function accepts <-chan int as input parameter"},
{"id": "4.3", "text": "The doubler function returns <-chan int (receive-only for callers)"},
{"id": "4.4", "text": "Internally, channels are created as bidirectional but exposed as directional through return types"},
{"id": "4.5", "text": "The producer (generator) closes its output channel with defer close(out)"}
]
},
{
"id": 5,
"name": "unbuffered-channel-default",
"description": "Tests whether the model defaults to unbuffered channels and requires justification for buffered ones",
"prompt": "I need a channel to pass tasks from a dispatcher to a pool of workers in Go. Should I buffer it? If so, how large?",
"trap": "Without the skill, the model suggests an arbitrary large buffer (e.g., 100 or 1000) without justification or discussion of backpressure",
"assertions": [
{"id": "5.1", "text": "Recommends starting with unbuffered (or very small buffer like 0 or 1) as the default"},
{"id": "5.2", "text": "Explains that large buffers mask backpressure problems"},
{"id": "5.3", "text": "States that buffer size should be based on measured need, not arbitrary choice"},
{"id": "5.4", "text": "Does NOT suggest a large arbitrary buffer (e.g., 100, 1000) without explaining the tradeoffs"},
{"id": "5.5", "text": "Mentions that buffered channels hide the problem of slow consumers"}
]
},
{
"id": 6,
"name": "send-copies-not-pointers",
"description": "Tests whether the model sends copies (not pointers) through channels to avoid shared memory",
"prompt": "I have a producer goroutine that creates Task structs and sends them to a channel for worker goroutines to process. The Task struct has fields like ID, Payload, and Status. Write the producer and consumer code.",
"trap": "Without the skill, the model sends *Task pointers through the channel, creating invisible shared memory that defeats the purpose of channels",
"assertions": [
{"id": "6.1", "text": "Sends Task values (not *Task pointers) through the channel, OR explicitly documents why pointers are safe in this case"},
{"id": "6.2", "text": "The channel type is chan Task (value type) rather than chan *Task"},
{"id": "6.3", "text": "Does not mutate the Task struct after sending it on the channel (or sends a copy)"},
{"id": "6.4", "text": "If pointers are used, explicitly acknowledges the shared-memory risk and explains the mitigation"}
]
},
{
"id": 7,
"name": "errgroup-vs-waitgroup-decision",
"description": "Tests whether the model chooses errgroup over WaitGroup when error handling is needed, and uses SetLimit for bounded concurrency",
"prompt": "Write a Go function that fetches data from 50 URLs concurrently. At most 10 should run at once. If any fetch fails, stop all remaining work and return the error.",
"trap": "Without the skill, the model builds a hand-rolled worker pool with WaitGroup + semaphore channel, missing errgroup.SetLimit which handles this natively",
"assertions": [
{"id": "7.1", "text": "Uses errgroup (golang.org/x/sync/errgroup) instead of sync.WaitGroup for error propagation"},
{"id": "7.2", "text": "Uses errgroup.WithContext to cancel siblings on first error"},
{"id": "7.3", "text": "Uses g.SetLimit(10) for bounded concurrency instead of a hand-rolled semaphore"},
{"id": "7.4", "text": "Does NOT build a manual worker pool with channels and WaitGroup when errgroup suffices"},
{"id": "7.5", "text": "Each goroutine checks ctx.Done() or uses the context from errgroup.WithContext"}
]
},
{
"id": 8,
"name": "ctx-done-in-select",
"description": "Tests whether every select statement includes a ctx.Done() case to prevent goroutine leaks",
"prompt": "Write a Go function that reads from an input channel, transforms each item, and writes to an output channel. It should run as a goroutine.",
"trap": "Without the skill, the model writes a select with only channel operations but no ctx.Done() case, causing goroutine leaks on cancellation",
"assertions": [
{"id": "8.1", "text": "The function accepts a context.Context parameter"},
{"id": "8.2", "text": "Every select statement includes a case <-ctx.Done(): return branch"},
{"id": "8.3", "text": "Both the read from input channel AND the write to output channel are wrapped in select with ctx.Done()"},
{"id": "8.4", "text": "The goroutine exits cleanly when context is cancelled"},
{"id": "8.5", "text": "The output channel is closed when the goroutine exits (defer close)"}
]
},
{
"id": 9,
"name": "sync-map-vs-rwmutex-decision",
"description": "Tests whether the model correctly chooses between sync.Map and RWMutex+map based on access patterns",
"prompt": "I need a concurrent cache in Go. Keys are strings, values are structs. The cache will have frequent writes from many goroutines updating the same keys, and reads happen about as often as writes. Which synchronization approach should I use?",
"trap": "Without the skill, the model recommends sync.Map because it sounds like 'concurrent map', but sync.Map is slower for write-heavy overlapping-key patterns",
"assertions": [
{"id": "9.1", "text": "Recommends sync.RWMutex + plain map over sync.Map for this write-heavy, overlapping-key pattern"},
{"id": "9.2", "text": "Explains that sync.Map is optimized for write-once/read-many or disjoint key sets"},
{"id": "9.3", "text": "Explains that for frequent writes with overlapping keys, RWMutex+map is faster"},
{"id": "9.4", "text": "Does NOT unconditionally recommend sync.Map for any concurrent map scenario"},
{"id": "9.5", "text": "Mentions that concurrent map read/write without synchronization causes a hard crash (not just a data race)"}
]
},
{
"id": 10,
"name": "sync-pool-reset-before-put",
"description": "Tests whether the model resets objects before returning them to sync.Pool",
"prompt": "Write a Go function that uses sync.Pool to reuse bytes.Buffer objects for encoding JSON responses in an HTTP handler. Show the pool setup and the handler.",
"trap": "Without the skill, the model returns the buffer to the pool without calling Reset(), leading to dirty objects and data corruption",
"assertions": [
{"id": "10.1", "text": "Calls buf.Reset() BEFORE bufPool.Put(buf), not after Get()"},
{"id": "10.2", "text": "Uses defer to ensure the buffer is returned to the pool even on error"},
{"id": "10.3", "text": "Does not assume the object from Get() is clean/zeroed"},
{"id": "10.4", "text": "The pool's New function creates a new buffer"},
{"id": "10.5", "text": "Does not store persistent state in pooled objects"}
]
},
{
"id": 11,
"name": "goroutine-panic-recovery",
"description": "Tests whether the model adds panic recovery at goroutine boundaries in production code",
"prompt": "Write a Go HTTP server that spawns a background goroutine per request to do async processing (e.g., sending a notification email). The main handler returns 202 Accepted immediately.",
"trap": "Without the skill, the model spawns go func() without recover(), so a panic in the background goroutine crashes the entire server process",
"assertions": [
{"id": "11.1", "text": "Adds defer func() { recover() }() or equivalent panic recovery inside the goroutine"},
{"id": "11.2", "text": "Logs or handles the recovered panic (not just silently swallowed)"},
{"id": "11.3", "text": "The goroutine has a shutdown mechanism (context, done channel, or similar)"},
{"id": "11.4", "text": "Mentions that a panic in a goroutine crashes the entire process"}
]
},
{
"id": 12,
"name": "singleflight-cache-stampede",
"description": "Tests whether the model uses singleflight to deduplicate concurrent requests for the same resource",
"prompt": "My Go service has an expensive database query for user profiles. Under load, hundreds of goroutines request the same user profile simultaneously, causing a thundering herd on the database. How should I solve this?",
"trap": "Without the skill, the model suggests only traditional caching (TTL-based) or a mutex, missing singleflight which deduplicates in-flight requests",
"assertions": [
{"id": "12.1", "text": "Recommends golang.org/x/sync/singleflight as the primary solution"},
{"id": "12.2", "text": "Shows usage of group.Do(key, func) where key identifies the deduplicated resource"},
{"id": "12.3", "text": "Explains that only one goroutine executes the function; others wait and share the result"},
{"id": "12.4", "text": "May combine singleflight with a cache layer for TTL-based caching, but singleflight is the deduplication mechanism"},
{"id": "12.5", "text": "Does NOT suggest only a plain mutex or only a TTL cache as the solution to thundering herd"}
]
},
{
"id": 13,
"name": "iterator-vs-goroutine-pipeline",
"description": "Tests whether the model uses Go 1.23+ iterators for sequential CPU-bound transforms instead of goroutine+channel pipelines",
"prompt": "I have a Go 1.23+ project. I need to filter a slice of users to find active ones, then extract their email addresses. The slice is in-memory and processing is pure CPU. Should I use a goroutine pipeline with channels?",
"trap": "Without the skill, the model builds a goroutine+channel pipeline for a purely sequential, CPU-bound in-memory transformation where iterators are more appropriate",
"assertions": [
{"id": "13.1", "text": "Recommends against goroutine+channel pipeline for this purely sequential, in-memory transform"},
{"id": "13.2", "text": "Suggests Go 1.23+ iterators (iter.Seq) or simple slice operations instead"},
{"id": "13.3", "text": "Explains that goroutine+channel pipelines add overhead without benefit for sequential CPU-bound work"},
{"id": "13.4", "text": "Mentions that goroutine pipelines are appropriate when stages involve I/O or need true parallelism"},
{"id": "13.5", "text": "Does NOT build a multi-goroutine pipeline for this use case"}
]
}
]
Channels and Select Patterns
Goroutine Lifecycle
NEVER start a goroutine without knowing how it stops. Every goroutine MUST answer: how will it stop?
// ✗ Bad — fire-and-forget, no way to stop or wait
func startWorker() {
go func() {
for {
doWork() // runs forever, leaks on shutdown
}
}()
}
// ✓ Good — goroutine respects context cancellation, caller can wait
func startWorker(ctx context.Context) *sync.WaitGroup {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
default:
doWork(ctx)
}
}
}()
return &wg
}Panic Recovery at Goroutine Boundaries
A panic in a goroutine crashes the entire process. Always recover at goroutine boundaries in production code:
go func() {
defer func() {
if r := recover(); r != nil {
// ...
}
}()
doWork(ctx)
}()Channel Direction
Specify direction in function signatures to prevent misuse at compile time:
// ✗ Bad — caller could accidentally close or send on a receive-only channel
func consume(ch chan int) { ... }
// ✓ Good — compiler enforces correct usage
func produce(ch chan<- int) { ... } // send-only
func consume(ch <-chan int) { ... } // receive-onlyChannel Closing
Channels MUST be closed by the sender (producer), NEVER by the receiver — it causes a panic if the sender writes after close.
// ✓ Good — producer closes when done
func generate(ctx context.Context) <-chan int {
ch := make(chan int)
go func() {
defer close(ch) // sender closes
for i := 0; ; i++ {
select {
case ch <- i:
case <-ctx.Done():
return
}
}
}()
return ch
}Buffer Size
| Size | When to use |
|---|---|
| 0 (unbuffered) | Default. Synchronizes sender and receiver — use when you need handoff guarantees |
| 1 | Signal channels (done := make(chan struct{}, 1)), or when sender must not block on a single pending item |
| N > 1 | Only with measured justification — document why N was chosen and what happens when the buffer fills |
// ✓ Good — unbuffered for synchronous handoff
ch := make(chan Result)
// ✓ Good — buffered 1 for signal
done := make(chan struct{}, 1)
// ✗ Suspicious — arbitrary large buffer hides backpressure problems
// Give explanation in comments.
ch := make(chan Task, 1000) // why 1000? what if it fills?Select for Non-Blocking Communication
Use select to multiplex channel operations and always include ctx.Done() to prevent goroutine leaks:
func process(ctx context.Context, in <-chan Task, out chan<- Result) {
for {
select {
case <-ctx.Done():
return
case task, ok := <-in:
if !ok {
return // channel closed
}
result := handle(ctx, task)
select {
case out <- result:
case <-ctx.Done():
return
}
}
}
}Avoid Repeated time.After in Hot Loops
// ✗ Bad — creates a new timer on every iteration
for {
select {
case msg := <-ch:
handle(msg)
case <-time.After(5 * time.Second): // repeated allocation/churn
handleTimeout()
}
}
// ✓ Good (Go 1.23+) — reuse the timer
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case msg := <-ch:
timer.Stop()
timer.Reset(5 * time.Second)
handle(msg)
case <-timer.C:
handleTimeout()
timer.Reset(5 * time.Second)
}
}For Go <1.23, if timer.Stop() returns false, drain a possible stale value before Reset. In Go 1.23+, receiving from timer.C after Stop returns is guaranteed to block rather than receive a stale value.
Pipelines and Worker Pools
Pipeline Pattern
A pipeline is a series of stages connected by channels, where each stage is a goroutine (or group of goroutines) that:
1. Receives values from an upstream channel 2. Processes each value 3. Sends results to a downstream channel
// Stage 1: Generate integers
func generate(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
// Stage 2: Square each integer
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
// Usage
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ch := generate(ctx, 2, 3, 4)
results := square(ctx, ch)
for v := range results {
fmt.Println(v) // 4, 9, 16
}
}Key rules for pipelines:
- Pipeline stages MUST accept and respect context cancellation — every stage must select on
ctx.Done()to avoid goroutine leaks on early cancellation - The producer (first stage) closes its output channel; each subsequent stage closes its own output
- NEVER create unbounded goroutines in pipeline stages
- Use unbuffered channels unless you have measured throughput needs
Fan-Out / Fan-In
Fan-out: multiple goroutines read from the same channel to parallelize CPU-bound work. Fan-in: multiple channels are merged into a single output channel.
// Fan-out: N workers reading from the same input channel
func fanOut(ctx context.Context, in <-chan Task, workers int) <-chan Result {
out := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case task, ok := <-in:
if !ok {
return
}
select {
case out <- process(ctx, task):
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}// Fan-in: merge multiple channels into one
func fanIn(ctx context.Context, channels ...<-chan Result) <-chan Result {
out := make(chan Result)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan Result) {
defer wg.Done()
for v := range c {
select {
case out <- v:
case <-ctx.Done():
return
}
}
}(ch)
}
go func() {
wg.Wait()
close(out)
}()
return out
}Worker Pool with errgroup
Fan-out workers SHOULD use errgroup.SetLimit for bounded concurrency. For most use cases, errgroup.SetLimit replaces hand-rolled worker pools:
func processAll(ctx context.Context, tasks []Task) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // max 10 concurrent workers
for _, task := range tasks {
g.Go(func() error {
return process(ctx, task)
})
}
return g.Wait()
}Use a hand-rolled worker pool only when you need:
- Per-worker state (connections, buffers)
- Custom backpressure or priority scheduling
- Graceful draining with in-flight task completion
Bounded Concurrency with Semaphore
When you need fine-grained concurrency control without errgroup:
func processAll(ctx context.Context, items []Item) error {
sem := make(chan struct{}, 10) // semaphore of 10
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // acquire
go func(item Item) {
defer wg.Done()
defer func() { <-sem }() // release
process(ctx, item)
}(item)
}
wg.Wait()
return nil
}Prefer errgroup.SetLimit over this pattern when error propagation is needed.
Pipeline Alternatives
Go 1.23+ Iterators (range-over-func)
For in-process data transformations that do not need concurrency, iterators avoid the overhead of goroutines and channels:
func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {
return func(yield func(T) bool) {
for v := range seq {
if pred(v) {
if !yield(v) {
return
}
}
}
}
}
func Map[T, U any](seq iter.Seq[T], f func(T) U) iter.Seq[U] {
return func(yield func(U) bool) {
for v := range seq {
if !yield(f(v)) {
return
}
}
}
}Use iterators when:
- Processing is CPU-bound and does not benefit from parallelism
- You want lazy evaluation without goroutine overhead
- The data source is already sequential (slice, database cursor)
Use goroutine+channel pipelines when:
- Stages involve I/O (network, disk) that benefits from concurrency
- You need true parallelism across CPU cores
- Stages have different throughput characteristics
samber/ro
samber/ro provides a fluent, type-safe pipeline API for read-only collections:
import "github.com/samber/ro"
emails, _ := ro.Collect( // ignore error
ro.Pipe(
ro.FromSlice(users),
ro.Filter(func(u User) bool { return u.Active }),
ro.Map(func(u User) string { return u.Email }),
),
)
Use samber/ro for sequential data transformations that benefit from a fluent API. It might also support parallel processing if needed.
Goroutine Leak Detection
Goroutine leaks SHOULD be detected with goleak in tests. Use go.uber.org/goleak in TestMain to catch leaked goroutines across all tests:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}Common Pipeline Mistakes
| Mistake | Fix |
|---|---|
Missing ctx.Done() in pipeline stage | Always select on context to allow cancellation |
| Not closing output channel | Producer must defer close(out) |
| Unbounded goroutine spawning | Use errgroup.SetLimit or a semaphore |
| Sending mutable data through channel | Send copies or immutable values |
| Blocking send without select | Wrap channel sends in select with ctx.Done() |
→ See samber/cc-skills-golang@golang-concurrency skill for sync primitives and channel patterns.
Sync Primitives Deep Dive
sync.Mutex
Protects shared state with exclusive access. MUST hold the lock for the shortest time possible — NEVER hold a mutex across I/O, network calls, or channel operations.
type SafeCache struct {
mu sync.Mutex
items map[string]string
}
func (c *SafeCache) Get(key string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.items[key]
return v, ok
}
func (c *SafeCache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}Embedding Convention
Embed the mutex as an unexported field, placed directly above the fields it protects:
type Registry struct {
mu sync.Mutex // protects entries
entries map[string]Entry
}sync.RWMutex
SHOULD be used when reads greatly outnumber writes. Multiple goroutines can hold RLock simultaneously; Lock is exclusive.
type Config struct {
mu sync.RWMutex
values map[string]string
}
func (c *Config) Get(key string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.values[key]
}
func (c *Config) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.values[key] = value
}Pitfall: Do not upgrade RLock to Lock — this deadlocks. Release RLock first, then acquire Lock.
sync/atomic
Lock-free operations for simple values. SHOULD be preferred over Mutex for simple counter operations. Faster than mutex for low-contention counters and flags.
// ✓ Good — atomic for a simple counter
var requestCount atomic.Int64
func handleRequest() {
requestCount.Add(1)
}
func getCount() int64 {
return requestCount.Load()
}// ✓ Good — atomic.Bool for a shutdown flag
var shuttingDown atomic.Bool
func shutdown() {
shuttingDown.Store(true)
}
func isRunning() bool {
return !shuttingDown.Load()
}Go 1.19+ provides typed atomics (atomic.Int64, atomic.Bool, atomic.Pointer[T]) — prefer these over raw atomic.AddInt64/atomic.LoadInt64.
sync.Map
SHOULD only be used for write-once/read-many patterns. Optimized for two common patterns: (1) keys are written once and read many times, (2) multiple goroutines read/write disjoint key sets. For other patterns, a plain map + sync.RWMutex is faster.
var cache sync.Map
func Get(key string) (any, bool) {
return cache.Load(key)
}
func Set(key string, value any) {
cache.Store(key, value)
}
func GetOrSet(key string, compute func() any) any {
if v, ok := cache.Load(key); ok {
return v
}
v, _ := cache.LoadOrStore(key, compute())
return v
}When NOT to use `sync.Map`: when you need to iterate, get the length, or when writes are frequent and keys overlap heavily. Use sync.RWMutex + map instead.
sync.Pool
Reuse temporary objects to reduce GC pressure. MUST NOT store pointers to stack-allocated objects. Objects in the pool may be reclaimed at any GC cycle — do not store persistent state.
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func process(data []byte) string {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
buf.Write(data)
// ... transform ...
return buf.String()
}Rules:
- Always
Reset()beforePut()— returning dirty objects causes bugs - Do not assume an object from
Get()is zeroed — theNewfunc only runs if the pool is empty - Best for short-lived, frequently allocated objects (buffers, encoders, temporary structs)
sync.Once
MUST be used for one-time initialization. Execute exactly once, regardless of how many goroutines call it concurrently. Thread-safe by design.
type DBClient struct {
initOnce sync.Once
closeOnce sync.Once
conn *sql.DB
}
func (c *DBClient) getConn() *sql.DB {
c.initOnce.Do(func() {
var err error
c.conn, err = sql.Open("postgres", dsn)
if err != nil {
panic(fmt.Sprintf("db init: %v", err))
}
})
return c.conn
}
func (c *DBClient) Close() error {
var err error
c.closeOnce.Do(func() {
err = c.conn.Close()
})
return err
}Go 1.21+ also provides sync.OnceFunc, sync.OnceValue, and sync.OnceValues for simpler use cases:
var loadConfig = sync.OnceValue(func() *Config {
cfg, err := parseConfig("config.yaml")
if err != nil {
panic(err)
}
return cfg
})
// Usage: cfg := loadConfig()sync.WaitGroup
Use sync.WaitGroup when you only need to wait for a set of goroutines to finish.
Go 1.25+: wg.Go
WaitGroup.Go starts a goroutine, adds it to the group, and removes it from the group when the function returns.
func processAll(items []Item) {
var wg sync.WaitGroup
for _, item := range items {
// Go 1.22+ loop variables are per-iteration when the module has `go 1.22+`.
// Do not add `item := item` solely for closure capture in modern modules.
wg.Go(func() {
process(item)
})
}
wg.Wait()
}Rules:
WaitGroup.Gois Go 1.25+, not Go 1.24.- The function passed to
wg.Gomust not panic. WaitGroupdoes not propagate errors and does not cancel siblings.- For first-error-wins, cancellation, concurrency limits, or returned values, use
golang.org/x/sync/errgroup.
Benefits of `wg.Go()`:
- No manual
Add/Donebookkeeping - Lower risk of
Add/Waitordering bugs - Cleaner API for simple fire-and-wait work
When to use: Go 1.25+ projects for simple goroutines that must all finish, do not return errors, do not need cancellation, and must not panic. Use errgroup when work returns errors, needs cancellation, limits, or first-error behavior.
Go <1.25 fallback
func processAll(ctx context.Context, items []Item) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1) // Add BEFORE go
go func(item Item) {
defer wg.Done()
process(ctx, item)
}(item)
}
wg.Wait() // blocks until all goroutines finish
}// ✗ Bad — Add inside the goroutine (race: Wait may return before Add runs)
go func() {
wg.Add(1)
defer wg.Done()
process(item)
}()golang.org/x/sync/singleflight
Deduplicates concurrent calls for the same key. When multiple goroutines request the same resource simultaneously, only one executes; the rest wait and share the result.
var group singleflight.Group
func GetUser(ctx context.Context, id string) (*User, error) {
v, err, _ := group.Do(id, func() (any, error) {
// Only one goroutine executes this for a given id
return db.QueryUser(ctx, id)
})
if err != nil {
return nil, err
}
return v.(*User), nil
}Use cases: cache stampede prevention, deduplicating expensive lookups (DB, API), rate-limited external service calls.
golang.org/x/sync/errgroup
Goroutine group with error propagation. Returns the first error from any goroutine. With WithContext, cancels remaining goroutines on first error.
func fetchAll(ctx context.Context, urls []string) ([]Response, error) {
g, ctx := errgroup.WithContext(ctx) // cancel siblings on first error
results := make([]Response, len(urls))
for i, url := range urls {
g.Go(func() error {
resp, err := fetch(ctx, url)
if err != nil {
return fmt.Errorf("fetching %s: %w", url, err)
}
results[i] = resp // safe: each goroutine writes to its own index
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}Bounded Concurrency with SetLimit
SHOULD use SetLimit to bound concurrency and avoid unbounded goroutine spawning.
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // at most 10 goroutines run concurrently
for _, task := range tasks {
g.Go(func() error {
return process(ctx, task)
})
}
return g.Wait()This replaces hand-rolled worker pools for most use cases.
→ See samber/cc-skills-golang@golang-concurrency skill for high-level patterns and decision trees.
Related skills
How it compares
Go concurrency engineering playbook, not a generic threading tutorial.
FAQ
Who is golang-concurrency for?
Go developers writing or reviewing concurrent code involving goroutines, channels, sync primitives, and errgroup.
When should I use golang-concurrency?
When implementing pipelines, fixing goroutine leaks, choosing channels versus mutexes, or auditing race-prone code.
Is golang-concurrency safe to install?
Review the Security Audits panel on this page before installing in production.