Skip to content

Repository files navigation

goconcurrencylint

goconcurrencylint logo with a Go-inspired concurrency inspector mascot

Go Version License: MIT Go Reference CI Go Report Card

A static analyzer for Go that catches common concurrency mistakes around sync.Mutex, sync.RWMutex, sync.WaitGroup, and sync.Once — before they reach production.


Table of Contents


Why goconcurrencylint?

Concurrency bugs in Go are notoriously hard to debug: races, deadlocks, and leaked goroutines often surface only under production load. The standard Go toolchain ships -race for data races, but nothing flags structural misuse of synchronization primitives at compile time.

goconcurrencylint fills that gap with control-flow-sensitive static analysis. It walks the AST of every function, tracks lock/unlock and Add/Done state across if, switch, select, loops, and goroutines, and reports paths where synchronization primitives are used incorrectly — including across files of the same package.

It is built on the standard go/analysis framework, so it drops into any Go tooling pipeline without extra machinery.

Installation

Install the binary with go install:

go install github.com/sanbricio/goconcurrencylint/cmd/goconcurrencylint@latest

This places goconcurrencylint in $GOBIN (or $GOPATH/bin). Make sure that directory is on your PATH.

Requirements: Go 1.25 or later.

Build from source

git clone https://github.com/sanbricio/goconcurrencylint.git
cd goconcurrencylint
go build -o goconcurrencylint ./cmd/goconcurrencylint

Use it from golangci-lint

goconcurrencylint is also a golangci-lint module plugin, so you can run it from the golangci-lint you already have instead of installing a second binary. Declare it in .custom-gcl.yml:

version: v2.12.2
name: custom-gcl
destination: ./bin

plugins:
  - module: github.com/sanbricio/goconcurrencylint
    import: github.com/sanbricio/goconcurrencylint/pkg/golangci
    version: v0.5.0

Run golangci-lint custom to build ./bin/custom-gcl — a golangci-lint binary with this linter compiled in — then enable it in .golangci.yml:

linters:
  enable:
    - goconcurrencylint
  settings:
    custom:
      goconcurrencylint:
        type: module
        description: Detects misuse of sync primitives and channels.
        settings:
          checks:
            - all
            - -GCL5001

checks is the -checks flag written as a YAML list, one entry per element, the way golangci-lint writes every other list setting: -checks "all,-GCL5001" on the command line is checks: [all, -GCL5001] here. Omit the key to run every check.

Getting it wrong fails the run with exit code 3 rather than quietly dropping a check — a mistyped key, a check that does not exist, an empty list, and the flag's comma-separated string in place of a list are all errors, the last one printing the list to write instead.

Full walkthrough — CI, severity rules, run.tests, and building against a local checkout: docs/golangci-lint.md.

Quick Start

Run the analyzer against your module:

goconcurrencylint ./...

Example diagnostics:

mutex.go:12:2: GCL1001: mutex 'mu' is locked but not unlocked
waitgroup.go:23:3: GCL2001: waitgroup 'wg' has Add without corresponding Done
waitgroup.go:41:2: GCL2004: waitgroup 'wg' Go called after Wait

Every diagnostic is prefixed with a stable code (GCL1001). Run goconcurrencylint explain GCL1001 for a full description of any check, or browse the check catalogue.

Because the tool is a standard go/analysis single-checker, it accepts the usual package patterns (./..., ./pkg/..., individual import paths) and standard analyzer flags.

Checks

Each check has a stable code (e.g. GCL1001) shown in the diagnostic message and carried as the analysis.Diagnostic.Category, so golangci-lint and IDE integrations can filter or label by check. The legacy kebab-case slug is still accepted in ignore directives. Per-check pages live under docs/checks/, or run goconcurrencylint explain <code>.

Code Slug Primitive Description
GCL1001 lock-without-unlock sync.Mutex, sync.RWMutex A Lock()/RLock() call has no matching Unlock()/RUnlock() on some execution path.
GCL1002 unlock-without-lock sync.Mutex, sync.RWMutex An Unlock()/RUnlock() call is reached without a prior matching lock (including double-unlocks).
GCL1003 defer-unlock-without-lock sync.Mutex, sync.RWMutex A deferred Unlock()/RUnlock() can run while the mutex is unlocked.
GCL1004 unchecked-trylock sync.Mutex, sync.RWMutex TryLock()/TryRLock() is called without checking the returned boolean.
GCL1005 defer-lock sync.Mutex, sync.RWMutex defer mu.Lock()/RLock() is used where an unlock was almost certainly intended.
GCL1006 mutex-in-loop sync.Mutex, sync.RWMutex A mutex is declared inside a loop body, creating a fresh lock per iteration.
GCL1007 defer-unlock-in-loop sync.Mutex, sync.RWMutex defer mu.Unlock() lives inside a loop body, so the unlock only runs at function return.
GCL1008 rwmutex-api-mismatch sync.RWMutex Unlock() is used for a read lock, or RUnlock() is used for a write lock.
GCL1009 goroutine-lock-deadlock sync.Mutex, sync.RWMutex A goroutine started while a lock is held tries to take the same lock before the parent releases it.
GCL1010 panic-before-unlock sync.Mutex, sync.RWMutex A statically-known out-of-range index can panic between Lock() and a non-deferred unlock.
GCL1011 double-lock sync.Mutex, sync.RWMutex A second Lock() is taken while the first is still held.
GCL1012 lock-order-cycle sync.Mutex, sync.RWMutex Two functions acquire the same pair of mutexes in opposite orders — a classic deadlock pattern.
GCL1013 rwmutex-recursive-lock sync.RWMutex A goroutine re-acquires an RWMutex it already holds in a conflicting mode (read then write, or write then read), which self-deadlocks.
GCL2001 add-without-done sync.WaitGroup wg.Add(n) has fewer guaranteed Done()s than its count, so the counter can never reach zero.
GCL2002 done-without-add sync.WaitGroup wg.Done() is called more times than wg.Add() allows, which panics at runtime.
GCL2003 add-after-wait sync.WaitGroup wg.Add() is called after wg.Wait() returned with an empty counter — a classic reuse bug.
GCL2004 go-after-wait sync.WaitGroup wg.Go() is called after wg.Wait() returned empty — the Go 1.25 variant of add-after-wait.
GCL2005 add-inside-goroutine sync.WaitGroup wg.Add() is called from inside a worker goroutine, racing with Wait().
GCL2006 done-not-deferred sync.WaitGroup A worker calls Done() on a path a runtime.Goexit or recovered panic can skip, instead of deferring it.
GCL2007 add-loop-count-mismatch sync.WaitGroup A literal Add(n) count does not match a statically countable loop of worker goroutines.
GCL2008 add-zero sync.WaitGroup wg.Add(0) is a no-op and usually means the intended count was lost.
GCL2009 add-negative sync.WaitGroup wg.Add(n) is called with a negative literal, which panics at runtime.
GCL2010 wait-without-add sync.WaitGroup A local WaitGroup is waited on without any Add() in the same lifecycle.
GCL2011 wait-deadlock sync.WaitGroup Wait() is reached while the same goroutine still owes a Done().
GCL2012 multiple-done-worker sync.WaitGroup The same worker branch can call Done() more than once.
GCL2013 nested-waitgroup-deadlock sync.WaitGroup A worker for one WaitGroup waits on another whose release is blocked behind the outer Wait().
GCL2014 done-outside-goroutine sync.WaitGroup Done() runs on the parent goroutine instead of the worker, so a panic in the parent skips it.
GCL2015 go-panic sync.WaitGroup A function passed to wg.Go() may panic and bring the program down.
GCL3001 once-do-deadlock sync.Once once.Do(f) where f calls Do on the same Once again — Once.Do is not reentrant, so this deadlocks.
GCL3002 once-do-nil sync.Once once.Do(nil) panics when the function is invoked.
GCL3003 once-constructor-nil sync.Once sync.OnceFunc/OnceValue/OnceValues is called with a nil function, which panics when the memoized function first runs.
GCL4001 cond-new-nil-locker sync.Cond sync.NewCond(nil) builds a Cond whose Locker is nil, so the first Wait panics at runtime.
GCL5001 pool-non-pointer-value sync.Pool A non-pointer value is placed in a sync.Pool (a Put argument or a New return), so every call boxes it into an interface and heap-allocates — defeating the pool.
GCL6001 close-of-nil-channel channel close() is called on a channel that is nil on every path reaching the call, which panics at runtime.
GCL6002 close-of-closed-channel channel close() is called on a channel that is already closed on every path reaching the call, which panics at runtime.
GCL6003 send-on-closed-channel channel A value is sent on a channel that is already closed on every path reaching the send, which panics at runtime.
GCL6004 nil-channel-op channel A send or receive is performed on a channel that is nil on every path reaching the operation, which blocks the goroutine forever.
GCL9001 sync-primitive-copy sync.Mutex, sync.RWMutex, sync.WaitGroup, sync.Once, sync.Cond, sync.Pool, sync.Map A sync primitive (or a struct embedding one) is copied by value.

