Go by Example

Stateful Goroutines

Confine mutable state to a single goroutine and expose it through channels to avoid data races by design.

Instead of sharing state protected by a mutex, you can confine state to a single goroutine and expose read and write access through channels. This pattern eliminates data races by construction: only one goroutine ever touches the state.

A state goroutine owns a map. Callers send command structs over channels and receive results via per-request reply channels. The state goroutine serializes all access in its select loop.

package main
 
import "fmt"
 
type readOp struct {
    key  int
    resp chan int
}
 
type writeOp struct {
    key  int
    val  int
    resp chan bool
}
 
func main() {
    reads := make(chan readOp)
    writes := make(chan writeOp)
 
    // state goroutine: sole owner of the map
    go func() {
        state := make(map[int]int)
        for {
            select {
            case op := <-reads:
                op.resp <- state[op.key]
            case op := <-writes:
                state[op.key] = op.val
                op.resp <- true
            }
        }
    }()
 
    // write
    w := writeOp{key: 1, val: 42, resp: make(chan bool)}
    writes <- w
    <-w.resp
 
    // read
    r := readOp{key: 1, resp: make(chan int)}
    reads <- r
    fmt.Println("value:", <-r.resp) // 42
}

Compare the two approaches:

ApproachBest for
MutexSimple read-modify-write; high-throughput counters
Stateful goroutineComplex state transitions; protocol-like sequencing

The stateful goroutine is heavier than a mutex for a plain counter but shines when state transitions follow a protocol - for example, a connection that must be in idle before it can move to in-flight:

type connState int
 
const (
    idle connState = iota
    inflight
    closing
)
 
// transition is enforced inside the state goroutine's select,
// so callers can never put the connection into an invalid state.

In production

The stateful goroutine pattern (one goroutine owns mutable state, all access goes through a channel) avoids data races by design. It trades throughput for correctness guarantees. Use it when the state transitions are complex or when you want to model a protocol; use a mutex when the critical section is a simple read-modify-write.

Enjoyed this? Get more essays on software craft delivered to your inbox.

Subscribe free