| name | go-sync-primitives |
| description | Guides Go's shared-memory synchronization toolkit — a zero-value sync.Mutex/RWMutex is ready with no constructor and must NEVER be copied after first use (passing a struct-with-Mutex by value copies the lock; go vet's copylocks catches it), keep critical sections small with defer Unlock right after Lock, don't embed a Mutex in an exported struct, reach for RWMutex only when reads vastly dominate, use sync.Once/OnceFunc/OnceValue (1.21) for one-time init, prefer typed sync/atomic (atomic.Int64/Bool/Pointer — 1.19) over the old free functions and over a Mutex for a single word, use sync.Map ONLY for its two documented cases, and treat sync.Pool items as transient. Auto-invokes when writing or editing sync.Mutex/RWMutex, sync.Once, sync/atomic, sync.Map, sync.Pool, or a WaitGroup, and on "is this lock copied" / "mutex or channel here" / "why did copylocks fire" requests. The lock-based half of "share memory by communicating." |
| allowed-tools | Read, Glob, Grep |
| compatibility | Claude Code, Codex CLI, Gemini CLI |
Go Sync Primitives
"Do not communicate by sharing memory; instead, share memory by communicating."
— Effective Go
"Channels orchestrate; mutexes serialize."
— Go Proverbs
"A Mutex must not be copied after first use."
— pkg.go.dev/sync
The sync and sync/atomic packages protect memory that more than one goroutine touches. Each primitive has exact, documented rules, and most sync bugs are one of those rules broken: a lock copied, a critical section that returns without unlocking, an atomic used where a Mutex was needed, a sync.Map reached for by reflex. This skill owns the lock-based, shared-state primitives. Goroutine lifetime (who starts and stops the goroutine) is owned by go-concurrency-goroutines; the mutex-vs-channel choice is shared with go-channels-select; why unsynchronized access is a bug at all is owned by go-race-and-memory-model.
1. The Rules, and Where Each Is Stated
Every rule is a documented fact of the sync/sync/atomic packages or established guidance, not a preference.
| Rule | The mechanic | Source |
|---|
| Zero value is ready | var mu sync.Mutex works; no constructor | "The zero value for a Mutex is an unlocked mutex" (sync); "you almost never need a pointer to a mutex" (Uber) |
| Never copy a lock | Pass the struct by pointer; use a pointer receiver | "A Mutex must not be copied after first use" (sync); "synchronization objects such as sync.Mutex must not be copied" (Google) |
copylocks finds the copy | go vet flags a by-value lock | "copylocks: check for locks erroneously passed by value" (cmd/vet) |
| Don't embed a Mutex | An embedded lock exports Lock/Unlock | "Do not embed the mutex on the struct, even if the struct is not exported" (Uber) |
Once runs exactly once | Do(f) invokes f only on the first call | "Do calls the function f if and only if Do is being called for the first time for this instance of Once" (sync) |
| Prefer typed atomics | atomic.Int64 over atomic.AddInt64 | "Consider using the more ergonomic and less error-prone Int64.Add instead" (sync/atomic) |
sync.Map is special-purpose | Only two documented use cases | "The Map type is optimized for two common use cases" (sync) |
sync.Pool items are transient | May vanish on any GC; always reset | "Any item stored in the Pool may be removed automatically at any time without notification" (sync) |
2. The Zero Value Is Ready; Never Copy the Lock
A sync.Mutex needs no initialization: "The zero value for a Mutex is an unlocked mutex" (sync), and the same holds for RWMutex, Once, and WaitGroup. So var mu sync.Mutex or an unexported struct field is the whole setup — "you almost never need a pointer to a mutex" (Uber). A New-constructor that only zeroes a lock is the needless-constructor anti-pattern from go-idiomatic-discipline.
The one hard rule that follows: "A Mutex must not be copied after first use" (sync). A Mutex is a small struct holding lock state; copying it duplicates that state, so two goroutines "lock" two different copies and the mutual exclusion silently evaporates. The copy is almost never written as m2 := m1 — it hides inside a value receiver or a by-value parameter/return: "In general, do not copy a value of type T if its methods are associated with the pointer type, *T. ... This guidance also applies to copying sync.Mutex" (Google). The fix is a pointer receiver: "If the receiver is a struct containing fields that cannot safely be copied, use a pointer receiver. Common examples are sync.Mutex and other synchronization types" (Google).
type Counter struct {
mu sync.Mutex
n int
}
func (c Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
You do not have to spot this by eye. go vet ships the copylocks analyzer — "check for locks erroneously passed by value" (cmd/vet) — which reports both the by-value parameter and the by-value return. Wiring go vet (and -race) into CI is owned by go-tooling-and-static-analysis. WaitGroup carries the identical rule — "A WaitGroup must not be copied after first use" (sync) — so a struct holding one is likewise pointer-only; its lifetime mechanics live in go-concurrency-goroutines.
3. Keep the Critical Section Small; defer Unlock Right After Lock
Hold the lock for as few statements as the invariant needs — every other goroutine wanting that lock is blocked for the whole critical section. The robust pattern is defer c.mu.Unlock() on the line immediately after c.mu.Lock(), so an early return or a panic mid-section still releases the lock (Google uses exactly this shape). The trap is a return between Lock and a bare Unlock: the function exits still holding the lock, and the next Lock deadlocks the whole program.
func (c *Cache) Get(k string) (V, error) {
c.mu.Lock()
v, ok := c.m[k]
if !ok {
return V{}, errNotFound
}
c.mu.Unlock()
return v, nil
}
func (c *Cache) Get(k string) (V, error) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.m[k]
if !ok {
return V{}, errNotFound
}
return v, nil
}
If real work (an RPC, file I/O) sits inside the section, copy out what you need under the lock and do the slow part after Unlock. Note the boundary rule: a method that returns a slice or map it guards hands the caller a reference to still-shared memory — "snapshot is no longer protected by the mutex, so any access to the snapshot is subject to data races" (Uber). Return a copy made under the lock.
4. Don't Embed a Mutex in a Struct
Embedding a sync.Mutex (writing sync.Mutex with no field name) promotes its Lock and Unlock methods onto the outer type. On an exported type that makes them API: "The Mutex field, and the Lock and Unlock methods are unintentionally part of the exported API" (Uber) — now any caller can lock your lock and deadlock you. Use a named, unexported field instead; Uber makes it unconditional: "Do not embed the mutex on the struct, even if the struct is not exported" (Uber).
type SMap struct {
sync.Mutex
data map[string]string
}
type SMap struct {
mu sync.Mutex
data map[string]string
}
Place the mu field directly above the fields it protects, and a comment like // guarded by mu on those fields documents the contract.
5. RWMutex Only When Reads Vastly Dominate
A sync.RWMutex "lock can be held by an arbitrary number of readers or a single writer" (sync) — so many RLock/RUnlock readers run concurrently. That is a win only when reads vastly outnumber writes and the critical sections are non-trivial; otherwise an RWMutex is strictly more expensive than a plain Mutex (it tracks reader counts and has worse cache behavior under contention). Default to sync.Mutex; switch to RWMutex only with a measured read-heavy profile. It carries every rule above — "A RWMutex must not be copied after first use" (sync), zero value ready, pointer receiver, no embedding.
6. sync.Once for One-Time Init; the Once* Helpers (1.21)
sync.Once runs an action exactly once across all goroutines: "Do calls the function f if and only if Do is being called for the first time for this instance of Once. ... if once.Do(f) is called multiple times, only the first call will invoke f" (sync). It is the right tool for lazy, concurrency-safe initialization of a single shared value.
type lazy struct {
once sync.Once
cfg *Config
}
func (l *lazy) get() *Config {
l.once.Do(func() { l.cfg = loadConfig() })
return l.cfg
}
Since Go 1.21, prefer the helpers when they fit: OnceFunc "returns a function that invokes f only once," OnceValue returns the one-time value, and OnceValues returns a one-time pair (sync). They replace the hand-rolled Once+field pair above with get := sync.OnceValue(loadConfig). (Whether 1.21 helpers are available is gated by the module's go directive — go-version-feature-map.) A single Once is for one action; for per-key one-time init use a map guarded by a Mutex, not one Once per key.
7. Typed sync/atomic Over the Free Functions — and Over a Mutex for One Word
For a single counter or flag, a typed atomic is simpler and faster than a Mutex. Since Go 1.19 the package provides atomic.Int32/Int64/Uint32/Uint64/Bool/Pointer[T]/Value, and the docs steer you to them over the old free functions: "Consider using the more ergonomic and less error-prone Int64.Add instead" of atomic.AddInt64 (sync/atomic). The typed value carries its own no-copy rule — "Int64 must not be copied after first use" (sync/atomic) — and copylocks catches a copied atomic too.
type Stats struct{ n int64 }
func (s *Stats) Inc() { s.n++ }
atomic.AddInt64(&s.n, 1)
type Stats struct{ n atomic.Int64 }
func (s *Stats) Inc() { s.n.Add(1) }
func (s *Stats) Value() int64 { return s.n.Load() }
The boundary: an atomic protects exactly one word. The moment an invariant spans two fields (e.g. "len and cap move together"), a single atomic cannot keep them consistent — you need a Mutex. And atomics are a low-level tool: "Except for special, low-level applications, synchronization is better done with channels or the facilities of the sync package" (sync/atomic). Never mix atomic and non-atomic access to the same word — every access to it must go through the atomic methods.
8. sync.Map Only for Its Two Documented Cases
sync.Map is not "a map that's safe for concurrent use" to reach for by default. Its own docs say so: "The Map type is specialized. Most code should use a plain Go map instead, with separate locking or coordination, for better type safety and to make it easier to maintain other invariants along with the map content" (sync). It is optimized for exactly two cases: "(1) when the entry for a given key is only ever written once but read many times, as in caches that only grow, or (2) when multiple goroutines read, write, and overwrite entries for disjoint sets of keys" (sync). Outside those, a plain map guarded by a Mutex is faster, type-safe, and clearer.
var m sync.Map
m.Store("k", 1)
v, _ := m.Load("k")
type Cache struct {
mu sync.Mutex
m map[string]int
}
func (c *Cache) Get(k string) (int, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.m[k]
return v, ok
}
Choose sync.Map deliberately for an append-only cache or genuinely disjoint per-goroutine key sets — not as the reflexive answer to "this map is shared."
9. sync.Pool: Transient, Resettable, Never Assumed to Survive
sync.Pool recycles short-lived allocations to relieve GC pressure: "Pool's purpose is to cache allocated but unused items for later reuse, relieving pressure on the garbage collector" (sync) — the canonical use is reusable bytes.Buffers or []byte scratch space in a hot path. Two rules make it safe:
- Never assume an item survives. The pool can be drained by the runtime at any moment: "Any item stored in the Pool may be removed automatically at any time without notification" (sync). So a Pool is a cache, never storage with cleanup semantics — don't put anything in it that needs a
Close() or holds a resource that must be released.
- Always reset on the way out. A
Get returns whatever a previous user left in the item, so reset it before reuse or you leak stale data into the next caller.
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}
func process(data []byte) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
buf.Write(data)
return buf.String()
}
sync.Pool as an allocation-reduction technique is shared with go-performance; this skill owns its synchronization contract.
10. Mutex vs Channel: Protect State vs Hand Off Ownership
"Channels orchestrate; mutexes serialize" (Go Proverbs) is the decision rule. The famous "share memory by communicating" (Effective Go) is a default, not an absolute: a Mutex protects memory that stays shared; a channel passes ownership of a value from one goroutine to another so it is never shared in the first place. Pick by which model the problem actually is:
- Guarding a field, counter, cache, or map that several goroutines read and write →
Mutex (or a typed atomic for one word). Wrapping that in a goroutine-plus-channel "server" imports a concurrency primitive's full failure surface — deadlocks, leaks, panics — to do a lock's job.
- Handing a value off, fanning work out, or signaling an event → channel. The channel-mechanics depth (directionality, who-closes,
select) is owned by go-channels-select.
When the answer is "protect this state," reach here; when it is "hand off this value," reach for go-channels-select.
11. Who Suffers When Sync Is Done Badly
The victim is never the author at write time, and these bugs are the worst kind — intermittent and invisible until load:
- The on-call engineer paged at 3am by a wedged service: a
return between Lock and Unlock (§3) left the lock held, and the next request deadlocked every goroutine waiting on it — no panic, no log, just a frozen process.
- The teammate chasing a counter that is "usually" right: a value receiver copied the
Mutex (§2), so the mutual exclusion never existed and a -race run finally prints the data race that production had been silently miscounting for weeks.
- The next caller of a
sync.Pool (§9) handed a buffer full of the previous request's bytes because nobody called Reset — a data-leak bug that only appears when two requests reuse the same pooled item.
go vet's copylocks and go test -race exist precisely because the compiler accepts all of this. Run them; the skill that wires them into CI is go-tooling-and-static-analysis, and why an unsynchronized access is a bug even when it "works" is go-race-and-memory-model.
12. Routing to Related Skills
go-idiomatic-discipline — the policy root; this skill is the shared-memory-synchronization depth behind "don't fight Go" and "make the zero value useful."
go-concurrency-goroutines — what you are synchronizing: goroutine lifetime, who starts/stops, and WaitGroup lifetime mechanics (this skill owns only its no-copy rule).
go-channels-select — the mutex-vs-channel choice in §10 from the channel side; channel mechanics.
go-race-and-memory-model — why unsynchronized shared access is a data race, happens-before, and go test -race.
go-tooling-and-static-analysis — wiring go vet (copylocks) and -race into CI.
go-performance — sync.Pool as an allocation-reduction technique.
go-version-feature-map — whether OnceFunc/OnceValue (1.21) and typed atomics (1.19) are available under the module's go directive.
13. Reference Files
High-frequency sync anti-patterns in LLM-generated Go, each with wrong/right code and citations:
${CLAUDE_SKILL_DIR}/references/common-mistakes.md
Source provenance for every claim in this skill:
${CLAUDE_SKILL_DIR}/references/sources.yaml