Skip to content

sanbricio/goconcurrencylint

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.
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.
package-level-primitive all Any of the above, applied to package-scoped primitives declared in a different file of the same package.

All checks are enabled by default and emitted as standard go/analysis diagnostics.

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

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages