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, and sync.WaitGroup — 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

Quick Start

Run the analyzer against your module:

goconcurrencylint ./...

Example diagnostics:

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

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

ID Primitive Description
lock-without-unlock sync.Mutex, sync.RWMutex A Lock() / RLock() call has no matching Unlock() / RUnlock() on some execution path.
unlock-without-lock sync.Mutex, sync.RWMutex An Unlock() / RUnlock() call is reached without a prior matching lock (including double-unlocks).
defer-unlock-without-lock sync.Mutex, sync.RWMutex defer mu.Unlock() / defer mu.RUnlock() is scheduled before the corresponding lock is acquired.
unchecked-trylock sync.Mutex, sync.RWMutex TryLock() / TryRLock() is called without checking the returned boolean.
defer-lock sync.Mutex, sync.RWMutex defer mu.Lock() / defer mu.RLock() is used where an unlock was almost certainly intended.
mutex-in-loop sync.Mutex, sync.RWMutex A mutex is declared inside a loop body, creating a fresh lock per iteration.
rwmutex-api-mismatch sync.RWMutex Unlock() is used for a read lock, or RUnlock() is used for a write lock.
goroutine-lock-deadlock sync.Mutex, sync.RWMutex A goroutine is started while a lock is held and tries to take the same lock before the parent can release it.
panic-before-unlock sync.Mutex, sync.RWMutex An index with a statically known out-of-range value and collection length can panic between Lock() and a non-deferred unlock.
add-without-done sync.WaitGroup wg.Add(n) has no matching number of Done() calls on all paths — the counter may never reach zero.
done-without-add sync.WaitGroup wg.Done() is called more times than wg.Add() allows, which panics at runtime.
add-after-wait sync.WaitGroup wg.Add() is called after wg.Wait() has returned with an empty counter — a classic reuse bug.
go-after-wait sync.WaitGroup wg.Go() is called after wg.Wait() returned empty — same family as add-after-wait, specific to Go 1.25's Go method.
add-inside-goroutine sync.WaitGroup wg.Add() is called from inside a worker goroutine, racing with Wait().
done-not-deferred sync.WaitGroup A worker calls Done() after an explicit panic or runtime.Goexit path instead of deferring it.
add-loop-count-mismatch sync.WaitGroup A literal Add(n) count does not match a statically countable loop of worker goroutines.
add-zero sync.WaitGroup wg.Add(0) is a no-op and usually means the intended count was lost.
wait-without-add sync.WaitGroup A local WaitGroup is waited on without any Add() in the same lifecycle.
multiple-done-worker sync.WaitGroup The same worker branch can call Done() more than once.
nested-waitgroup-deadlock sync.WaitGroup A worker for one WaitGroup waits on another whose release is blocked behind the outer Wait().
done-outside-goroutine sync.WaitGroup Done() runs on the parent goroutine instead of the worker, so a panic in the parent skips it.
wait-deadlock sync.WaitGroup Wait() is reached while the same goroutine still owes a Done().
add-negative sync.WaitGroup wg.Add(n) is called with a negative literal — panics at runtime.
go-panic sync.WaitGroup A function passed to wg.Go() may panic and bring the program down.
defer-unlock-in-loop sync.Mutex, sync.RWMutex defer mu.Unlock() lives inside a loop body, so the unlock only runs at function return.
double-lock sync.Mutex, sync.RWMutex A second Lock() is taken while the first is still held (or a write Lock() while a read lock is held).
cross-goroutine-unlock sync.Mutex, sync.RWMutex Unlock() / RUnlock() runs in a different goroutine than the matching lock.
lock-order-cycle sync.Mutex, sync.RWMutex Two functions acquire the same pair of mutexes in opposite orders — classic deadlock pattern.
sync-primitive-copy all A sync.Mutex, sync.RWMutex or sync.WaitGroup (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 ID for that case; the diagnostic carries the same category as the in-function variant.

Each diagnostic is emitted with a stable analysis.Diagnostic.Category equal to the ID in the table above, so golangci-lint and IDE integrations can filter or label by check.

Suppressing diagnostics

Place // goconcurrencylint:ignore on the same line as the offending call:

wg.Wait() // goconcurrencylint:ignore wait-without-add
mu.Lock() // goconcurrencylint:ignore lock-without-unlock, defer-lock
mu.Lock() // goconcurrencylint:ignore  legacy code, see issue #42
  • A list of one or more category 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 category are treated as a human-readable note, so // goconcurrencylint:ignore lock-without-unlock because foo only silences lock-without-unlock.

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 is acquired.
func BadDeferUnlockBeforeLock() {
    var mu sync.Mutex
    defer mu.Unlock() // want "mutex 'mu' has defer unlock but no corresponding lock"
    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 a thin orchestrator over two focused analyzers:

  • Mutex analyzer (pkg/analyzer/mutex) — 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 (pkg/analyzer/waitgroup) — 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.

Both analyzers share helpers for type detection (IsMutex, IsRWMutex, IsWaitGroup), comment-aware filtering, and consistent error reporting.

Project Layout

goconcurrencylint/
├── cmd/goconcurrencylint/   # CLI entry point (singlechecker)
├── pkg/analyzer/
│   ├── analyzer.go          # Top-level orchestrator
│   ├── mutex/               # Mutex / RWMutex analyzer
│   ├── waitgroup/           # WaitGroup analyzer
│   ├── common/              # Shared type detection and reporting
│   └── testdata/src/        # analysistest fixtures
├── assets/                  # Logo and branding
└── .github/workflows/       # CI and release 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

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages