Mutate your locked state inside a closure
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:
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:
You keep mu and v unexported, pass around Locked[T], and callers use Get to read and Set to write:
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:
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:
And you want to conditionally update both fields:
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:
Running go test -race produces no race warnings, but the test fails:
The race detector is silent. The updates are just gone.
Take a function instead
Instead of taking a value, have Set take a function:
Now the counter increment becomes:
And the compound mutation:
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:
It's used throughout database/sql to serialize access to the underlying driver connection. For example, when pinging a connection:
Or when preparing a statement:
Or committing a transaction:
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:
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:
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 Mutex for forgotten-defer bugs.
He sketched out what that could look like:
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:
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.
Discussion in the ATmosphere