agentsclimarketplace

Go concurrency goroutines

Skill ctoth/golang-skills-plugin/plugins/golang/skills/go-concurrency-goroutines

Agent skill plugins for Go code quality and performance work.

Install
npx -y skills add ctoth/golang-skills-plugin --skill go-concurrency-goroutines

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 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.

What its author says it does

Copied from the file, not written here

Guides goroutine lifetime and ownership — never start a goroutine you can't stop, give every goroutine a defined exit (context cancellation, a closed channel, or bounded work), avoid the blocked-forever leak, use sync.WaitGroup correctly (Add before the go, or wg.Go in 1.25) and golang.org/x/sync/errgroup for fallible fan-out and SetLimit worker pools, and keep library functions synchronous so the caller owns concurrency. Auto-invokes when writing or editing `go` statements, goroutines, sync.WaitGroup, errgroup, worker pools, or on "is this goroutine leaking" / "how do I wait for these" requests. The depth behind the policy root's "never start a goroutine you can't stop."

SKILL.md

16.6 KB, as published. Nobody here has run it

Go Concurrency: Goroutines & Lifetime

"When you spawn goroutines, make it clear when or whether they exit. Goroutines can leak by blocking on channel sends or receives: the garbage collector will not terminate a goroutine blocked on a channel even if no other goroutine has a reference to the channel." — Google Go Style Guide — Decisions

"Goroutines are not garbage collected; they must exit on their own." — Go Blog: Pipelines and cancellation

A go statement is the cheapest thing to write and the easiest thing to leak. The runtime will not clean up after you: a goroutine that blocks forever sits in memory forever, holding everything it captured. This skill owns goroutine lifetime and ownership — when one starts, how it is guaranteed to stop, and how you wait for it; it is the depth behind go-idiomatic-discipline's axis-1 leak. The mechanics route out: cancellation to go-context, channel signaling to go-channels-select, WaitGroup/Mutex internals and shared-state locking to go-sync-primitives, race-safety to go-race-and-memory-model.


1. The Rule: Never Start a Goroutine You Can't Stop

Every goroutine you launch needs a defined exit decided at the moment you write go: it returns after bounded work, it observes a cancelled context, or it ranges over a channel that someone will close. "When you spawn goroutines, make it clear when or whether they exit" (Google Style Guide — Decisions). If you cannot point to the line that makes a goroutine return, you have written a leak.

// WRONG — no exit: this goroutine blocks on recv forever if nobody ever sends,
// and there is no way to tell it to stop
go func() {
	for job := range jobs { // leaks if `jobs` is never closed
		process(job)
	}
}()

// RIGHT — a context gives every goroutine a guaranteed exit
go func() {
	for {
		select {
		case <-ctx.Done(): // defined exit
			return
		case job := <-jobs:
			process(job)
		}
	}
}()

"Concurrent code should be written such that the goroutine lifetimes are obvious" (Google Style Guide — Decisions). How the context is derived and cancelled belongs to go-context; this skill owns the rule that the exit must exist.


2. Goroutine Leaks: The #1 Concurrency Bug

A leak is a goroutine blocked forever on a channel send or receive that no one will service. It is invisible — the program compiles, the happy path runs, and only under load does memory climb. "This is a resource leak: goroutines consume memory and runtime resources, and heap references in goroutine stacks keep data from being garbage collected" (Pipelines). The classic shape is a sender that outlives its receiver: "if a stage fails to consume all the inbound values, the goroutines attempting to send those values will block indefinitely" (Pipelines).

// WRONG — the goroutine sends one value; if the caller returns early after the
// first result (timeout, error), the send blocks forever and the goroutine leaks
func search(queries []string) <-chan Result {
	out := make(chan Result) // unbuffered
	for _, q := range queries {
		go func() { out <- run(q) }() // blocks until someone receives
	}
	return out
}

// RIGHT — a cancellable context lets a blocked sender abandon the channel
func search(ctx context.Context, queries []string) <-chan Result {
	out := make(chan Result)
	for _, q := range queries {
		go func() {
			select {
			case out <- run(q):
			case <-ctx.Done(): // sender can give up instead of blocking forever
			}
		}()
	}
	return out
}

The blog names the two fixes this skill teaches: give the sender enough buffer for every value, or "explicitly signal senders when the receiver may abandon the channel" (Pipelines) — e.g. closing a done channel, a broadcast because "a receive operation on a closed channel can always proceed immediately" (Pipelines). Channel-close mechanics route to go-channels-select.


3. WaitGroup: Add Before the go, or Use wg.Go (1.25)

