Go: The Confinement Pattern - Stop Using Mutex for Everything

Today I learned about the confinement pattern in Go: instead of protecting shared state with a sync.Mutex, you give ownership of the state to a single goroutine and communicate through channels.

The problem with mutexes everywhere

Using mutexes for shared state works, but comes with downsides:

  • Lock contention reduces parallelism.
  • Forgetting Unlock() leads to deadlocks.
  • Subtle race bugs appear under concurrency.
  • Reasoning about state ownership becomes harder as locks spread.

What is confinement?

Confinement means a piece of state is only ever accessible from one goroutine. No other goroutine can touch it directly, so no synchronization is needed. This follows Go’s philosophy: “Do not communicate by sharing memory; share memory by communicating.”

Example: Mutex vs Confinement

Before - shared state protected by a mutex:

counter := 0
var mu sync.Mutex

for i := 0; i < 3; i++ {
    go func() {
        mu.Lock()
        counter++
        mu.Unlock()
    }()
}

After - state confined to an owner goroutine:

increments := make(chan int)
read := make(chan chan int)

go func() {
    counter := 0 // confined state
    for {
        select {
        case v := <-increments:
            counter += v
        case response := <-read:
            response <- counter
        }
    }
}()

increments <- 1
increments <- 1
increments <- 1

response := make(chan int)
read <- response
fmt.Println("Final counter:", <-response)

Why it works

  • counter lives inside the goroutine — no other code can access it.
  • All interaction happens through channels.
  • No mutex, no race conditions, no shared mutable state.

Summary

  • Most concurrency bugs come from shared state, not from Go itself.
  • The confinement pattern eliminates locks by giving each piece of state a single owner.
  • The safest shared state is the state that isn’t shared at all.

Reference:

Standing
status: online
> location: Brazil