How Immutable Objects Eliminate Race Conditions Without Locks

How Immutable Objects Eliminate Race Conditions Without Locks

When a piece of state changes after creation and two goroutines touch it at the same time, you get lost writes — one goroutine reads an old value, another writes a new value on top of it, and the first goroutine’s write silently overwrites the second. You also get torn reads — one goroutine sees an intermediate state mid-update. The usual fix is locking every access, but there’s a structural way out: design objects so their state can’t change once they’re created.

An immutable object eliminates these problems without any synchronization primitives because no two goroutines can ever contend on the same mutable memory. Each one works on its own copy, and copies are cheap for plain-old-data structs in Go — they’re passed by value and copied automatically.

The code

The program below runs both patterns side-by-side with 10 concurrent goroutines each, all incrementing a counter 10,000 times:

  • Mutable section: ten goroutines share one pointer to a MutableAccount, each calling a.Balance++ 10,000 times. This read-modify-write sequence is not atomic — between reading the old value and writing the new one, another goroutine can interleave.
  • Immutable section: ten goroutines each capture a snapshot of a Snapshot struct, increment their own copy 10,000 times via the credit() method that returns a new struct each time, and send the result through a channel. No shared state is ever modified.
package main

import (
	"fmt"
	"sync"
)

type MutableAccount struct {
	Balance int
}

func mutateAccount(a *MutableAccount) {
	for i := 0; i < 10_000; i++ {
		a.Balance++ // NOT atomic — race window on read-modify-write!
	}
}

type Snapshot struct {
	Balance int
}

func (s Snapshot) credit() Snapshot {
	return Snapshot{Balance: s.Balance + 1} // returns NEW copy
}

func main() {
	fmt.Println("=== Mutable Account (concurrent mutations) ===")
	a := &MutableAccount{Balance: 0}

	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mutateAccount(a)
		}()
	}
	wg.Wait()
	fmt.Printf("Expected: 100000 | Got: %d (lost updates!)\n", a.Balance)

	fmt.Println()

	fmt.Println("=== Immutable Snapshots (concurrent, no shared state) ===")
	base := Snapshot{Balance: 0}

	resultsCh := make(chan int, 10)
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			snap := base // capture immutable state
			for j := 0; j < 10_000; j++ {
				snap = snap.credit() // creates a NEW Snapshot each time
			}
			resultsCh <- snap.Balance
		}()
	}
	wg.Wait()
	close(resultsCh)

	sum := 0
	for v := range resultsCh {
		fmt.Printf("Goroutine result: %d\n", v)
		sum += v
	}
	fmt.Printf("Base unchanged: %d | Total across goroutines: %d\n", base.Balance, sum)
}

Running it

The mutable account produces a wrong result — and every run gives a different wrong value:

First run

Each run produces a different incorrect value (e.g., ~32,000–~46,000 instead of 100,000). This is the atomicity hazard: between one goroutine reading Balance and writing back its incremented value, another goroutine reads the same stale value. Both write back old + 1, and one of the increments disappears.

Now run the immutable version. Every single goroutine produces exactly 10,000, the base stays at 0, and the total across all goroutines is exactly 100,000 — correct by construction:

  • Each goroutine captured base into its own local variable (snap := base). The struct copy is a value, not a reference — the goroutine’s snap points to completely independent memory.
  • credit() doesn’t mutate anything; it constructs and returns a new Snapshot. There is zero shared mutable state for any goroutine to contend on.
  • The original base struct is never modified — its value is effectively read-only after creation.

No sync.Mutex, no channels for coordination, no atomic.AddInt32. The concurrency correctness comes from the type design alone.

Takeaway

Objects whose state cannot change after construction don’t need synchronization because they can’t be contended on — every consumer works on its own copy and no two threads can ever read a stale value or lose an update. You trade the ability to mutate in place for the freedom to share freely.