All checks above also fire on package-scoped primitives declared in any file of the same package — there is no separate code for that case; the diagnostic carries the same category as the in-function variant.

Selecting checks

Every check runs by default. -checks narrows that down. It is declared on the analyzer itself, so it behaves identically in the CLI and in any go/analysis driver that consumes it.

goconcurrencylint -checks "all,-GCL5001" ./...
goconcurrencylint -checks "all,-GCL5*" ./...
goconcurrencylint -checks "GCL1*,-GCL1005" ./...

The list is processed left to right starting from an empty set: a plain entry adds the checks it matches, an entry prefixed with - removes them, and all matches the whole catalogue. Later entries override earlier ones, so GCL1*,-GCL1005 is "the mutex family except that one" and all,-GCL2*,GCL2001 puts a single check back after excluding its family. This is the same syntax staticcheck uses for its own -checks flag.

  • Entries may be canonical codes (GCL1001), legacy slugs (lock-without-unlock), or a code prefix ending in * (GCL1*). Separate several with commas, spaces or semicolons, and quote the value so the shell does not expand the star.
  • An unknown entry is a flag error, not a silent no-op — a typo in a CI config fails loudly instead of leaving a check enabled that you believe is off. So is a list that resolves to no checks at all (-checks "", -checks "all,-all"): an unset variable in a pipeline should not quietly turn the linter off.

To leave test files out, use the flag the go/analysis driver already provides — it skips loading them entirely, so the run also gets faster:

goconcurrencylint -test=false ./...

Under golangci-lint the equivalent is run.tests: false.

Use these for repo-wide policy and the inline directive below for one-off exceptions.

Suppressing diagnostics

Place // goconcurrencylint:ignore on the same line as the offending call. Each id may be a canonical code (GCL1001) or the legacy slug (lock-without-unlock); the two forms are interchangeable and can be mixed:

wg.Wait() // goconcurrencylint:ignore GCL2010
mu.Lock() // goconcurrencylint:ignore GCL1001, defer-lock
mu.Lock() // goconcurrencylint:ignore  legacy code, see issue #42
  • A list of one or more check ids (separated by spaces, commas or semicolons) silences only those checks on the line.
  • A bare // goconcurrencylint:ignore, or a directive followed only by free text, silences every check on the line.
  • Tokens after the first one that does not match a known check are treated as a human-readable note, so // goconcurrencylint:ignore GCL1001 because foo only silences GCL1001.

Examples

Correct usage

import "sync"

func GoodMutex() {
    var mu sync.Mutex
    mu.Lock()
    defer mu.Unlock()
    // critical section
}

func GoodWaitGroupGo() {
    var wg sync.WaitGroup
    wg.Go(func() {
        // work
    })
    wg.Wait()
}

Incorrect usage

import "sync"

// Lock without a matching Unlock.
func BadLockWithoutUnlock() {
    var mu sync.Mutex
    mu.Lock() // want "mutex 'mu' is locked but not unlocked"
}

// Defer scheduled before the lock; an early return can run it while the
// mutex is still unlocked. (An adjacent `defer mu.Unlock(); mu.Lock()` is safe.)
func BadDeferUnlockBeforeLock(cond bool) {
    var mu sync.Mutex
    defer mu.Unlock() // want "mutex 'mu' has defer unlock but no corresponding lock"
    if cond {
        return
    }
    mu.Lock()
}

