# Go Sync Primitives

> 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."

- Skill: `ctoth/go-sync-primitives` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-sync-primitives`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-sync-primitives/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-sync-primitives

---


# Go Sync Primitives

> "Do not communicate by sharing memory; instead, share memory by communicating."
> — [Effective Go](https://go.dev/doc/effective_go)

> "Channels orchestrate; mutexes serialize."
> — [Go Proverbs](https://go-proverbs.github.io/)

> "A Mutex must not be copied after first use."
> — [pkg.go.dev/sync](https://pkg.go.dev/sync#Mutex)

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](https://pkg.go.dev/sync#Mutex)); "you almost never need a pointer to a mutex" ([Uber](https://github.com/uber-go/guide/blob/master/style.md#zero-value-mutexes-are-valid)) |
| Never copy a lock | Pass the struct by pointer; use a pointer receiver | "A Mutex must not be copied after first use" ([sync](https://pkg.go.dev/sync#Mutex)); "synchronization objects such as `sync.Mutex` must not be copied" ([Google](https://google.github.io/styleguide/go/decisions#copying)) |
| `copylocks` finds the copy | `go vet` flags a by-value lock | "copylocks: check for locks erroneously passed by value" ([cmd/vet](https://pkg.go.dev/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](https://github.com/uber-go/guide/blob/master/style.md#zero-value-mutexes-are-valid)) |
| `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](https://pkg.go.dev/sync#Once.Do)) |
| Prefer typed atomics | `atomic.Int64` over `atomic.AddInt64` | "Consider using the more ergonomic and less error-prone Int64.Add instead" ([sync/atomic](https://pkg.go.dev/sync/atomic)) |
| `sync.Map` is special-purpose | Only two documented use cases | "The Map type is optimized for two common use cases" ([sync](https://pkg.go.dev/sync#Map)) |
| `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](https://pkg.go.dev/sync#Pool)) |

---

## 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](https://pkg.go.dev/sync#Mutex)), 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](https://github.com/uber-go/guide/blob/master/style.md#zero-value-mutexes-are-valid)). 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](https://pkg.go.dev/sync#Mutex)). 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](https://google.github.io/styleguide/go/decisions#copying)). 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](https://google.github.io/styleguide/go/decisions#receiver_type)).

```go
// WRONG — value receiver copies the Mutex on every call; each call locks its own copy.
// go vet: "Inc passes lock by value: ...Counter contains sync.Mutex"
type Counter struct {
	mu sync.Mutex
	n  int
}
func (c Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() } // c is a COPY

// RIGHT — pointer receiver; the one real lock guards the one real field.
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](https://pkg.go.dev/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](https://pkg.go.dev/sync#WaitGroup)) — 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](https://google.github.io/styleguide/go/decisions#receiver_type) 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.

```go
// WRONG — early return skips Unlock; the lock is held forever and the next Lock deadlocks
func (c *Cache) Get(k string) (V, error) {
	c.mu.Lock()
	v, ok := c.m[k]
	if !ok {
		return V{}, errNotFound // returns while still locked
	}
	c.mu.Unlock()
	return v, nil
}

// RIGHT — defer Unlock on the line after Lock; every return path releases it
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](https://github.com/uber-go/guide/blob/master/style.md#copy-slices-and-maps-at-boundaries)). 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](https://github.com/uber-go/guide/blob/master/style.md#zero-value-mutexes-are-valid)) — 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](https://github.com/uber-go/guide/blob/master/style.md#zero-value-mutexes-are-valid)).

```go
// WRONG — embedded Mutex; SMap.Lock() and SMap.Unlock() are now public API
type SMap struct {
	sync.Mutex
	data map[string]string
}

// RIGHT — named, unexported field; the lock is an implementation detail
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](https://pkg.go.dev/sync#RWMutex)) — 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](https://pkg.go.dev/sync#RWMutex)), 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](https://pkg.go.dev/sync#Once.Do)). It is the right tool for lazy, concurrency-safe initialization of a single shared value.

```go
// RIGHT — the config loads exactly once no matter how many goroutines call get()
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](https://pkg.go.dev/sync#OnceValue)). 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](https://pkg.go.dev/sync/atomic)). The typed value carries its own no-copy rule — "Int64 must not be copied after first use" ([sync/atomic](https://pkg.go.dev/sync/atomic)) — and `copylocks` catches a copied atomic too.

```go
// WRONG — a plain field updated from several goroutines is a data race (go-race-and-memory-model)
type Stats struct{ n int64 }
func (s *Stats) Inc() { s.n++ } // unsynchronized read-modify-write

// WRONG (old style) — the free function works but is the error-prone API
atomic.AddInt64(&s.n, 1)

// RIGHT — typed atomic (1.19+); zero value ready, the whole API is methods
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](https://pkg.go.dev/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](https://pkg.go.dev/sync#Map)). 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](https://pkg.go.dev/sync#Map)). Outside those, a plain `map` guarded by a `Mutex` is faster, type-safe, and clearer.

```go
// WRONG — sync.Map as a general concurrent map: untyped any keys/values, slower for read-write churn
var m sync.Map
m.Store("k", 1)
v, _ := m.Load("k") // v is any; every use needs a type assertion

// RIGHT (default) — a typed map guarded by a Mutex
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](https://pkg.go.dev/sync#Pool)) — the canonical use is reusable `bytes.Buffer`s 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](https://pkg.go.dev/sync#Pool)). 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.

```go
// RIGHT — a buffer pool: New supplies a fresh item, Reset clears it before reuse
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func process(data []byte) string {
	buf := bufPool.Get().(*bytes.Buffer)
	buf.Reset()            // clear whatever the previous user wrote
	defer bufPool.Put(buf) // return it for reuse; it may also just be GC'd
	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](https://go-proverbs.github.io/)) is the decision rule. The famous "share memory by communicating" ([Effective Go](https://go.dev/doc/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:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

