{
"$type": "site.standard.document",
"canonicalUrl": "https://rednafi.com/go/mutex-closure/",
"description": "Why your mutex wrapper should accept a closure for mutation instead of a plain value, with examples from the standard library and Tailscale.",
"path": "/go/mutex-closure/",
"publishedAt": "2026-03-05T00:00:00.000Z",
"site": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"tags": [
"Go",
"Concurrency",
"API"
],
"textContent": "When multiple goroutines need to read and write the same value, you need a mutex to make\nsure they don't step on each other. Without one, concurrent writes can corrupt the state -\ntwo goroutines might read the same value, both modify it, and one silently overwrites the\nother's change. The usual approach is to put a sync.Mutex next to the fields it protects:\n\nThis works, but nothing enforces it. The compiler won't stop you from accessing counter\nwithout holding the lock. Forget to lock in one spot and you have a data race. One way to\nmake this safer is to bundle the value and its mutex into a small generic wrapper that only\nexposes locked access through methods:\n\nYou keep mu and v unexported, pass around Locked[T], and callers use Get to read\nand Set to write:\n\nNow callers can't touch the underlying value without going through the lock. This doesn't\nprevent misuse within the same package, but it makes unprotected access from other packages\nimpossible.\n\nThis works fine when you're replacing the value wholesale - just call counter.Set(42) and\nmove on. But when your mutation depends on the current value, Get and Set can race\nagainst each other.\n\nThe problem with Set\n\nSay you want to increment the counter instead of replacing it. You'd have to do:\n\nEach individual call is safe - Get holds the lock while reading, Set holds it while\nwriting. But the three calls together aren't atomic. Between Get and Set, another\ngoroutine can modify the value, and your increment overwrites theirs. That's the classic\nlost-update bug.\n\nIt gets worse with compound state. Say the wrapper holds a struct:\n\nAnd you want to conditionally update both fields:\n\nSame problem. Get returns a copy, you mutate the copy, then Set writes it back. If\nanother goroutine modified state between those two calls, your write clobbers it.\n\n> [!IMPORTANT]\n>\n> The race detector (go test -race) won't catch this. It detects data races - two\n> goroutines accessing the same memory without synchronization. Here, every Get and Set\n> properly acquires the mutex, so each individual access is synchronized. The bug is a\n> logical race (lost update), not a data race. The race detector sees nothing wrong.\n>\n> You can prove this with a simple test. Ten goroutines each increment the counter 1000\n> times, so the final value should be 10000:\n>\n> \n>\n> Running go test -race produces no race warnings, but the test fails:\n>\n> \n>\n> The race detector is silent. The updates are just gone.\n\nTake a function instead\n\nInstead of taking a value, have Set take a function:\n\nNow the counter increment becomes:\n\nAnd the compound mutation:\n\nThe lock is held for the entire closure. There's no gap between reading and writing, so no\nother goroutine can interfere. Both fields update together or not at all.\n\nThe function takes a pointer to T rather than a value of T for two reasons. First, it\nlets you mutate the state in place instead of working on a copy. Second, if T is a large\nstruct, passing a pointer avoids copying the whole thing into the closure on every call.\n\nThe stdlib already does this\n\nGo's database/sql package has an internal [withLock] helper that follows the same pattern:\n\nIt's used throughout database/sql to serialize access to the underlying driver connection.\nFor example, when pinging a connection:\n\nOr when preparing a statement:\n\nOr committing a transaction:\n\nThere are about 18 call sites in sql.go alone. In those snippets, dc is a\ndriverConn - the struct that wraps a database driver connection. It embeds sync.Mutex\ndirectly, so it satisfies sync.Locker and can be passed straight to withLock.\n\n> [!NOTE]\n>\n> withLock accepts sync.Locker instead of sync.Mutex, so it also works with the read\n> side of an RWMutex:\n>\n> \n>\n> Here rs.closemu is a sync.RWMutex, and .RLocker() returns a sync.Locker that\n> acquires the read lock. The same withLock function handles both cases.\n\nThe proposal to add this to sync\n\nIn 2021, twmb filed [proposal #49563] to add a Mutex.Locked(func()) method to the standard\nlibrary:\n\nThe idea was that if sync.Mutex had this method natively, you wouldn't need to write a\nwrapper at all for simple cases - you'd just call mu.Locked(fn) directly. It also\neliminates forgotten unlocks and guards against panics leaving the mutex locked. esote\npointed out that database/sql already had an internal version of this - the same\nwithLock helper we saw earlier.\n\nzephyrtronium raised the sync.Locker point:\n\n> I think there are advantages to making this a function that takes a Locker rather than a\n> method on Mutex. This would allow using it with either end of an RWMutex, or another\n> custom Locker.\n>\n> -- [zephyrtronium on #49563]\n\nrsc declined it on philosophical grounds:\n\n> In general we try not to have two different ways to do something, and for better or worse\n> we have the current idioms.\n>\n> -- [rsc on #49563]\n\nThe more interesting pushback came from bcmills, who argued the proposal didn't go far\nenough. With generics arriving, he wanted something that also prevents unguarded access to\nthe protected data, not just forgotten unlocks:\n\n> Now that we have generics on the way, I would rather see us move in a direction that\n> _also_ eliminates unlocked-access bugs, not just incrementally update Mutex for\n> forgotten-defer bugs.\n>\n> -- [bcmills on #49563]\n\nHe sketched out what that could look like:\n\nThis is essentially the Locked[T] wrapper from the beginning of this post. The proposal\nwas declined, but bcmills' suggestion is the direction the community ended up going\nanyway-just outside the standard library.\n\nTailscale's MutexValue\n\nTailscale's [syncs] package has a MutexValue[T] type that follows this direction:\n\nThey provide both Store for simple replacements and WithLock for compound mutations.\nWhen you need to read-modify-write, you go through WithLock so the lock covers the whole\noperation.\n\nWhen a plain Set is fine\n\nIf T is small and you only ever replace the whole value without reading it first, a plain\nSet works. A boolean flag that gets toggled from one place, a config value that gets\nswapped wholesale - those are fine.\n\nBut most state doesn't stay that simple. You start with a single integer, it becomes a\nstruct with three fields, and now you need to update two of them based on the third. At that\npoint, Set(func(T)) is the only safe option.\n\n> [!IMPORTANT]\n>\n> The proposal benchmarks showed about 35% overhead for the closure-based approach (14.65\n> ns/op vs 10.82 ns/op for direct lock/unlock) due to closures and defer not being\n> inlineable. In practice this rarely matters. If your critical section does any real work,\n> the lock overhead dominates.\n\n\n\n\n[withLock]:\n https://cs.opensource.google/go/go/+/refs/tags/go1.24.0:src/database/sql/sql.go;l=3576\n\n[proposal #49563]:\n https://github.com/golang/go/issues/49563\n\n[zephyrtronium on #49563]:\n https://github.com/golang/go/issues/49563#issuecomment-968093753\n\n[rsc on #49563]:\n https://github.com/golang/go/issues/49563#issuecomment-983955169\n\n[bcmills on #49563]:\n https://github.com/golang/go/issues/49563#issuecomment-984092316\n\n[syncs]:\n https://github.com/tailscale/tailscale/blob/v1.94.2/syncs/syncs.go#L110",
"title": "Mutate your locked state inside a closure"
}