// Add without a matching Done — wg.Wait() will block forever.
func BadAddWithoutDone() {
    var wg sync.WaitGroup
    wg.Add(1) // want "waitgroup 'wg' has Add without corresponding Done"
    wg.Wait()
}

// Reusing a WaitGroup after Wait returned empty.
func BadWaitGroupGoAfterWait() {
    var wg sync.WaitGroup
    wg.Wait()
    wg.Go(func() {}) // want "waitgroup 'wg' Go called after Wait"
}

// Extra Done — panics at runtime.
func BadExtraDone() {
    var wg sync.WaitGroup
    wg.Add(1)
    wg.Done()
    wg.Done() // want "waitgroup 'wg' has Done without corresponding Add"
    wg.Wait()
}

More representative cases live under pkg/analyzer/testdata/src.

How It Works

goconcurrencylint is an umbrella go/analysis analyzer composed of four independent sub-analyzers, wired together through the standard Requires graph:

  • Mutex analyzer — tracks lock, rlock, borrowed lock, and defer unlock counters per function, visiting each control-flow node and reconciling state at join points. Final state is validated at function exit.
  • WaitGroup analyzer — collects every Add, Done, Wait, and Go call with its position, builds a reachability map for calls inside goroutines, and validates the balance along every path. Calls that escape the function scope are intentionally excluded to minimize false positives.
  • Once analyzer — resolves the function passed to once.Do (literal, named function, or method value) and reports re-entrant Do calls that deadlock, plus Do(nil) calls that panic.
  • Copy analyzer — flags any sync.Mutex, sync.RWMutex, sync.WaitGroup or sync.Once (or a struct embedding one) copied by value.

Two foundation analyzers run once per package and share their results with the sub-analyzers: one discovers sync primitive declarations, the other identifies generated files and builds the comment filters behind // goconcurrencylint:ignore. All checks also share helpers for type detection (IsMutex, IsRWMutex, IsWaitGroup, IsOnce) and deterministic, deduplicated error reporting.

For a contributor-level map of the analyzer graph and the journey of a single diagnostic, see ARCHITECTURE.md.

Project Layout

goconcurrencylint/
├── cmd/goconcurrencylint/       # CLI entry point (singlechecker)
├── pkg/analyzer/
│   ├── analyzer.go              # Umbrella analyzer (re-emits sub-analyzer diagnostics)
│   ├── internal/
│   │   ├── driver/              # Shared per-function run skeleton
│   │   ├── primitives/          # Discovers sync primitive names
│   │   ├── filesetup/           # Generated-file detection + comment filters
│   │   ├── mutex/               # Mutex / RWMutex analyzer
│   │   ├── waitgroup/           # WaitGroup analyzer
│   │   ├── copycheck/           # Copy-by-value analyzer
│   │   └── common/              # Shared helpers, check catalogue, reporting
│   └── testdata/src/            # analysistest fixtures
├── pkg/golangci/                # golangci-lint module plugin
├── docs/checks/                 # Per-check reference pages + index (generated)
├── scripts/gendocs/             # Check-docs generator (go generate ./...)
├── assets/                      # Logo and branding
├── ARCHITECTURE.md              # Internal design & data flow
└── .github/workflows/           # CI, release asset build, and integration pipelines

Contributing

Contributions are welcome. The most useful ones in this phase of the project are:

  1. Reduced false-positive / false-negative cases — extra testdata fixtures are the fastest way to harden the analyzer.
  2. Comparisons against overlapping analyzers — if another linter already covers part of this ground, we want to know.
  3. New checks — proposals for additional concurrency primitives are encouraged; open an issue first to discuss scope.

To get started:

git clone https://github.com/sanbricio/goconcurrencylint.git
cd goconcurrencylint
go test -race ./...

Tests use analysistest with // want "…" markers on fixture files under pkg/analyzer/testdata/src.

License

goconcurrencylint is released under the MIT License.


Built by Santiago Bricio · sanbriciorojas11@gmail.com

About

Static analyzer for Go that catches unsafe concurrent programming patterns in sync primitives

Resources

Stars

7 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages