agentsclimarketplace

Go channels select

Skill ctoth/golang-skills-plugin/plugins/golang/skills/go-channels-select

Agent skill plugins for Go code quality and performance work.

Install
npx -y skills add ctoth/golang-skills-plugin --skill go-channels-select

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 Go channel mechanics — declare directionality in signatures (chan<- send-only, <-chan receive-only), let only the sender close and only when sends are done (closing is a broadcast, not cleanup), read the comma-ok / range-ends-on-close idiom so a closed channel's zero value is not mistaken for data, choose unbuffered (synchronization) vs buffered (decoupling) deliberately, use chan struct{} for pure signals, and drive select with default for non-blocking and the nil-channel trick to disable a case. Auto-invokes when writing or editing channels, chan declarations, close(), select statements, or buffered channels, and on "send on closed channel panic" / "why does this select block" / "why does my range over a channel never end" requests. The depth behind the policy root's "don't fight Go" for the communication primitive.

SKILL.md

17.5 KB, as published. Nobody here has run it

Go Channels and Select

"Do not communicate by sharing memory; instead, share memory by communicating." — Effective Go

"This close is effectively a broadcast signal to the senders." — Go Concurrency Patterns: Pipelines and cancellation

"Channels orchestrate; mutexes serialize." — Go Proverbs

A channel is a typed conduit with exact, specified rules: who may send, who may close, what a receive returns after a close, and when an operation blocks. Most channel bugs are not subtle — they are one of those rules violated. This skill owns channel mechanics. Goroutine lifetime (who starts and stops the goroutine on each end) is owned by go-concurrency-goroutines; cancellation via ctx.Done() is owned by go-context; lock-based sharing is owned by go-sync-primitives.


1. The Rules, and Where Each Is Stated

Every rule below is a hard fact of the language spec or established guidance, not a preference. The owning section holds the depth.

RuleThe mechanicSource
Declare direction in signatureschan<- T send-only, <-chan T receive-only — documents and enforces the role"A channel may be constrained only to send or only to receive by assignment or explicit conversion" (Spec — Channel types)
Only the sender closesClose says "no more values," from the side that produces them"stages close their outbound channels when all the send operations are done" (Pipelines)
Send on closed panicsNever close from a receiver or twice"A send on a closed channel proceeds by causing a run-time panic" (Spec — Send); "Sending to or closing a closed channel causes a run-time panic" (Spec — Close)
Receive from closed is not an errorIt yields the zero value with ok == false"A receive operation on a closed channel can always proceed immediately, yielding the element type's zero value" (Spec — Receive)
Unbuffered = rendezvousSend and receive complete together"communication succeeds only when both a sender and receiver are ready" (Spec — Channel types)
Buffer size 1 or noneAny larger buffer needs justification"Channels should usually have a size of one or be unbuffered ... Any other size must be subject to a high level of scrutiny" (Uber)
nil channel blocks foreverUsed to disable a select case"a select with only nil channels and no default case blocks forever" (Spec — Select)
default makes select non-blockingOtherwise select blocks until a case is ready"if there is a default case, that case is chosen" (Spec — Select)

2. Declare Channel Direction in Signatures

A bidirectional chan T in a parameter says nothing about the function's role. A directional type does: "A channel may be constrained only to send or only to receive by assignment or explicit conversion" (Spec — Channel types) — chan<- float64 "can only be used to send," <-chan int "can only be used to receive" (Spec — Channel types). The compiler then enforces the role, and the signature documents it. A bidirectional channel converts to a directional one implicitly at the call site.

// WRONG — both params are chan T; nothing stops produce from receiving or consume from closing
func produce(ch chan int)  { /* could accidentally <-ch or close from the wrong side */ }
func consume(ch chan int)  { /* could accidentally close(ch) — a receiver must never close */ }

// RIGHT — direction is part of the contract; misuse is a compile error
func produce(out chan<- int) { out <- 1; close(out) } // send-only: can send and close
func consume(in <-chan int)  { for v := range in { use(v) } } // receive-only: cannot close(in)

close(in) where in is <-chan int does not compile — "It is an error if ch is a receive-only channel" (Spec — Close) — so directionality turns the "only the sender closes" rule (§3) into a checked guarantee.


3. Who Closes: The Sender, Once, and Never the Receiver

Closing is not cleanup and it is not a free()-style "I'm done with this." It is a one-time announcement to receivers that no more values are coming: close(ch) "records that no more values will be sent on the channel" (Spec — Close). So it belongs to whoever sends: "stages close their outbound channels when all the send operations are done" (Pipelines).

