4 min read
On this page

Sync & Common Mistakes

Channels are Go's preferred concurrency primitive, but sometimes shared state is the simpler solution. The sync package provides mutexes, wait groups, and one-time initialization for cases where channels would be overkill. This chapter covers those tools and the mistakes that catch every Go developer eventually.

sync.Mutex

A mutex (mutual exclusion lock) protects shared data from concurrent access. Only one goroutine can hold the lock at a time.

type SafeCounter struct {
    mu    sync.Mutex
    count map[string]int
}

func NewSafeCounter() *SafeCounter {
    return &SafeCounter{
        count: make(map[string]int),
    }
}

func (c *SafeCounter) Increment(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count[key]++
}

func (c *SafeCounter) Get(key string) int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count[key]
}

The pattern is always the same: lock, defer unlock, do work. Using defer ensures the mutex is unlocked even if the code panics.

Keep the Critical Section Small

Lock only what needs to be locked. Do not hold a mutex while doing I/O, network calls, or anything slow.

// Release the lock before slow operations like network calls
func (c *Cache) GetOrFetch(key string) (string, error) {
    c.mu.Lock()
    if val, ok := c.data[key]; ok {
        c.mu.Unlock()
        return val, nil
    }
    c.mu.Unlock()

    val, err := fetchFromAPI(key)
    if err != nil {
        return "", err
    }

    c.mu.Lock()
    c.data[key] = val
    c.mu.Unlock()
    return val, nil
}

sync.RWMutex

An RWMutex allows multiple concurrent readers or one exclusive writer. Use it when reads vastly outnumber writes.

type Config struct {
    mu       sync.RWMutex
    settings map[string]string
}

func (c *Config) Get(key string) string {
    c.mu.RLock()         // Multiple goroutines can read simultaneously
    defer c.mu.RUnlock()
    return c.settings[key]
}

func (c *Config) Set(key, value string) {
    c.mu.Lock()          // Exclusive access for writes
    defer c.mu.Unlock()
    c.settings[key] = value
}

RLock allows concurrent reads. Lock blocks until all readers release their locks, then holds exclusive access. Use RWMutex when you have many readers and infrequent writers. If reads and writes are roughly equal, a plain Mutex has less overhead.

sync.WaitGroup

A WaitGroup waits for a collection of goroutines to finish. It is the simplest way to "join" goroutines.

func processAll(items []Item) {
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Add(1)
        go func(it Item) {
            defer wg.Done()
            process(it)
        }(item)
    }

    wg.Wait()  // Blocks until all goroutines call Done
    fmt.Println("all items processed")
}

Three methods:

  • Add(n) -- increment the counter by n (call before launching the goroutine)
  • Done() -- decrement the counter by 1 (call when the goroutine finishes)
  • Wait() -- block until the counter reaches 0

WaitGroup Rules

Always call Add before launching the goroutine, not inside it. Otherwise there is a race between Wait and Add.

// Wrong: race condition
for _, item := range items {
    go func(it Item) {
        wg.Add(1)  // May execute after wg.Wait()
        defer wg.Done()
        process(it)
    }(item)
}
wg.Wait()

// Right: Add before go
for _, item := range items {
    wg.Add(1)
    go func(it Item) {
        defer wg.Done()
        process(it)
    }(item)
}
wg.Wait()

sync.Once

sync.Once guarantees that a function is executed exactly once, no matter how many goroutines call it.

type DBPool struct {
    once sync.Once
    pool *pgxpool.Pool
}

func (d *DBPool) Get(ctx context.Context) (*pgxpool.Pool, error) {
    var err error
    d.once.Do(func() {
        d.pool, err = pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
    })
    if err != nil {
        return nil, fmt.Errorf("initializing database pool: %w", err)
    }
    return d.pool, nil
}

The first goroutine to call Get initializes the pool. All subsequent calls return the already-initialized pool without running the initialization function again. Other goroutines that arrive while initialization is in progress block until it completes.

A common use is for lazy singleton initialization:

var (
    instance *Service
    once     sync.Once
)

