Why your mutex wrapper should accept a closure for mutation instead of a plain value, with examples from the standard library and Tailscale.
When multiple goroutines need to read and write the same value, you need a mutex to make sure
they don’t step on each other. Without one, concurrent writes can corrupt the state - two
goroutines might read the same value, both modify it, and one silently overwrites the other’s
change. The usual approach is to put a sync.Mutex next to the fields it protects:
var (
mu sync.Mutex
counter int
)
mu.Lock()
counter++
mu.Unlock()
This works, but nothing enforces it. The compiler won’t stop you from accessing counter
without holding the lock. Forget to lock in one spot and you have a data race. One way to
make this safer is to bundle the value and its mutex into a small generic wrapper that only
exposes locked access through methods:
type Locked[T any] struct {
mu sync.Mutex
v T
}
func NewLocked[T any](initial T) *Locked[T] {
return &Locked[T]{v: initial}
}
func (l *Locked[T]) Get() T {
l.mu.Lock()
defer l.mu.Unlock()
return l.v
}
func (l *Locked[T]) Set(v T) {
l.mu.Lock()
defer l.mu.Unlock()
l.v = v
}
You keep mu and v unexported, pass around *Locked[T], and callers use Get to read
and Set to write:
counter := NewLocked(0)
counter.Set(42)
fmt.Println(counter.Get()) // 42
Now callers can’t touch the underlying value without going through the lock. This doesn’t prevent misuse within the same package, but it makes unprotected access from other packages impossible.
This works fine when you’re replacing the value wholesale - just call counter.Set(42) and
move on. But when your mutation depends on the current value, Get and Set can race
against each other.
The problem with Set
Say you want to increment the counter instead of replacing it. You’d have to do:
v := counter.Get()
v++
counter.Set(v)
Each individual call is safe - Get holds the lock while reading, Set holds it while
writing. But the three calls together aren’t atomic. Between Get and Set, another
goroutine can modify the value, and your increment overwrites theirs. That’s the classic
lost-update bug.
It gets worse with compound state. Say the wrapper holds a struct:
type State struct {
Count int
Name string
}
state := NewLocked(State{})
And you want to conditionally update both fields:
s := state.Get()
if s.Count < 10 {
s.Count++
s.Name = fmt.Sprintf("item-%d", s.Count)
}
state.Set(s)
Same problem. Get returns a copy, you mutate the copy, then Set writes it back. If
another goroutine modified state between those two calls, your write clobbers it.
Important
The race detector (go test -race) won’t catch this. It detects data races - two
goroutines accessing the same memory without synchronization. Here, every Get and Set
properly acquires the mutex, so each individual access is synchronized. The bug is a
logical race (lost update), not a data race. The race detector sees nothing wrong.
You can prove this with a simple test. Ten goroutines each increment the counter 1000 times, so the final value should be 10000:
func TestSetValue(t *testing.T) {
counter := NewLocked(0)
var wg sync.WaitGroup
for range 10 {
wg.Go(func() {
for range 1000 {
v := counter.Get()
v++
counter.SetValue(v)
}
})
}
wg.Wait()
got := counter.Get()
if got != 10000 {
t.Errorf("got %d, want 10000 (lost %d updates)", got, 10000-got)
}
}
Running go test -race produces no race warnings, but the test fails:
=== RUN TestSetValue
locked_test.go:30: got 1855, want 10000 (lost 8145 updates)
--- FAIL: TestSetValue (0.02s)
The race detector is silent. The updates are just gone.
Take a function instead
Instead of taking a value, have Set take a function:
func (l *Locked[T]) Set(f func(*T)) {
l.mu.Lock()
defer l.mu.Unlock()
f(&l.v)
}
Now the counter increment becomes:
counter.Set(func(v *int) {
*v++
})
And the compound mutation:
state.Set(func(s *State) {
if s.Count < 10 {
s.Count++
s.Name = fmt.Sprintf("item-%d", s.Count)
}
})
The lock is held for the entire closure. There’s no gap between reading and writing, so no other goroutine can interfere. Both fields update together or not at all.
The function takes a pointer to T rather than a value of T for two reasons. First, it
lets you mutate the state in place instead of working on a copy. Second, if T is a large
struct, passing a pointer avoids copying the whole thing into the closure on every call.
The stdlib already does this
Go’s database/sql package has an internal withLock helper that follows the same pattern:
// withLock runs while holding lk.
func withLock(lk sync.Locker, fn func()) {
lk.Lock()
defer lk.Unlock() // in case fn panics
fn()
}
It’s used throughout database/sql to serialize access to the underlying driver connection.
For example, when pinging a connection:
if pinger, ok := dc.ci.(driver.Pinger); ok {
withLock(dc, func() {
err = pinger.Ping(ctx)
})
}
Or when preparing a statement:
withLock(dc, func() {
si, err = ctxDriverPrepare(ctx, dc.ci, query)
})
Or committing a transaction:
withLock(tx.dc, func() {
err = tx.txi.Commit()
})
There are about 18 call sites in sql.go alone. In those snippets, dc is a
*driverConn - the struct that wraps a database driver connection. It embeds sync.Mutex
directly, so it satisfies sync.Locker and can be passed straight to withLock.
Note
withLock accepts sync.Locker instead of *sync.Mutex, so it also works with the read
side of an RWMutex:
withLock(rs.closemu.RLocker(), func() {
doClose, ok = rs.nextLocked()
})
Here rs.closemu is a sync.RWMutex, and .RLocker() returns a sync.Locker that
acquires the read lock. The same withLock function handles both cases.
The proposal to add this to sync
In 2021, twmb filed proposal #49563 to add a Mutex.Locked(func()) method to the standard
library:
func (m *Mutex) Locked(fn func()) {
m.Lock()
defer m.Unlock()
fn()
}
The idea was that if sync.Mutex had this method natively, you wouldn’t need to write a
wrapper at all for simple cases - you’d just call mu.Locked(fn) directly. It also
eliminates forgotten unlocks and guards against panics leaving the mutex locked. esote
pointed out that database/sql already had an internal version of this - the same
withLock helper we saw earlier.
zephyrtronium raised the sync.Locker point:
I think there are advantages to making this a function that takes a Locker rather than a method on Mutex. This would allow using it with either end of an RWMutex, or another custom Locker.
rsc declined it on philosophical grounds:
In general we try not to have two different ways to do something, and for better or worse we have the current idioms.
The more interesting pushback came from bcmills, who argued the proposal didn’t go far enough. With generics arriving, he wanted something that also prevents unguarded access to the protected data, not just forgotten unlocks:
Now that we have generics on the way, I would rather see us move in a direction that also eliminates unlocked-access bugs, not just incrementally update
Mutexfor forgotten-deferbugs.
He sketched out what that could look like:
type Synchronized[T any] struct {
mu Mutex
val T
}
func (s *Synchronized[T]) Do(fn func(*T)) {
s.mu.Lock()
defer s.mu.Unlock()
fn(&s.val)
}
This is essentially the Locked[T] wrapper from the beginning of this post. The proposal
was declined, but bcmills’ suggestion is the direction the community ended up going
anyway-just outside the standard library.
Tailscale’s MutexValue
Tailscale’s syncs package has a MutexValue[T] type that follows this direction:
type MutexValue[T any] struct {
mu sync.Mutex
v T
}
func (m *MutexValue[T]) WithLock(f func(p *T)) {
m.mu.Lock()
defer m.mu.Unlock()
f(&m.v)
}
func (m *MutexValue[T]) Load() T {
m.mu.Lock()
defer m.mu.Unlock()
return m.v
}
func (m *MutexValue[T]) Store(v T) {
m.mu.Lock()
defer m.mu.Unlock()
m.v = v
}
They provide both Store for simple replacements and WithLock for compound mutations.
When you need to read-modify-write, you go through WithLock so the lock covers the whole
operation.
When a plain Set is fine
If T is small and you only ever replace the whole value without reading it first, a plain
Set works. A boolean flag that gets toggled from one place, a config value that gets
swapped wholesale - those are fine.
But most state doesn’t stay that simple. You start with a single integer, it becomes a
struct with three fields, and now you need to update two of them based on the third. At that
point, Set(func(*T)) is the only safe option.
Important
The proposal benchmarks showed about 35% overhead for the closure-based approach (14.65
ns/op vs 10.82 ns/op for direct lock/unlock) due to closures and defer not being
inlineable. In practice this rarely matters. If your critical section does any real work,
the lock overhead dominates.