Go Advanced #2 Memory Model and the sync Package
If #1 Concurrency Patterns showed how to compose channels — this post covers the other side. The tools you need when accessing shared variables directly instead of using channels.
“Don’t communicate by sharing memory; share memory by communicating.”
The aphorism is right — but in practice there are cases where shared variables fit better. Counters, caches, lazy initialization.
One-line summary of the memory model #
Go’s memory model guarantees that when two goroutines access the same variable concurrently (read + write, or write + write), it’s a data race. To share state safely, synchronization is required.
Synchronization tools beyond channels — the sync package and sync/atomic, which we cover in this post.
Mutex — the simplest lock #
import "sync"
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}The code between Lock() / Unlock() is the critical section. Only one goroutine at a time can enter.
Pattern — defer Unlock is standard
#
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // automatic on function exit
c.n++
}Safe even with panics or early returns. Almost always follow this pattern.
Mutex’s zero value is usable #
var mu sync.Mutex // ready to use
mu.Lock()No need for sync.Mutex{} initialization. Embedded in a struct — automatically operational.
Pitfall — don’t copy Mutex #
type Counter struct {
mu sync.Mutex
n int
}
func use(c Counter) { // ✗ value copy — Mutex is copied too
c.mu.Lock()
// ...
}Receiving by value — the Mutex is copied wholesale to a different lock. go vet catches it. Always use pointer receivers.
RWMutex — concurrent reads #
When reads are many and writes are few — sync.RWMutex increases read concurrency.
type Cache struct {
mu sync.RWMutex
m map[string]string
}
func (c *Cache) Get(k string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.m[k]
}
func (c *Cache) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k] = v
}RLock/RUnlock— many readers can enter at onceLock/Unlock— a writer is alone
Benchmarks show that RWMutex only pays off when reads account for 90% or more of accesses. Below that, it performs similarly to or slower than a plain Mutex due to RWMutex’s own overhead.
WaitGroup — wait for many goroutines to finish #
You saw it in Intermediate #3. Just the essentials again.
var wg sync.WaitGroup
for _, x := range items {
wg.Add(1)
go func(x Item) {
defer wg.Done()
process(x)
}(x)
}
wg.Wait()Three methods: register with Add(n), signal completion with Done(), and block until all are done with Wait().
Pitfall — where to call Add #
Add(1) goes before starting the goroutine, in the main goroutine. If you Add inside the goroutine — Wait may run first while the count is 0 and exit.
Once — execute exactly once #
A tool suited for lazy initialization.
var (
once sync.Once
instance *DB
)
func GetDB() *DB {
once.Do(func() {
instance = connect()
})
return instance
}once.Do(f) — even when many goroutines call it concurrently, f runs exactly once. Every subsequent call returns immediately.
Fits singletons, lazy init, and one-shot cleanup.
Pool — reuse temporary objects #
When you want to reduce GC pressure.
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func handler() {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
// use buf
}Characteristics:
Getpulls from the pool, or callsNewif empty- Return with
Put - The pool’s objects may be reclaimed by GC at any time — no guarantee you’ll get the same one back
Big win for hot-path bytes.Buffer and byte slices. Used inside the fmt package too.
atomic — single-variable synchronization without locks #
The sync/atomic package handles reads and writes of a single variable atomically. It is lighter than a Mutex, but only applies when you are protecting a single variable.
import "sync/atomic"
type Counter struct {
n atomic.Int64
}
func (c *Counter) Inc() {
c.n.Add(1)
}
func (c *Counter) Value() int64 {
return c.n.Load()
}Types like atomic.Int64 added in Go 1.19+ — enforce atomicity by type, safer. Cleaner than older versions’ atomic.AddInt64(&n, 1).
compare-and-swap #
var flag atomic.Bool
if flag.CompareAndSwap(false, true) {
// only the first goroutine reaches here
}CompareAndSwap(old, new) — if the current value is old, set it to new and return true; otherwise leave it and return false. The basic primitive of lock-free algorithms.
Which tool when? #
| Situation | Tool |
|---|---|
| Single counter/flag | atomic |
| Protect a struct or several variables together | Mutex |
| Reads dominate, writes rare | RWMutex |
| Wait for many goroutines to finish | WaitGroup |
| One-time initialization | Once |
| Reuse temporary objects | Pool |
| Staged data flow, signaling | channels |
Default to channels first. Where channels feel awkward (shared counters, caches, one-time init), switch to sync tools.
Data races — the race detector #
Go catches data races via a standard tool.
go run -race main.go
go test -race ./...
go build -raceIt watches for concurrent access at runtime and reports immediately on detection. There is overhead, so do not enable it in production, but always turn it on in tests and CI.
WARNING: DATA RACE
Read at 0x00c000118018 by goroutine 7:
main.read()
/tmp/race.go:14 +0x3a
Previous write at 0x00c000118018 by goroutine 6:
main.write()
/tmp/race.go:9 +0x47Tells you both read and write locations. A core debugging tool.
Channels vs Mutex — which side? #
| Situation | Which? |
|---|---|
| Transfer ownership of data (producer → consumer) | channel |
| Staged processing flow (pipeline) | channel |
| Multiple goroutines read and write shared state | Mutex |
| Simple counter/flag | atomic |
| One-time init | Once |
The Go community’s consensus is — both are tools. The aphorism says “channels first,” but for cases inside a data structure where channels feel awkward, Mutex is natural. The standard library itself uses both freely.
Pitfall — lock scope #
func (c *Cache) Get(k string) string {
c.mu.Lock()
v := c.m[k]
c.mu.Unlock()
return v
}
func main() {
v := c.Get("k")
process(v) // outside the lock — OK
}Locks should be held only as long as necessary. Doing heavy work inside a lock — such as I/O or external calls — makes other goroutines wait.
c.mu.Lock()
defer c.mu.Unlock()
v := c.m[k]
log.Print(v) // ✗ I/O inside the lock
http.Get(someURL) // ✗ network inside the lockI/O always outside the lock.
Pitfall — order of two locks #
If you must hold multiple locks, always acquire them in the same order. Inconsistent ordering leads to deadlock.
// goroutine A
a.Lock(); b.Lock() // a → b order
// goroutine B (reverse)
b.Lock(); a.Lock() // b → a order — deadlocks if A is in the middleWhen possible — avoid designs that need multiple locks at all.
Wrap-up #
What we covered:
- Mutex — simplest critical-section protection,
defer Unlockis standard - RWMutex — only meaningful when reads dominate
- WaitGroup — wait for many goroutines,
Addoutside the goroutine - Once — guaranteed one-shot execution, lazy init
- Pool — reuse temporary objects, ease GC pressure
- atomic — lock-free synchronization for a single variable
- race detector — always on in tests/CI
- No I/O inside locks; consistent lock order
In the next post (#3 Generics) — type parameters introduced in Go 1.18. When to use them and when not, and how constraints work.