Skip to main content
Go: Sync Concurrent State
  1. Posts/

Go: Sync Concurrent State

Roman
Author
Roman
Photographer with MSci in Computer Science and a Home Lab obsession
Table of Contents

Following the Learn Go with Tests guide, exploring Go’s sync package.

Shared State and Race Conditions
#

When multiple goroutines access shared data:

  • Race conditions occur
  • Data corruption happens
  • Programs behave unpredictably
  • Results become non-deterministic

The sync package provides coordination primitives to solve these problems.

Counter Example
#

Let’s start with a problematic counter:

type Counter struct {
    value int
}

func (c *Counter) Inc() {
    c.value++
}

func (c *Counter) Value() int {
    return c.value
}

Testing the race condition:

func TestCounterIncrement(t *testing.T) {
    counter := Counter{}
    
    counter.Inc()
    counter.Inc()
    counter.Inc()
    
    if counter.Value() != 3 {
        t.Errorf("got %d, want 3", counter.Value())
    }
}

With concurrency:

func TestCounterConcurrency(t *testing.T) {
    wantedCount := 1000
    counter := Counter{}
    
    var wg sync.WaitGroup
    wg.Add(wantedCount)
    
    for i := 0; i < wantedCount; i++ {
        go func() {
            defer wg.Done()
            counter.Inc()
        }()
    }
    
    wg.Wait()
    
    // This will likely fail - result will be less than 1000
    if counter.Value() != wantedCount {
        t.Errorf("got %d, want %d", counter.Value(), wantedCount)
    }
}

This test spawns 1000 goroutines that each call counter.Inc(). The sync.WaitGroup coordinates execution: Add(1000) sets an internal counter to 1000, each goroutine calls Done() which decrements the counter by 1, and Wait() blocks until the counter reaches 0. This ensures all increments are attempted before checking the result.

Mutex
#

A Mutex provides mutual exclusion, only one goroutine can hold the lock at a time.

type SafeCounter struct {
    mu    sync.Mutex
    value int
}

func (c *SafeCounter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}
  • Always use defer to unlock
  • Protect both reads and writes
  • Keep critical sections small

Embedded Mutex
#

Avoid embedding sync.Mutex directly:

// Bad - exposes Lock/Unlock methods publicly
type Counter struct {
    sync.Mutex
    value int
}

func (c *Counter) Inc() {
    c.Lock()           // Cleaner syntax but wrong approach
    defer c.Unlock()
    c.value++
}
  • Exposes Lock() and Unlock() in the public API
  • External code can incorrectly manipulate the mutex
  • Violates encapsulation principles

Always use a private field instead:

// Good - keeps mutex private
type Counter struct {
    mu    sync.Mutex  // Private field
    value int
}

Copying Mutexes Anti-Pattern
#

Problem: Mutexes cannot be copied after first use.

A Mutex must not be copied after first use.

// Bad - passing by value copies the mutex
func assertCounter(t testing.TB, got Counter, want int) {
    // This copies the mutex and breaks synchronization
}

Running go vet will catch this mistake:

$ go vet
./sync_test.go:15:20: call of assertCounter copies lock value: example.com/learnwithtests/sync.Counter contains sync.Mutex
./sync_test.go:31:20: call of assertCounter copies lock value: example.com/learnwithtests/sync.Counter contains sync.Mutex

Solution: Always pass structs containing mutexes by pointer:

// Good - pass by pointer
func assertCounter(t testing.TB, got *Counter, want int) {
    if got.Value() != want {
        t.Errorf("got %d, want %d", got.Value(), want)
    }
}

// Use a constructor to encourage pointer usage
func NewCounter() *Counter {
    return &Counter{}
}

Channels vs Mutexes
#

Go Wiki: Use a sync.Mutex or a channel?

Use Channels When:
#

  • Passing ownership of data between goroutines
  • Coordinating work between multiple goroutines

Use Mutexes When:
#

  • Managing shared state that multiple goroutines need to access
  • Protecting critical sections of code
  • Simple read/write operations on shared data