To wait for a known set of goroutines, use sync.WaitGroup. The one rule that matters for correctness: Add must run before the goroutine starts, never inside it. "Calls with a positive delta that occur when the counter is zero must happen before a Wait ... Typically this means the calls to Add should execute before the statement creating the goroutine" (pkg.go.dev/sync). Add inside the goroutine races with WaitWait may observe a zero counter and return before the goroutine has even registered. Go 1.25's go vet ships a waitgroup analyzer that "reports misplaced calls to sync.WaitGroup.Add" (Go 1.25 release notes).

// WRONG — Add inside the goroutine: Wait can return before they register
var wg sync.WaitGroup
for _, t := range tasks {
	go func() {
		wg.Add(1) // RACE: caught by `go vet` waitgroup analyzer (1.25)
		defer wg.Done()
		t.run()
	}()
}
wg.Wait()

The pre-1.25 fix is to move wg.Add(1) directly above the go statement and defer wg.Done() inside it. Go 1.25 removes the footgun entirely with wg.Go, which "calls f in a new goroutine and adds that task to the WaitGroup" (pkg.go.dev/sync) — it does the Add/Done for you, so the misplaced-Add bug becomes unwritable. "Callers should prefer WaitGroup.Go" (pkg.go.dev/sync):

// RIGHT (1.25+) — wg.Go does Add/Done; nothing to misplace
var wg sync.WaitGroup
for _, t := range tasks {
	wg.Go(t.run)
}
wg.Wait()

A WaitGroup "must not be copied after first use" (pkg.go.dev/sync) — pass it by pointer. The copy-a-lock rule and the vet copylocks check are owned by go-sync-primitives.


4. errgroup: Fallible Fan-Out and Bounded Pools

sync.WaitGroup waits but carries no error. When the goroutines can fail, use golang.org/x/sync/errgroup, which "provides synchronization, error propagation, and Context cancellation for groups of goroutines working on subtasks of a common task" (pkg.go.dev/errgroup). With errgroup.WithContext, "the first goroutine in the group that returns a non-nil error will cancel the associated Context ... The error will be returned by Wait" (pkg.go.dev/errgroup). Workers must select on that context to actually stop on the first failure.

// RIGHT — first error cancels ctx; siblings that honor ctx.Done() stop early
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
	g.Go(func() error {
		return fetch(ctx, url) // returns ctx.Err() promptly when cancelled
	})
}
if err := g.Wait(); err != nil { // the first non-nil error
	return fmt.Errorf("fetching: %w", err)
}

For a worker pool, SetLimit caps concurrency: it "limits the number of active goroutines in this group to at most n" (pkg.go.dev/errgroup). A subsequent g.Go "blocks until it can add an active goroutine without exceeding the configured limit" — so a million-item loop runs at most N at a time instead of spawning a million goroutines:

// RIGHT — bounded fan-out: at most 8 goroutines alive at once
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range millionItems {
	g.Go(func() error { return handle(ctx, item) })
}
return g.Wait()

A zero Group "is valid, has no limit ... and does not cancel on error" (pkg.go.dev/errgroup) — use WithContext for first-error cancellation. Always check g.Wait()'s result; ignoring it throws away every failure (see references/common-mistakes.md).


5. Prefer Synchronous Functions — Don't Spawn in a Library

A library function should do its work and return; it should not start background goroutines the caller cannot see or stop. "Prefer synchronous functions ... over asynchronous ones. Synchronous functions keep goroutines localized within a call, making it easier to reason about their lifetimes and avoid leaks and data races" (CodeReviewComments — Synchronous Functions). Concurrency is the caller's decision: "If callers need more concurrency, they can add it easily by calling the function from a separate goroutine. But it is quite difficult — sometimes impossible — to remove unnecessary concurrency at the caller side" (CodeReviewComments).

// WRONG — the library spawns a goroutine the caller can't stop or wait for
func (c *Client) Fetch(url string) {
	go func() { c.results <- c.get(url) }() // who stops this? who waits?
}

// RIGHT — synchronous: the caller decides whether to run it concurrently
// (e.g. g.Go(func() error { r, err := c.Fetch(ctx, url); ...; return err }))
func (c *Client) Fetch(ctx context.Context, url string) (Result, error) {
	return c.get(ctx, url)
}

If a library genuinely must run background work, it owns that lifetime explicitly: take a context, expose a Close/Shutdown that blocks until the goroutine exits, and document it.


6. The Loop Variable Is Per-Iteration Since Go 1.22

The classic capture bug — every goroutine seeing the last loop value — is fixed for modules on go 1.22 or later: "each iteration of the loop creates new variables, to avoid accidental sharing bugs" (Go 1.22 release notes). Whether it is safe depends entirely on the module's go directive (go-version-feature-map owns that gate).

