Go Concurrency

SkillAI & models

Idiomatic Go concurrency - sync primitives, channel semantics, the select statement, and the standard channel patterns (cancellation/done-channel, fan-out/fan-in, pipeline, or-done, context, worker-pool). Use when writing or reviewing Go that spawns goroutines, shares state, or coordinates over channels, even if the user never says "concurrency" - e.g. data races, deadlocks, goroutine leaks, sync/context/select usage, or "make this parallel". Distilled from luk4z7/go-concurrency-guide.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Go Concurrency skill

What this skill tells your AI

The instructions your AI receives, as published by inference-gateway/inference-gateway in .agents/skills/go-concurrency/SKILL.md and read by ahel’s review.

A quick-reference for writing and reviewing concurrent Go. Reach for the table that fits the problem, copy the nearest pattern, then check it against the gotchas. Verify everything with go test -race.

Don't communicate by sharing memory; share memory by communicating. Use channels to pass ownership of data, distribute work, and signal. Use a mutex for in-place shared state (caches, counters, registries).

Pick your tool

NeedUse
Hand off / stream values between goroutineschan
Protect shared state in placesync.Mutex / sync.RWMutex (read-heavy)
Wait for N goroutines to finishsync.WaitGroup
Run exactly once (lazy init)sync.Once
Reuse expensive allocations, cut GC pressuresync.Pool
Wake goroutines on a conditionsync.Cond (rare; a channel is usually clearer)
Lock-free counters / flagssync/atomic
Cancellation, deadlines, request-scoped valuescontext.Context

Always defer mu.Unlock() right after mu.Lock() so a panic can't leave the lock held.

Channel states

Behaviour is defined by the operation × the channel's state. Memorize this:

Operationnil channelopen channelclosed channel
receive v := <-cblocks forevera value (or blocks if empty)zero value, ok==false (drains buffer first)
send c <- vblocks foreversends (or blocks if full)panic
close(c)paniccloses itpanic
  • Unbuffered (make(chan T)): send and receive rendezvous - each blocks until the other is ready. This is the synchronization.
  • Buffered (make(chan T, n)): send blocks only when full, receive only when empty. A buffer decouples timing; it does not remove backpressure.
  • Ownership rule: exactly one goroutine owns a channel - it creates, writes, and closes it, then hands consumers a receive-only <-chan T. This makes the panics above structurally impossible. Receivers never close.
  • range c reads until the channel is closed; v, ok := <-c detects closure.

select idioms

select blocks until one case is ready; if several are ready it picks one pseudo-randomly (so no case can starve another). nil channels are never ready - set a channel to nil to disable its case.

// timeout - but time.After leaks a timer until it fires; in a hot loop use a
// context or a reused time.Timer instead.
select {
case v := <-c:
    use(v)
case <-time.After(time.Second):
    return errTimeout
}

// non-blocking poll
select {
case v := <-c:
    use(v)
default: // nothing ready, carry on
}

// cancellation - prefer sending under a done/ctx guard so you never block forever
select {
case out <- v:
case <-done:
    return
}

Gotchas

  • Data race: concurrent read+write of the same variable with no synchronization. Undefined behaviour. Catch it with go test -race / go run -race.
  • Goroutine leak: a goroutine blocked on a channel that will never proceed. Every goroutine you start needs a guaranteed exit - a done/ctx signal or a channel that will close. Leaks accumulate silently.
  • Deadlock: all goroutines blocked. Causes: acquiring locks in inconsistent order (always lock in the same order), or send/receive on a nil channel, or an unbuffered send with no receiver. fatal error: all goroutines are asleep.
  • Send on closed channel / double close: panics. Follows from the ownership rule - one owner closes, once.
  • Loop-variable capture: pre-Go 1.22, for _, v := range xs { go f(v) } shared one v; pass it as an argument. Go 1.22+ gives each iteration its own copy. Know which Go you target.
  • Livelock / starvation: goroutines run but make no progress (livelock), or a greedy lock-holder keeps others from ever running (starvation). Usually a sign to add backoff or rebalance critical-section size.