Closing is a broadcast. Every blocked receiver unblocks at once, because "a receive operation on a closed channel can always proceed immediately" (Pipelines); "This close is effectively a broadcast signal to the senders" (Pipelines). That is the whole point of the done-channel pattern (§6) — one close wakes N goroutines.

Two corollaries, both enforced by the runtime:

  • A receiver must never close. It does not own the send side, and a later send from the real owner would panic (§4).
  • With multiple senders, no single sender may close — a second send (or second close) panics: "Sending to or closing a closed channel causes a run-time panic" (Spec — Close). Coordinate shutdown elsewhere (a sync.WaitGroup/errgroup) and close once after all senders stop — that coordination is goroutine lifetime, owned by go-concurrency-goroutines.

A channel need not be closed at all: close only to signal "no more values" to a ranging receiver; an unclosed channel is garbage-collected normally.


4. Send on Closed Panics; Receive From Closed Returns zero, ok=false

These two rules are asymmetric, and the asymmetry is the source of most close-related bugs.

Sending on a closed channel is fatal: "A send on a closed channel proceeds by causing a run-time panic" (Spec — Send). There is no recover-and-continue idiom for it; the fix is to never let a send race a close (§3).

Receiving from a closed channel is safe and silent — it hands back the zero value. So the bare v := <-ch cannot tell "real zero that was sent" from "channel closed." Use the comma-ok form: "The value of ok is true if the value received was delivered by a successful send operation to the channel, or false if it is a zero value generated because the channel is closed and empty" (Spec — Receive).

// WRONG — once ch is closed, this loops forever handing 0 to process(); the zero is not real data
for {
	v := <-ch          // closed channel returns 0, ok discarded
	process(v)
}

// RIGHT — comma-ok distinguishes a sent value from a close
for {
	v, ok := <-ch
	if !ok {
		break          // channel closed and drained
	}
	process(v)
}

// RIGHT (idiomatic) — range receives until the channel is closed, then stops
for v := range ch {
	process(v)
}

for v := range ch is the same rule packaged: it pulls values until the channel is closed and drained, then ends. Ranging a channel that is never closed blocks forever once the buffer empties — a "why does my range over a channel never end" hang is almost always a missing close on the sender side.


5. Unbuffered Is Synchronization; Buffered Is Decoupling

The capacity argument to make is a semantic choice, not a tuning knob.

An unbuffered channel is a rendezvous: "communication succeeds only when both a sender and receiver are ready" (Spec — Channel types). The send and the receive happen together, so it also synchronizes: "Unbuffered channels combine communication—the exchange of a value—with synchronization—guaranteeing that two calculations (goroutines) are in a known state" (Effective Go). Reach for unbuffered first — it gives you a handoff guarantee.

A buffered channel decouples sender from receiver up to the buffer's size: "communication succeeds without blocking if the buffer is not full (sends) or not empty (receives)" (Spec — Channel types). That is useful for a known, bounded count (a semaphore limiting concurrency, or collecting exactly N results), but it removes the handoff guarantee.

The size you pick must mean something: "Channels should usually have a size of one or be unbuffered ... Any other size must be subject to a high level of scrutiny. Consider how the size is determined, what prevents the channel from filling up under load and blocking writers" (Uber).

// WRONG — a big buffer chosen to make a deadlock "go away"; it only delays the block and hides backpressure
results := make(chan Result, 1000) // why 1000? what happens at 1001?

// RIGHT — unbuffered: the receiver is guaranteed to have the value before the sender proceeds
results := make(chan Result)

// RIGHT — buffer sized to a real bound: exactly len(jobs) results, never more
results := make(chan Result, len(jobs))

A buffer chosen to paper over "send blocks forever" hides a missing receiver — see the deadlock case in references/common-mistakes.md.


6. chan struct{} for Pure Signals

When a channel carries no data — only the fact that an event happened — its element type should be struct{}, which occupies zero bytes. The done/quit pattern is the canonical use: closing it broadcasts "stop" to every receiver (§3). The pipelines blog uses exactly this — done := make(chan struct{}) — and notes that closing it unblocks all waiters at once (Pipelines).

// RIGHT — a signal-only channel; the value never matters, only "did it fire"
done := make(chan struct{})

go func() {
	defer close(done) // broadcast completion to every <-done waiter
	work()
}()

<-done // blocks until work() finishes and close(done) fires

Prefer close(done) (a broadcast, readable any number of times) over sending one value per waiter (which you would have to count). Use struct{} not bool or int: the type itself says "this is a signal, there is no payload." For cancellation specifically, you usually want ctx.Done() rather than a hand-rolled done channel — owned by go-context.


7. select Chooses a Ready Case; default Makes It Non-Blocking

