Following the Learn Go with Tests guide, exploring Go’s
syncpackage.
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
deferto 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()andUnlock()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.MutexSolution: 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