Signature patterns

Short, copy-ready skeletons. They use any (= interface{}) and assume Go 1.22+ loop semantics.

Done-channel cancellation - pass a read-only done; close it once to stop all listeners. Send results under the done guard so a cancelled consumer can't wedge the producer.

func worker(done <-chan struct{}, in <-chan string) <-chan string {
    out := make(chan string)
    go func() {
        defer close(out)
        for s := range in {
            select {
            case out <- transform(s):
            case <-done: // caller cancelled → exit, no leak
                return
            }
        }
    }()
    return out
}

done := make(chan struct{})
defer close(done) // every return path cancels the worker

Fan-out / fan-in - many workers read one input channel (distribute load), one channel merges their results. No value is processed twice.

func fanIn(done <-chan struct{}, cs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    wg.Add(len(cs))
    for _, c := range cs {
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                select {
                case out <- v:
                case <-done:
                    return
                }
            }
        }(c)
    }
    go func() { wg.Wait(); close(out) }() // close once every worker drains
    return out
}

Pipeline - compose stages, each a goroutine that reads one channel and returns the next. Every stage closes the channel it owns, so closure propagates downstream and range terminates cleanly.

func sq(in <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for n := range in {
            out <- n * n
        }
    }()
    return out
}

for n := range sq(sq(gen(2, 3))) { // 16, 81
    fmt.Println(n)
}

Or-done - range over a channel without leaking when you might quit early. Wrap the channel; the loop body stays clean.

func orDone(done, c <-chan any) <-chan any {
    out := make(chan any)
    go func() {
        defer close(out)
        for {
            select {
            case <-done:
                return
            case v, ok := <-c:
                if !ok {
                    return
                }
                select {
                case out <- v:
                case <-done:
                }
            }
        }
    }()
    return out
}

for v := range orDone(done, stream) { // safe; exits on done or closure
    use(v)
}

Context - the standard cancellation/deadline carrier. Take ctx as the first argument, always defer cancel(), and honor ctx.Done() in your loops.

ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel() // releases resources even on early return

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req) // aborts on cancel/timeout

select {
case out <- v:
case <-ctx.Done():
    return ctx.Err() // context.Canceled or DeadlineExceeded
}

Queuing / semaphore - a buffered channel bounds how many goroutines run at once. Acquire before launching, release on exit.

sem := make(chan struct{}, runtime.NumCPU()) // N concurrent slots
var wg sync.WaitGroup
for _, job := range jobs {
    sem <- struct{}{} // blocks while N are in flight
    wg.Add(1)
    go func(job Job) {
        defer wg.Done()
        defer func() { <-sem }() // free the slot
        process(job)
    }(job)
}
wg.Wait()

Lower-frequency patterns from the source, when you need them: or-channel (combine many done signals into one), tee (duplicate one stream into two), bridge (flatten a channel-of-channels), heartbeat (periodic liveness signal for monitoring), replicated requests (fan a request to N handlers, take the first reply, cancel the rest).

Scheduler, briefly

Go multiplexes goroutines onto OS threads with an M:N work-stealing scheduler: G = goroutine, M = OS thread, P = processor (a run context). GOMAXPROCS (default runtime.NumCPU()) caps how many Ps run Go code at once; an idle P steals goroutines from a busy P's queue. Goroutines are cheap

  • ~2 KB initial stacks that grow on demand and nanosecond-scale switches - so thousands are fine where threads would not be. You rarely manage any of this directly; you make goroutines cancellable and let the runtime schedule them. None of it makes a racy program correct - run -race.

Distilled from luk4z7/go-concurrency-guide, which draws on "Concurrency in Go" (Katherine Cox-Buday) and "The Go Programming Language".

Signals

GitHub stars
211
Forks
52
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
go-concurrency-inference-gateway
Source
github.com/inference-gateway/inference-gateway