Go Concurrency skill for Codex
Every goroutine has an owner and an exit. Codex starts no goroutine without an owner that waits for it, threads context cancellation through every blocking call, and bounds fan-out with errgroup limits or fixed worker pools. It chooses mutex, atomic or channel per piece of state, reads goroutine dumps before guessing at hangs, and proves fixes with go test -race, goleak and synctest fake clocks.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- go-concurrency Installs to ~/.agents/skills/go-concurrency/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install go-concurrency The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project go-concurrency Installs to .agents/skills/go-concurrency/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/go-concurrency.zipunzip -o go-concurrency.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) go-concurrency PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $go-concurrency, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Writes and debugs concurrent Go - goroutine ownership and shutdown, context cancellation, errgroup with SetLimit, WaitGroup.Go, channel close rules, worker pools and pipelines, mutexes versus channels, sync.Once and atomics, and finding races and leaks with go test -race, testing/synctest, goleak and goroutine profiles. Use when Go code deadlocks, leaks goroutines, has data races or flaky timing tests, ignores cancellation, or when a user asks to parallelize work with bounded concurrency.
Use it when
- Go code deadlocks, leaks goroutines or panics on concurrent map writes
- Work should run in parallel with a concurrency limit and error cancellation
- Shutdown or request cancellation does not stop background work
- Tests that involve timers or goroutines are flaky
Use something else when
- You need CPU or heap profiling to find what is slow: use go-pprof
- The question is HTTP server structure and graceful shutdown wiring: use go-http-services
How to check the result
go test -race -count=5 passes, goleak reports nothing, and a test shows that cancelling the context stops all work within a bounded time.
The mistake it prevents
Starting goroutines that block forever on a channel nobody reads after an early return, leaking one goroutine per request.
Done well means: every goroutine has an owner that knows when it ends, cancellation flows
through context.Context, concurrency is bounded, shared state has one clear guard, and
the tests pass under -race repeatedly.
Before changing anything
- Read
AGENTS.mdandgo.mod.WaitGroup.Goand stabletesting/synctestneed Go 1.25; per-iteration loop variables need 1.22. Check whethergolang.org/x/syncis already a dependency before adding it (adding needs network). - Run the race detector on the current code to get a baseline:
go test -race -count=3 ./...(needs cgo on some platforms; setCGO_ENABLED=1). - For a hang, get a goroutine dump before guessing: send
SIGQUIT(Ctrl-) to the process, or hit/debug/pprof/goroutine?debug=2ifnet/http/pprofis mounted.
Rules that prevent most bugs
- Never start a goroutine without knowing how it stops. The function that starts it waits for it (WaitGroup, errgroup, done channel) or hands that duty to a clear owner.
- Pass
ctx context.Contextas the first parameter to anything that blocks; checkctx.Done()in every loop andselect. - Only the sender closes a channel, and only when no more sends can happen. Receivers never close. With multiple senders, close after a WaitGroup says they are all done.
- Bound concurrency:
errgroup.SetLimit, a semaphore, or a fixed worker pool. - One guard per piece of shared state: a mutex next to the fields it protects, or a single goroutine that owns the state and talks over channels. Never both.
Choosing a tool
| Need | Use |
|---|---|
| Run N tasks, first error cancels the rest | errgroup.WithContext + g.SetLimit(n) |
| Run N tasks, no errors, just wait | sync.WaitGroup with wg.Go(func(){...}) (1.25) |
| Protect a map or struct fields | sync.Mutex / sync.RWMutex (RW only if reads dominate and are slow) |
| Counter or flag | atomic.Int64, atomic.Bool |
| Lazy one-time init | sync.OnceValue(func() T) / sync.OnceValues (1.21) |
| Hand off work or results between goroutines | Channels |
| Limit concurrent access to a resource | Buffered channel as semaphore or x/sync/semaphore (weighted) |
| Cache many readers, rare writers, disjoint keys | sync.Map only for that pattern; otherwise a mutex map |
| Deduplicate concurrent identical calls | x/sync/singleflight |
errgroup with bounded fan-out
import "golang.org/x/sync/errgroup"
func FetchAll(ctx context.Context, ids []string) ([]Item, error) {
items := make([]Item, len(ids)) // each goroutine writes its own index: no lock needed
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for i, id := range ids {
g.Go(func() error {
it, err := fetch(ctx, id)
if err != nil {
return fmt.Errorf("fetch %s: %w", id, err)
}
items[i] = it
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return items, nil
}
- The derived
ctxis cancelled when any task fails; tasks must use it, not the outer one. g.Goblocks when the limit is reached, which gives natural backpressure.- Collect results by index into a pre-sized slice, or send them on a channel; never append to a shared slice without a lock.
Worker pool with a results channel
func Process(ctx context.Context, jobs []Job, workers int) <-chan Result {
in := make(chan Job)
out := make(chan Result)
go func() { // producer owns and closes `in`
defer close(in)
for _, j := range jobs {
select {
case in <- j:
case <-ctx.Done():
return
}
}
}()
var wg sync.WaitGroup
for range workers {
wg.Go(func() {
for j := range in {
select {
case out <- handle(ctx, j):
case <-ctx.Done():
return
}
}
})
}
go func() { wg.Wait(); close(out) }() // closer waits for every sender
return out
}
The caller must drain out or cancel ctx; otherwise workers block forever on send.
Context patterns
| Need | API |
|---|---|
| Deadline for a call chain | ctx, cancel := context.WithTimeout(ctx, 2*time.Second); defer cancel() |
| Record why it was cancelled | context.WithCancelCause, context.Cause(ctx) |
| Run cleanup when ctx ends | stop := context.AfterFunc(ctx, fn) |
| Background work that outlives the request but keeps values | context.WithoutCancel(ctx) (still bound it with its own timeout) |
| Graceful shutdown on SIGINT/SIGTERM | signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) |
Always defer cancel(); go vet reports lost cancel functions. Do not store contexts in
structs.
Shared state with a mutex
type Counter struct {
mu sync.Mutex
counts map[string]int // guarded by mu
}
func (c *Counter) Inc(key string) {
c.mu.Lock()
defer c.mu.Unlock()
if c.counts == nil {
c.counts = make(map[string]int)
}
c.counts[key]++
}
Keep critical sections short; never call out to network or user callbacks while holding a
lock. Structs containing a mutex must not be copied (go vet copylocks); pass pointers.
Diagnosing
| Symptom | Likely cause | Check |
|---|---|---|
fatal error: all goroutines are asleep - deadlock! | Unbuffered send with no receiver, or waiting on a WaitGroup that never reaches zero | Read the dump: which goroutine is blocked on what |
| Memory grows, goroutine count grows | Leaked goroutines blocked on send/receive or ignoring ctx | runtime.NumGoroutine() over time; pprof goroutine profile grouped by stack |
concurrent map writes panic | Unguarded map | Add a mutex; run -race to find others |
Flaky test depending on time.Sleep | Timing assumptions | testing/synctest with a fake clock |
| Throughput flat as workers increase | Shared lock contention, or I/O limit elsewhere | Mutex profile: runtime.SetMutexProfileFraction, then pprof |
Testing concurrent code
func TestMain(m *testing.M) { goleak.VerifyTestMain(m) } // go.uber.org/goleak
func TestDebounce(t *testing.T) {
synctest.Test(t, func(t *testing.T) { // Go 1.25: fake clock inside the bubble
d := NewDebouncer(100 * time.Millisecond)
d.Trigger()
time.Sleep(150 * time.Millisecond) // returns instantly; time is virtual
synctest.Wait()
if d.Fired() != 1 {
t.Fatalf("fired %d times", d.Fired())
}
})
}
- Run
go test -race -count=10 -run TestName ./pkgon any test touching concurrency; races are probabilistic. -raceonly finds races that actually execute. Exercise the concurrent paths in tests.
Gotchas
- Sending on a closed channel panics; receiving from a closed channel returns the zero value
immediately (use
v, ok := <-ch). A nil channel blocks forever, which is useful for disabling aselectcase. time.Afterin a long-runningselectloop allocates a timer per iteration; use atime.Tickeror reset atime.Timer.GOMAXPROCSis cgroup-aware from Go 1.25; on older versions in containers, usego.uber.org/automaxprocs.- Capturing loop variables in goroutines is safe from Go 1.22 only if
go.moddeclares 1.22+. - Unrecovered panics in any goroutine crash the whole program; errgroup does not recover them. Recover at goroutine boundaries in long-running servers.
- More goroutines is not faster for CPU-bound work beyond
GOMAXPROCS; measure.
Verification
go test -race -count=5 ./...passes; goleak (if used) reports no leaked goroutines.go vet ./...clean (copylocks, lostcancel).- A cancellation test proves shutdown: cancel the context and assert the function returns within a bound and goroutine count returns to baseline.
- For performance claims, a benchmark (
go test -bench . -cpu 1,4,8) shows the speedup.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Go Concurrency"
short_description: "Every goroutine has an owner and an exit"
default_prompt: "$go-concurrency Process these 50k records in parallel with at most 16 goroutines" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.