// go 1.22+: each iteration has its own v — capturing it directly is now SAFE
for _, v := range items {
	go func() { process(v) }()
}
// Pre-1.22 (module declares go 1.21 or lower): all goroutines share one v and
// likely see the final element. Editing such code still needs a `v := v` copy
// inside the loop before the `go`.

7. A Panic in a Goroutine Crashes the Whole Process

An unrecovered panic in any goroutine terminates the entire program — a recover in the goroutine that spawned it does nothing, because recover only catches panics on its own stack. This is why wg.Go's contract is blunt: "The function f must not panic" (pkg.go.dev/sync). A goroutine that runs caller-supplied or fallible work must recover at its own top frame and turn the panic into an error.

// RIGHT — recover at the goroutine boundary so one bad task can't kill the process
g.Go(func() (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("task panicked: %v", r)
		}
	}()
	return riskyWork()
})

The defer/panic/recover mechanics — recover only inside a deferred func, only on the same stack — are owned by go-defer-panic-recover.


8. Don't Communicate by Sharing Memory

Go's headline slogan picks the default tool: "Do not communicate by sharing memory; instead, share memory by communicating" (Effective Go); "Don't communicate by sharing memory, share memory by communicating" (Go Proverbs). Pass ownership of a value down a channel rather than guard a shared variable with a lock — but it is a default, not an absolute: "Channels orchestrate; mutexes serialize" (Go Proverbs). Channel to hand off work and signal completion; Mutex when goroutines truly share one piece of state. Channel mechanics route to go-channels-select; the mutex side to go-sync-primitives.


9. The Headline Disciplines

DisciplineThe ruleSource
Defined exitNever start a goroutine without a guaranteed stop"make it clear when or whether they exit" (Google Decisions)
Leaks are silentA goroutine blocked on a channel is never GC'd"Goroutines are not garbage collected; they must exit on their own" (Pipelines)
Add before goWaitGroup.Add before the go, never inside it"calls to Add should execute before the statement creating the goroutine" (sync)
Prefer wg.Go (1.25)Let wg.Go do Add/Done"Callers should prefer WaitGroup.Go" (sync)
errgroup for failureFirst error cancels ctx and is returned by Wait"The first goroutine ... that returns a non-nil error will cancel the associated Context" (errgroup)
Bound the poolSetLimit(n) instead of unbounded goroutine-per-item"limits the number of active goroutines ... to at most n" (errgroup)
Synchronous librariesDon't spawn in a library; let the caller add concurrency"it is quite difficult — sometimes impossible — to remove unnecessary concurrency at the caller side" (CodeReviewComments)
Share by communicatingChannel to hand off; mutex to serialize shared state"share memory by communicating" (Proverbs)

10. Who Suffers When This Is Done Badly

The cost of a leaked or unstoppable goroutine never lands at write time:

  • The on-call engineer paged at 3am by a service whose memory climbs until the OOM killer fires — the root cause a go func(){ out <- result }() whose receiver returned early on a timeout, leaving thousands of senders blocked forever. The happy path passed every test.
  • The teammate who adds a feature to a library and discovers it silently spawns background goroutines with no Close, no context, no way to wait — so their own tests flake and their shutdown never completes.
  • The next reader of an unbounded for _, item := range millionItems { go work(item) }, explaining to the customer why the box fell over: a million goroutines at once, instead of a SetLimit(N) pool.

"Goroutines can leak by blocking on channel sends or receives" (Google Decisions) is an operational warning, not a style nit. Every goroutine you start is a promise to stop it.


11. Routing to Related Skills

  • go-idiomatic-discipline — the policy root; the leak is its axis-1 "never start a goroutine you can't stop."
  • go-context — how a goroutine is cancelled: deriving a context, defer cancel(), propagating ctx to workers, ctx.Err() in loops.
  • go-channels-select — channel signaling mechanics: who closes, chan struct{} done signals, nil-channel disable, select/default, buffered-vs-unbuffered.
  • go-sync-primitivesWaitGroup/Mutex internals, never-copy-a-lock (copylocks), and locking shared state when a channel isn't the right tool.
  • go-race-and-memory-model — whether the shared access in a goroutine is actually safe; go test -race; happens-before.
  • go-defer-panic-recover — recovering a panic at a goroutine boundary so one task can't crash the process.
  • go-version-feature-map — which idiom the module's go directive allows: loop-var-per-iteration (1.22), wg.Go and the waitgroup vet analyzer (1.25).

12. Reference Files

High-frequency goroutine-lifetime anti-patterns in LLM-generated Go, each with wrong/right code and citations:

${CLAUDE_SKILL_DIR}/references/common-mistakes.md

Source provenance for every claim in this skill:

${CLAUDE_SKILL_DIR}/references/sources.yaml

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.