Go's motto is "share memory by communicating," but sometimes you genuinely need shared state. A cache, a counter, a connection poolWhat is connection pool?A set of pre-opened database connections that your app reuses instead of opening and closing a new one for every request., these are naturally shared resources. The sync package provides the low-level primitives for safe concurrent access. AI reaches for these reflexively because they look familiar from other languages, but often uses them where channels would be cleaner.
sync.MutexWhat is mutex?A mutual exclusion lock that prevents concurrent access to shared data - only one thread or goroutine can hold it at a time.
A mutex provides mutual exclusion, only one goroutineWhat is goroutine?A lightweight concurrent function in Go launched with the go keyword - much cheaper than an OS thread (~2KB vs ~1MB of stack). can hold the lock at a time:
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}The pattern is always: Lock(), defer Unlock(), access the protected data, return. The defer ensures the unlock happens even if the code panics.
defer, using explicit Unlock() calls at each return point. This is fragile: add a new return path or early return and you forget to unlock, causing a deadlock. Always use defer.// BAD - AI generates this
func (c *SafeCounter) ConditionalInc(shouldInc bool) int {
c.mu.Lock()
if !shouldInc {
c.mu.Unlock() // easy to forget one of these
return c.count
}
c.count++
c.mu.Unlock()
return c.count
}
// CORRECT - defer handles all return paths
func (c *SafeCounter) ConditionalInc(shouldInc bool) int {
c.mu.Lock()
defer c.mu.Unlock()
if shouldInc {
c.count++
}
return c.count
}The copy trap
This is the most insidious mutexWhat is mutex?A mutual exclusion lock that prevents concurrent access to shared data - only one thread or goroutine can hold it at a time. bug AI generates. Mutexes must never be copied, but Go's value semantics make this easy to do accidentally:
type Config struct {
mu sync.Mutex
setting string
}
// WRONG - c is a copy, so c.mu is a copy - different lock!
func updateConfig(c Config) {
c.mu.Lock()
defer c.mu.Unlock()
c.setting = "new"
}
// CORRECT - pointer receiver, same lock
func updateConfig(c *Config) {
c.mu.Lock()
defer c.mu.Unlock()
c.setting = "new"
}| Operation | What happens to the mutex |
|---|---|
| Pass struct by value | Mutex is copied, new independent lock |
| Pass struct by pointer | Same mutex, correct |
| Assign struct to new variable | Mutex is copied |
| Return struct from function | Mutex is copied |
| Embed struct in another struct | Same mutex if accessed via pointer |
func (c Config) Get() copies the Config, including its mutex. Use go vet, it catches this. But AI doesn't run go vet before giving you code.sync.RWMutex
When reads vastly outnumber writes, RWMutex allows multiple concurrent readers:
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock() // multiple goroutines can RLock simultaneously
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
func (c *Cache) Set(key, val string) {
c.mu.Lock() // exclusive - blocks until all RLocks release
defer c.mu.Unlock()
c.data[key] = val
}| Lock type | Concurrent with RLock? | Concurrent with Lock? | Use for |
|---|---|---|---|
RLock() | Yes | No | Read-only access |
Lock() | No | No | Write access |
RWMutex when there's a mix of reads and writes without analyzing the ratio. RWMutex has higher overhead than Mutex, it's only faster when reads outnumber writes by roughly 10:1 or more. For a 50/50 read/write ratio, plain Mutex is faster.sync.Once
Guarantees a function executes exactly once, regardless of how many goroutines call it:
var (
db *Database
once sync.Once
)
func GetDB() *Database {
once.Do(func() {
db = connectToDatabase() // runs exactly once
})
return db
}Even if 100 goroutines call GetDB() simultaneously, connectToDatabase() runs once. All other callers block until it completes, then all return the same db instance.
sync.Once to initialize a resource but doesn't handle initialization errors. If connectToDatabase() fails, once.Do still marks it as "done", subsequent calls return nil forever. You need either a sync.Once per attempt (not how it works) or a different pattern like a mutex with a flag.// BAD - if init fails, it's failed forever
once.Do(func() {
db, err = connect()
// err is non-nil but once.Do won't retry
})
// BETTER - use mutex to allow retry
func GetDB() (*Database, error) {
mu.Lock()
defer mu.Unlock()
if db != nil {
return db, nil
}
var err error
db, err = connect()
return db, err
}sync.Map
A concurrent-safe map. But it's not a general-purpose replacement for map + Mutex:
var cache sync.Map
cache.Store("key", "value")
if val, ok := cache.Load("key"); ok {
fmt.Println(val.(string))
}
// LoadOrStore: atomic check-and-set
actual, loaded := cache.LoadOrStore("key", "default")| Feature | map + Mutex | sync.Map |
|---|---|---|
| Type safety | Yes (generics) | No (any types, needs assertion) |
| General performance | Better in most cases | Worse |
| Many reads, few writes | Good | Optimized for this |
| Disjoint key access | Same performance | Optimized for this |
| When to use | Default choice | Only when benchmarks prove it's faster |
sync.Map because it looks simpler than a mutex-protected map. But it loses type safety, has worse performance for most workloads, and its API is clunky. Use map + Mutex by default. Only switch to sync.Map if profiling shows contention.Channels vs mutexes: decision framework
This is the most common design question in concurrent Go, and where AI makes the worst architectural choices:
| Situation | Use channels | Use mutex |
|---|---|---|
| Passing data ownership between goroutines | Yes | No |
| Coordinating goroutine lifecycle | Yes | No |
| Protecting a shared data structure | No | Yes |
| Simple counter or flag | No | Yes |
| Pipeline of transformations | Yes | No |
| Cache with concurrent readers | No | Yes (RWMutex) |
| Fan-out work distribution | Yes | No |
| Rate limiting | Either works | Either works |
The mental model: channels transfer ownership, mutexes protect shared state. If you're sending data from one goroutineWhat is goroutine?A lightweight concurrent function in Go launched with the go keyword - much cheaper than an OS thread (~2KB vs ~1MB of stack). to another, use a channelWhat is channel?A typed conduit in Go used to pass values between goroutines - can be unbuffered (synchronous) or buffered (async queue).. If multiple goroutines need to read/write the same variable, use a mutexWhat is mutex?A mutual exclusion lock that prevents concurrent access to shared data - only one thread or goroutine can hold it at a time..
Deadlocks from lock ordering
When two goroutines acquire multiple locks in different orders, deadlockWhat is deadlock?A situation where two or more operations are stuck waiting on each other forever, so none of them can proceed.:
// DEADLOCK - inconsistent lock ordering
// Goroutine 1 Goroutine 2
// mu1.Lock() mu2.Lock()
// mu2.Lock() <- waits mu1.Lock() <- waits
// FIX - always acquire in the same order
func transfer(from, to *Account, amount int) {
// Ensure consistent ordering by comparing addresses
first, second := from, to
if uintptr(unsafe.Pointer(from)) > uintptr(unsafe.Pointer(to)) {
first, second = to, from
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
from.balance -= amount
to.balance += amount
}