Skip to content
Back to skills

Go Expert

ASecurity

Use when writing or reviewing Go code that starts goroutines, holds a sync.Mutex/RWMutex, or closes channels - to enforce goroutine lifecycle ownership, lock-hygiene, and channel-close-side rules, and to wire up the detection tooling (go test -race, go vet, go.uber.org/goleak) that catches violations tests alone won't. Use when the user asks "review this Go concurrency code," "why is this goroutine leaking," or "is this Go code race-safe."

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 16, 2026
ai-agentsgobashtesting

Security analysis

A100/100

Pro scans all 2 files and shows the line behind each finding

Scanned October 16, 2026

npx -y skills add shennawardana23/skillme --skill go-expert --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Go Expert?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Go Expert
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/shennawardana23-go-expert/badge)](https://www.skillsdirectory.com/skills/shennawardana23-go-expert)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: go-expert
description: Use when writing or reviewing Go code that starts goroutines, holds a sync.Mutex/RWMutex, or closes channels - to enforce goroutine lifecycle ownership, lock-hygiene, and channel-close-side rules, and to wire up the detection tooling (go test -race, go vet, go.uber.org/goleak) that catches violations tests alone won't. Use when the user asks "review this Go concurrency code," "why is this goroutine leaking," or "is this Go code race-safe."
license: Apache-2.0
metadata:
  version: "0.1.0"
---

# Go Concurrency Correctness

Race conditions and goroutine leaks are the Go bugs that pass every test run
and only surface under load, months later. `go-service-idioms` covers
*composing* concurrent work with `errgroup`; this skill covers the
*lower-level ownership rules* that make individual goroutines, mutexes, and
channels safe to combine that way in the first place. Apply these rules
whenever code starts a goroutine, takes a lock, or closes a channel — then
run the detection tooling in the last section to confirm you didn't miss
one, since these bugs are usually invisible without instrumentation.

## Goroutine lifecycle: every goroutine needs an owner and a stop signal

Never start a goroutine without a way to observe when it's done or tell it
to stop — a `context.Context`, a `done` channel, or a `sync.WaitGroup`
someone actually waits on.

```go
// Bad: fire-and-forget, no one can wait for it or cancel it
go worker(items)

// Good: owned, cancelable, and its completion is observable
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
    defer close(done)
    worker(ctx, items)
}()
```

Pair every `wg.Add(1)` with a `defer wg.Done()` **on the next line inside the
goroutine**, not somewhere later in the function body — a `return` or
`panic` between `Add` and a non-deferred `Done` hangs every caller of
`wg.Wait()` forever.

```go
wg.Add(1)
go func() {
    defer wg.Done() // immediately, before any other logic
    process(item)
}()
```

## Mutex hygiene: lock and defer-unlock are one atomic thought

`defer mu.Unlock()` goes on the line immediately after `mu.Lock()` — never
lock, do conditional work, and defer the unlock later based on a branch.
A goroutine that panics while holding a lock without a deferred unlock
poisons every future caller with a permanent deadlock, since Go mutexes
don't auto-release on panic.

```go
// Bad: unlock is reachable but not guaranteed on every path
mu.Lock()
if invalid(state) {
    mu.Unlock()
    return errors.New("invalid")
}
mutate(state)
mu.Unlock()

// Good: unlock guaranteed regardless of how the function returns
mu.Lock()
defer mu.Unlock()
if invalid(state) {
    return errors.New("invalid")
}
mutate(state)
```

Prefer `sync.RWMutex` over `sync.Mutex` for read-heavy shared state — many
concurrent readers can hold `RLock()` simultaneously, only a writer needs
exclusive `Lock()`. Never copy a struct containing a `sync.Mutex` by value
(passing it to a function, storing it in a slice that reallocates) — `go
vet`'s copylocks check catches this; see Detection below.

## Channel ownership: only the sender closes

Close a channel from the goroutine that sends on it, never from a receiver
— closing from the receiver side, or closing twice from anywhere, panics at
runtime (`close of closed channel` / `send on closed channel`).

```go
// Good: sender owns the channel's lifecycle
func produce(ctx context.Context, out chan<- int) {
    defer close(out) // only the producer closes
    for i := 0; i < 10; i++ {
        select {
        case out <- i:
        case <-ctx.Done():
            return
        }
    }
}
```

Use a buffered channel (`chan struct{}`) as a semaphore to bound
concurrency rather than spawning a goroutine pool with its own internal
queue — it's simpler and its capacity *is* the bound, visible at the
declaration site:

```go
sem := make(chan struct{}, maxConcurrent)
for _, item := range items {
    sem <- struct{}{} // acquire
    go func(item Item) {
        defer func() { <-sem }() // release
        process(item)
    }(item)
}
```

Never use `time.Sleep` to "wait long enough" for another goroutine to finish
or to synchronize state — it is not a synchronization primitive and is
inherently racy under load; use a channel receive, `sync.WaitGroup.Wait()`,
or `sync.Cond` instead.

## Detection: prove the rules held, don't just follow them by eye

These bugs are frequently invisible in code review and in a normal
`go test` run because they depend on scheduler timing. Always pair the
rules above with tooling that actually exercises the race:

```bash
go test -race ./...          # detects concurrent unsynchronized access
go vet ./...                 # catches sync.Mutex copies (copylocks), among others
```

For goroutine leaks specifically — a goroutine that outlives the test that
started it, holding a channel or context reference — add
[`go.uber.org/goleak`](https://pkg.go.dev/go.uber.org/goleak) to the test's
`TestMain`:

```go
func TestMain(m *testing.M) {
    goleak.VerifyTestMain(m)
}
```

`-race` only detects a data race if the racing goroutines actually run
concurrently *during that specific test execution* — a race that only
manifests under production load or a specific interleaving can pass
`-race` cleanly in CI. Treat a clean `-race` run as evidence, not proof.

## Gotchas

- A `select` with a `default` case turns a blocking channel op into a
  non-blocking poll — if you meant to wait, dropping `default` back in is
  usually the fix for a goroutine that reports success without actually
  doing the work.
- `sync.WaitGroup.Add` called *inside* the goroutine it's tracking (instead
  of before `go func(){}()`) races with `Wait()` potentially returning
  before the `Add` even runs — call `Add` in the parent goroutine, before
  spawning.
- A `nil` channel blocks forever on both send and receive — this is
  sometimes used deliberately in a `select` to disable a case, but if it's
  accidental (a channel field never initialized), the symptom is a goroutine
  that just hangs with no error, easily mistaken for a deadlock elsewhere.

## Real-world grounding

Uber open-sourced `go.uber.org/goleak` specifically because goroutine leaks
pass ordinary test suites silently — a leaked goroutine holds no assertion
that fails, it just accumulates, and the industry-standard way to catch it
is to assert on goroutine count at test teardown rather than to try to spot
it by reading code.

Files in this skill

  • SKILL.md6.4 KB
  • evals/evals.json3.2 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.