A select "chooses which of a set of possible send or receive operations will proceed" (Spec — Select). The rules are precise:

  • One ready case: it runs.
  • Several ready cases: "a single one that can proceed is chosen via a uniform pseudo-random selection" (Spec — Select) — you cannot rely on priority order.
  • No ready case, with a default: "if there is a default case, that case is chosen" (Spec — Select) — the select does not block.
  • No ready case, no default: "the select statement blocks until at least one of the communications can proceed" (Spec — Select).

So default is the non-blocking switch. Use it for a "try" operation — but never inside a tight for with nothing else, or you get a busy-loop spinning the CPU (see references/common-mistakes.md).

// Non-blocking send: drop the value if no receiver is ready, instead of blocking
select {
case ch <- v:
	// delivered
default:
	// nobody ready; skip rather than block
}

An empty select{} has no cases that can ever proceed, so it blocks the goroutine forever — occasionally used in a main that should park while background goroutines run, but usually a sign something is wrong.


8. The nil-Channel Trick: Disable a select Case

A nil channel never communicates: "Receiving from a nil channel blocks forever" (Spec — Receive) and "A send on a nil channel blocks forever" (Spec — Send). In a select, a case whose channel is nil can never be chosen — "communication on nil channels can never proceed" (Spec — Select). Setting a channel variable to nil therefore removes its case from the select, dynamically, without restructuring the loop.

The classic use: when an input channel is drained (closed), set it to nil so the loop stops selecting on it but keeps serving the other cases.

// RIGHT — once `in` is closed, nil it out so this select stops spinning on the always-ready closed case
func merge(in <-chan int, out chan<- int, done <-chan struct{}) {
	for in != nil {
		select {
		case v, ok := <-in:
			if !ok {
				in = nil // disable this case; a closed channel is "always ready" and would busy-loop
				continue
			}
			out <- v
		case <-done:
			return
		}
	}
}

Without the in = nil, a closed in is permanently ready and <-in keeps firing with the zero value — the busy-loop from §4 wearing a select. Niling the case is the idiomatic disable.


9. select for Timeout and Cancellation

select composes a channel operation with an escape hatch. The two escape hatches are a timer and a context:

// Timeout: race the work against a deadline
select {
case v := <-work:
	use(v)
case <-time.After(2 * time.Second):
	return errTimeout
}

// Cancellation: race the work against the caller giving up
select {
case v := <-work:
	use(v)
case <-ctx.Done():
	return ctx.Err()
}

Prefer ctx.Done() for anything request-scoped or cancellable — it propagates and composes — and reserve time.After for a genuine local timeout. The ctx.Done() channel, ctx.Err(), and the context.Canceled / context.DeadlineExceeded values are owned by go-context; this skill owns only the select mechanic that consumes them. (A time.After in a hot loop allocates a timer per iteration that lives until it fires — for a repeated timeout reset a time.Timer, owned by go-time.)


10. Who Suffers When Channels Are Done Badly

The victim is never the author at write time:

  • The on-call engineer paged at 3am by a deadlock: every goroutine blocked sending to a channel whose only receiver returned early, the process wedged with no error and no log.
  • The teammate chasing a worker that "sometimes" processes a phantom zero record — a v := <-ch that lost the , ok and read a closed channel's zero value as data (§4).
  • The whole service, crashed by a close of closed channel panic because shutdown let two senders both close — a rule the spec makes fatal precisely so it cannot be ignored (Spec — Close).

"Channels orchestrate; mutexes serialize" (Go Proverbs) is also a warning: a channel reached for where a sync.Mutex would do (guarding one shared field) buys a concurrency primitive's full failure surface — deadlocks, leaks, panics — to do a lock's job. When the answer is "protect this state," not "hand off this value," that is go-sync-primitives.


11. Routing to Related Skills

  • go-idiomatic-discipline — the policy root; this skill is the channel-mechanics depth behind its "don't fight Go."
  • go-concurrency-goroutineswho owns the channel: goroutine lifetime, who starts/stops each end, closing once after N senders stop, WaitGroup/errgroup. The §3 coordination lives here.
  • go-contextctx.Done() as the cancellation channel in §9, and context.Canceled/DeadlineExceeded as values.
  • go-sync-primitives — when a Mutex beats a channel (§10): guarding shared state vs handing off values.
  • go-race-and-memory-model — why a channel send/receive establishes happens-before, and how to detect the races a misused channel leaves behind (go test -race).
  • go-iterators-rangefunc — the channel-vs-iterator choice for producing a sequence.
  • go-timetime.Timer/Ticker lifetime behind the §9 timeout.

12. Reference Files

High-frequency channel and select 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.