func GetService() *Service {
    once.Do(func() {
        instance = &Service{
            client: http.DefaultClient,
            cache:  make(map[string]string),
        }
    })
    return instance
}

The Race Detector

Go's race detector is one of its most valuable tools. It finds data races at runtime by instrumenting memory accesses.

$ go test -race ./...
$ go run -race main.go
$ go build -race -o myservice

When a race is detected, you get a detailed report:

WARNING: DATA RACE
Read at 0x00c0000a4010 by goroutine 7:
  main.main.func2()
      /home/user/main.go:18 +0x38

Previous write at 0x00c0000a4010 by goroutine 6:
  main.main.func1()
      /home/user/main.go:13 +0x50

Goroutine 7 (running) created at:
  main.main()
      /home/user/main.go:17 +0xf4

Goroutine 6 (finished) created at:
  main.main()
      /home/user/main.go:12 +0xc8

This tells you exactly which goroutines are involved, which lines of code contain the conflicting accesses, and where the goroutines were created.

Run your tests with -race in CI. Always. The overhead is about 5-10x slower and 5-10x more memory, which is fine for tests.

Common Mistake: Shared Variable in Loop

Before Go 1.22, the loop variable was reused across iterations. Capturing it in a closure gave every goroutine the same variable.

// Bug in Go < 1.22
func buggyLoop() {
    urls := []string{"a.com", "b.com", "c.com"}
    var wg sync.WaitGroup

    for _, url := range urls {
        wg.Add(1)
        go func() {
            defer wg.Done()
            fmt.Println(url)  // All goroutines print "c.com"
        }()
    }
    wg.Wait()
}
c.com
c.com
c.com

The fix (works in all Go versions): pass the variable as a function argument.

for _, url := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        fmt.Println(u)
    }(url)
}

Go 1.22 changed the semantics so each loop iteration gets its own variable. But passing as an argument is still good practice -- it works everywhere and makes the intent explicit.

Common Mistake: Copying a Mutex

A sync.Mutex must not be copied after first use. Passing a struct containing a mutex by value copies the mutex, and the copy is independent of the original -- defeating the purpose.

type Cache struct {
    mu   sync.Mutex
    data map[string]string
}

// Bug: c is a copy. The mutex in c is independent of the original.
func broken(c Cache) {
    c.mu.Lock()
    defer c.mu.Unlock()
    // This lock does not protect the original Cache
}

// Correct: use a pointer
func correct(c *Cache) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data["key"] = "value"
}

go vet catches this mistake:

$ go vet ./...
./main.go:15:14: broken passes lock by value: main.Cache contains sync.Mutex

Common Pitfalls

  • Using sync primitives when channels would be clearer. If you are coordinating independent tasks, channels and select are usually more readable than mutexes and condition variables.
  • Holding a mutex during I/O. This serializes all concurrent access behind the slowest I/O operation. Lock, read/write shared state, unlock. Do everything else outside the lock.
  • Recursive locking. Go's sync.Mutex is not reentrant. Calling Lock from a goroutine that already holds the lock deadlocks. Restructure your code so that locked helper functions do not re-lock.
  • Forgetting to call wg.Done. A missed Done call means Wait blocks forever. Always use defer wg.Done().
  • Not running the race detector in CI. Data races may not manifest in local testing. Run go test -race ./... in every CI build.

Key Takeaways

  • sync.Mutex protects shared state. Lock, defer unlock, do work. Keep critical sections small.
  • sync.RWMutex allows concurrent reads with RLock. Use it when reads vastly outnumber writes.
  • sync.WaitGroup waits for goroutines to finish. Call Add before launching, Done when finished, Wait to block.
  • sync.Once executes a function exactly once, safely across goroutines. Use it for lazy initialization.
  • Run go test -race in CI. The race detector catches data races that are invisible during normal testing.
  • Never copy a mutex. Pass structs containing mutexes by pointer.
  • The most common concurrency bugs: goroutine leaks, closing channels from the wrong side, shared loop variables (pre-1.22), forgetting to wait, and holding locks during slow operations.