Sync #

Goroutines make concurrency in Go feel easy — but as soon as two goroutines access the same data simultaneously, without proper coordination, the result is unpredictable. The sync package provides the basic synchronization primitives for this situation: Mutex to ensure only one goroutine accesses critical data at a time, RWMutex to distinguish between readers and writers, WaitGroup to wait for a group of goroutines to finish, Once for guaranteed one-time initialization, Pool for recycling objects and reducing garbage collector pressure, and Map for safe concurrent maps. Understanding when and how to use each primitive is the difference between correct concurrent programs and ones full of race conditions.

An Overview of the sync Package #

flowchart TD
    Sync["package sync"] --> Mutex["Mutex\nExclusive lock\none goroutine at a time"]
    Sync --> RWMutex["RWMutex\nRead-Write lock\nmany readers or one writer"]
    Sync --> WaitGroup["WaitGroup\nWait for N goroutines to finish"]
    Sync --> Once["Once\nRun a function exactly once"]
    Sync --> Pool["Pool\nRecycle objects, reduce GC pressure"]
    Sync --> Map["Map\nConcurrent-safe map"]
    Sync --> Cond["Cond\nCondition variable\nnotifications between goroutines"]

    Mutex --> M1["Lock() / Unlock()\nalways defer Unlock()"]
    RWMutex --> RW1["Lock() / Unlock()\nfor writes"]
    RWMutex --> RW2["RLock() / RUnlock()\nfor reads"]
    WaitGroup --> WG1["Add(n) / Done() / Wait()"]
    Once --> O1["Do(func())\nonly runs the first time"]
    Pool --> P1["Get() / Put()\nrecycle expensive objects"]
    Map --> SM1["Store / Load / Delete\nLoadOrStore / Range"]

    style Sync fill:#4f86c6,color:#fff
    style Mutex fill:#e8f5e9
    style RWMutex fill:#e3f2fd
    style WaitGroup fill:#fff3e0
    style Once fill:#f3e5f5
    style Pool fill:#fce4ec
    style Map fill:#e0f7fa

Mutex — Mutual Exclusion #

sync.Mutex ensures only one goroutine can execute the critical section — the code accessing shared data — at a time. Other goroutines trying to Lock() will block until the Mutex is released.

package main

import (
    "fmt"
    "sync"
)

// ANTI-PATTERN: a counter without synchronization — race condition!
type UnsafeCounter struct {
    value int
}

func (c *UnsafeCounter) Add() {
    c.value++ // read-modify-write isn't atomic!
}

// CORRECT: a counter with a Mutex
type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Add() {
    c.mu.Lock()
    defer c.mu.Unlock() // ALWAYS defer Unlock — prevents forgetting to unlock on panic
    c.value++
}

func (c *Counter) Subtract() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value--
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

func main() {
    counter := &Counter{}
    var wg sync.WaitGroup

    // 1000 goroutines adding to the counter simultaneously
    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Add()
        }()
    }

    wg.Wait()
    fmt.Println("Final value:", counter.Value()) // always 1000
}

Pattern: Structs with an Embedded Mutex #

The Go convention is to place the Mutex right above the fields it protects, and not export the Mutex:

type Cache struct {
    mu      sync.Mutex      // protects the fields below
    data    map[string]string
    hits    int
    misses  int
}

func NewCache() *Cache {
    return &Cache{
        data: make(map[string]string),
    }
}

func (c *Cache) Set(key, val string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = val
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()

    val, exists := c.data[key]
    if exists {
        c.hits++
    } else {
        c.misses++
    }
    return val, exists
}

func (c *Cache) Stats() (hits, misses int) {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.hits, c.misses
}

Deadlock — The Trap to Avoid #

flowchart LR
    subgraph Deadlock["Deadlock"]
        G1["Goroutine A\nholds Lock mu1\nwaits for mu2"] 
        G2["Goroutine B\nholds Lock mu2\nwaits for mu1"]
        G1 <-->|"waiting on each other\nforever"| G2
    end

    subgraph How["How to Avoid It"]
        C1["Always lock in\nthe same order"]
        C2["Use one Mutex\nfor related data"]
        C3["Avoid nested\nlocks if possible"]
        C4["Use defer Unlock\nso you don't forget"]
    end

    style Deadlock fill:#fce4ec
    style How fill:#e8f5e9
// ANTI-PATTERN: deadlock because of a different lock order
var mu1, mu2 sync.Mutex

// Goroutine A
go func() {
    mu1.Lock()
    defer mu1.Unlock()
    // ... do something
    mu2.Lock() // waits for mu2 — but goroutine B holds mu2 and waits for mu1!
    defer mu2.Unlock()
}()

// Goroutine B
go func() {
    mu2.Lock()
    defer mu2.Unlock()
    // ... do something
    mu1.Lock() // deadlock!
    defer mu1.Unlock()
}()

// CORRECT: always lock in the same order
go func() {
    mu1.Lock()
    defer mu1.Unlock()
    mu2.Lock()
    defer mu2.Unlock()
    // ...
}()
go func() {
    mu1.Lock() // the same order: mu1 first, then mu2
    defer mu1.Unlock()
    mu2.Lock()
    defer mu2.Unlock()
    // ...
}()

RWMutex — Read-Write Lock #

sync.RWMutex is an optimization of Mutex for scenarios where read operations are far more frequent than writes. Many goroutines can hold RLock simultaneously, but only one can hold Lock (the write lock) — and no one can read while the write lock is active.

flowchart TD
    subgraph States["RWMutex States"]
        Free["Free\n(no lock)"]
        Reading["Reading\n(N goroutines using RLock)\n✓ RLock can enter\n✗ Lock must wait"]
        Writing["Writing\n(1 goroutine using Lock)\n✗ RLock must wait\n✗ Lock must wait"]
    end

    Free -- "RLock()" --> Reading
    Free -- "Lock()" --> Writing
    Reading -- "all RUnlock()" --> Free
    Writing -- "Unlock()" --> Free
    Reading -- "Lock() — wait" --> Writing

    style Free fill:#e8f5e9
    style Reading fill:#e3f2fd
    style Writing fill:#fce4ec
type ConfigStore struct {
    mu     sync.RWMutex
    config map[string]string
}

func NewConfigStore() *ConfigStore {
    return &ConfigStore{
        config: make(map[string]string),
    }
}

// Read — many goroutines can read simultaneously
func (cs *ConfigStore) Get(key string) (string, bool) {
    cs.mu.RLock()         // read lock — doesn't block other readers
    defer cs.mu.RUnlock()
    val, exists := cs.config[key]
    return val, exists
}

func (cs *ConfigStore) GetAll() map[string]string {
    cs.mu.RLock()
    defer cs.mu.RUnlock()

    // Make a copy to avoid concurrent access to the returned map
    copy := make(map[string]string, len(cs.config))
    for k, v := range cs.config {
        copy[k] = v
    }
    return copy
}

// Write — only one goroutine at a time, blocks all readers
func (cs *ConfigStore) Set(key, val string) {
    cs.mu.Lock()         // write lock — exclusive
    defer cs.mu.Unlock()
    cs.config[key] = val
}

func (cs *ConfigStore) SetMany(kv map[string]string) {
    cs.mu.Lock()
    defer cs.mu.Unlock()
    for k, v := range kv {
        cs.config[k] = v
    }
}

// When is RWMutex better than Mutex?
// Use RWMutex if:
// - Read operations >> write operations (e.g. 100:1)
// - Read operations take a meaningful amount of time
// For very fast operations (just reading/writing a field),
// the RWMutex overhead can be bigger than its benefit

WaitGroup — Waiting for Goroutines to Finish #

sync.WaitGroup is used to wait for a group of goroutines to finish before continuing execution. It works like a counter: Add increments, Done decrements, Wait blocks until the counter reaches zero.

import (
    "fmt"
    "sync"
    "time"
)

func main() {
    var wg sync.WaitGroup

    tasks := []string{"send email", "update cache", "write log", "notify"}

    for _, t := range tasks {
        wg.Add(1) // increment BEFORE the goroutine starts
        go func(name string) {
            defer wg.Done() // decrement when the goroutine finishes
            fmt.Printf("Starting: %s\n", name)
            time.Sleep(100 * time.Millisecond) // simulate work
            fmt.Printf("Finished: %s\n", name)
        }(t) // pass it as an argument, not captured directly
    }

    wg.Wait() // blocks until all goroutines call Done
    fmt.Println("All tasks finished")
}

Pattern: Fan-Out with Result Collection #

func processParallel(items []string, workerCount int) []Result {
    jobs := make(chan string, len(items))
    results := make(chan Result, len(items))

    var wg sync.WaitGroup

    // Start the worker pool
    for i := 0; i < workerCount; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for item := range jobs {
                result := processItem(item)
                results <- result
            }
        }()
    }

    // Send all the jobs
    for _, item := range items {
        jobs <- item
    }
    close(jobs) // signal: no more jobs

    // Wait for all workers to finish, then close results
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect all the results
    var all []Result
    for result := range results {
        all = append(all, result)
    }
    return all
}

WaitGroup Anti-Patterns #

// ANTI-PATTERN 1: Add inside the goroutine — race condition!
var wg sync.WaitGroup
for _, item := range items {
    go func(i string) {
        wg.Add(1) // too late! Wait() could finish before Add() is called
        defer wg.Done()
        process(i)
    }(item)
}
wg.Wait()

// CORRECT: Add outside the goroutine, before the go keyword
for _, item := range items {
    wg.Add(1) // this is correct
    go func(i string) {
        defer wg.Done()
        process(i)
    }(item)
}

// ANTI-PATTERN 2: Reusing a WaitGroup before Wait finishes
var wg2 sync.WaitGroup
wg2.Add(1)
go func() {
    defer wg2.Done()
    time.Sleep(time.Second)
}()
wg2.Add(1) // this is SAFE if the counter is still > 0
go func() {
    defer wg2.Done()
}()
wg2.Wait()

// wg2.Add(1) here is NOT SAFE if another goroutine is already Waiting

Once — One-Time Initialization #

sync.Once ensures a function is executed exactly once, even if called from many goroutines simultaneously. This is the idiomatic way to do thread-safe lazy initialization.

import "sync"

// Pattern: a singleton with Once
type Database struct {
    conn *sql.DB
}

var (
    dbInstance *Database
    dbOnce     sync.Once
)

func GetDatabase() *Database {
    dbOnce.Do(func() {
        // Only runs ONCE, even if GetDatabase is called from
        // thousands of goroutines simultaneously
        conn, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
        if err != nil {
            panic(fmt.Sprintf("failed to open the DB connection: %v", err))
        }
        if err := conn.Ping(); err != nil {
            panic(fmt.Sprintf("failed to ping the DB: %v", err))
        }
        dbInstance = &Database{conn: conn}
        fmt.Println("Database connection created successfully")
    })
    return dbInstance
}

Once with Error Handling #

sync.Once doesn’t return an error — if the function inside Do fails, you need to store the error separately:

type Service struct {
    once sync.Once
    err  error
    conn *Connection
}

func (s *Service) ensureConnected() error {
    s.once.Do(func() {
        conn, err := openConnection()
        if err != nil {
            s.err = fmt.Errorf("ensureConnected: %w", err)
            return
        }
        s.conn = conn
    })
    return s.err
}

func (s *Service) Process(data string) error {
    if err := s.ensureConnected(); err != nil {
        return err
    }
    return s.conn.Send(data)
}
sync.Once can’t be reset. If the function inside Do panics, Once still considers the execution finished — subsequent Do calls won’t run the function again. If you need retryable initialization after a failure, use a Mutex with a boolean flag instead.

Pool — Object Pooling #

sync.Pool is a pool of recyclable objects that reduces garbage collector pressure. Useful for objects that are expensive to create and frequently created-and-discarded, like buffers, temporary connections, or decoders.

flowchart LR
    subgraph WithoutPool["Without a Pool"]
        A1["Request 1\ncreate a new buffer"] --> GC1["GC must\nclean up the buffer"]
        A2["Request 2\ncreate a new buffer"] --> GC2["GC must\nclean up the buffer"]
        A3["Request 3\ncreate a new buffer"] --> GC3["GC must\nclean up the buffer"]
    end

    subgraph WithPool["With a Pool"]
        B1["Request 1\nPool.Get()"] --> Buf["Buffer\n(recycled)"]
        Buf --> B1Done["Pool.Put()\nreturn to the pool"]
        B1Done --> B2["Request 2\nPool.Get()\n(uses the same buffer)"]
        B2 --> B2Done["Pool.Put()"]
        B2Done --> B3["Request 3\nPool.Get()"]
    end

    style WithoutPool fill:#fce4ec
    style WithPool fill:#e8f5e9
import (
    "bytes"
    "sync"
)

// A pool for bytes.Buffer — very common in HTTP applications
var bufferPool = sync.Pool{
    New: func() any {
        // Called when the pool is empty and needs a new object
        return new(bytes.Buffer)
    },
}

func processRequest(data []byte) string {
    // Take a buffer from the pool
    buf := bufferPool.Get().(*bytes.Buffer)
    buf.Reset() // IMPORTANT: always reset before use!
    defer bufferPool.Put(buf) // return it to the pool when done

    // Use the buffer
    buf.Write(data)
    buf.WriteString(" [processed]")
    return buf.String()
}

// A pool for JSON encoders/decoders
var jsonDecoderPool = sync.Pool{
    New: func() any {
        return json.NewDecoder(nil)
    },
}

// A pool for expensive-to-initialize structs
type WorkerState struct {
    Buffer   []byte
    Results  []string
    count    int
}

var workerPool = sync.Pool{
    New: func() any {
        return &WorkerState{
            Buffer:  make([]byte, 0, 4096),
            Results: make([]string, 0, 100),
        }
    },
}

func runWorker(input string) []string {
    state := workerPool.Get().(*WorkerState)
    // Reset the state before use
    state.Buffer = state.Buffer[:0]
    state.Results = state.Results[:0]
    state.count = 0
    defer workerPool.Put(state)

    // Use the state...
    state.Results = append(state.Results, input+" result")
    return state.Results
}
sync.Pool isn’t a permanent cache — the GC can clear the pool’s contents at any time. Don’t store important state in the pool. Pools are best for objects that are expensive to create (large memory allocations, connections, encoders) but don’t need to persist. Always Reset() or clean objects before reusing them from a pool.

sync.Map — A Concurrent-Safe Map #

Go’s built-in map (map[K]V) isn’t safe for concurrent access — reading and writing from different goroutines without synchronization causes race conditions. sync.Map provides a map that’s safe for concurrent access without needing a manual Mutex.

import "sync"

var m sync.Map

// Store — save a value
m.Store("key1", "value1")
m.Store("key2", 42)
m.Store("key3", true)

// Load — read a value
val, exists := m.Load("key1")
if exists {
    fmt.Println(val.(string)) // "value1"
}

// LoadOrStore — read or store if missing (atomic)
actual, loaded := m.LoadOrStore("key1", "new-value")
fmt.Println(actual.(string)) // "value1" — unchanged
fmt.Println(loaded)          // true — the key already existed

actual2, loaded2 := m.LoadOrStore("new-key", "new-value")
fmt.Println(actual2.(string)) // "new-value" — stored
fmt.Println(loaded2)          // false — a new key

// LoadAndDelete — read and delete (atomic)
val2, exists2 := m.LoadAndDelete("key2")
if exists2 {
    fmt.Println(val2.(int)) // 42
}

// Delete — remove a key
m.Delete("key3")

// Range — iterate all key-value pairs
// NOTE: no order guarantee, and the map can change during iteration
m.Range(func(key, value any) bool {
    fmt.Printf("%v: %v\n", key, value)
    return true // return false to stop the iteration
})

// CompareAndSwap — update only if the current value matches (Go 1.20+)
swapped := m.CompareAndSwap("key1", "value1", "updated-value")
fmt.Println(swapped) // true if the swap succeeded

// CompareAndDelete — delete only if the value matches (Go 1.20+)
deleted := m.CompareAndDelete("key1", "updated-value")
fmt.Println(deleted) // true if the delete succeeded

sync.Map vs map + Mutex — When to Use Which #

flowchart TD
    Q{"Concurrent map\naccess pattern?"} --> P1["Many goroutines\nread/write the\nSAME keys"]
    Q --> P2["Each goroutine\nreads/writes\nDIFFERENT keys"]
    Q --> P3["Keys stable after\ninitialization,\nmany readers"]

    P1 --> R1["map + Mutex\nmore efficient\nfor high contention"]
    P2 --> R2["sync.Map\noptimal for\nthis pattern (internal sharding)"]
    P3 --> R3["sync.Map\nor map + RWMutex\nboth are fine"]

    style R1 fill:#e3f2fd
    style R2 fill:#e8f5e9
    style R3 fill:#fff3e0
// An ideal sync.Map case: a cache read by many goroutines,
// rarely updated, different keys per goroutine
var cache sync.Map

func getFromCache(key string) (string, bool) {
    val, exists := cache.Load(key)
    if !exists {
        return "", false
    }
    return val.(string), true
}

func saveToCache(key, val string) {
    cache.Store(key, val)
}

// A case better served by map + Mutex:
// per-key counters accessed by many goroutines
type HitCounter struct {
    mu   sync.Mutex
    hits map[string]int
}

func (hc *HitCounter) Record(endpoint string) {
    hc.mu.Lock()
    defer hc.mu.Unlock()
    hc.hits[endpoint]++
}

Cond — Condition Variables #

sync.Cond is a primitive for sending notifications between goroutines when a certain condition is met. It’s used less often than the other primitives, but useful for producer-consumer scenarios needing finer coordination than channels.

type BoundedQueue struct {
    mu      sync.Mutex
    cond    *sync.Cond
    queue   []string
    maxSize int
}

func NewBoundedQueue(maxSize int) *BoundedQueue {
    q := &BoundedQueue{maxSize: maxSize}
    q.cond = sync.NewCond(&q.mu)
    return q
}

// Add an item — blocks if full
func (q *BoundedQueue) Enqueue(item string) {
    q.mu.Lock()
    defer q.mu.Unlock()

    for len(q.queue) >= q.maxSize {
        q.cond.Wait() // release the lock, wait for a notification, reacquire the lock
    }

    q.queue = append(q.queue, item)
    q.cond.Signal() // notify one waiting goroutine
}

// Take an item — blocks if empty
func (q *BoundedQueue) Dequeue() string {
    q.mu.Lock()
    defer q.mu.Unlock()

    for len(q.queue) == 0 {
        q.cond.Wait()
    }

    item := q.queue[0]
    q.queue = q.queue[1:]
    q.cond.Signal()
    return item
}

Production Usage Patterns #

A Thread-Safe Struct with CRUD Methods #

type UserStore struct {
    mu     sync.RWMutex
    users  map[int]*User
    nextID int
}

func NewUserStore() *UserStore {
    return &UserStore{
        users: make(map[int]*User),
    }
}

func (s *UserStore) Add(name, email string) *User {
    s.mu.Lock()
    defer s.mu.Unlock()

    s.nextID++
    user := &User{ID: s.nextID, Name: name, Email: email}
    s.users[user.ID] = user
    return user
}

func (s *UserStore) Find(id int) (*User, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    user, exists := s.users[id]
    return user, exists
}

func (s *UserStore) Update(id int, name, email string) bool {
    s.mu.Lock()
    defer s.mu.Unlock()

    user, exists := s.users[id]
    if !exists {
        return false
    }
    user.Name = name
    user.Email = email
    return true
}

func (s *UserStore) Delete(id int) bool {
    s.mu.Lock()
    defer s.mu.Unlock()

    _, exists := s.users[id]
    if !exists {
        return false
    }
    delete(s.users, id)
    return true
}

func (s *UserStore) All() []*User {
    s.mu.RLock()
    defer s.mu.RUnlock()

    result := make([]*User, 0, len(s.users))
    for _, u := range s.users {
        // Make a copy for safety
        copy := *u
        result = append(result, &copy)
    }
    return result
}

A Worker Pool with Context #

type WorkerPool struct {
    jobs chan func()
    wg   sync.WaitGroup
    once sync.Once
    quit chan struct{}
}

func NewWorkerPool(workerCount int) *WorkerPool {
    wp := &WorkerPool{
        jobs: make(chan func(), workerCount*10),
        quit: make(chan struct{}),
    }

    for i := 0; i < workerCount; i++ {
        wp.wg.Add(1)
        go wp.worker()
    }

    return wp
}

func (wp *WorkerPool) worker() {
    defer wp.wg.Done()
    for {
        select {
        case job, ok := <-wp.jobs:
            if !ok {
                return
            }
            job()
        case <-wp.quit:
            return
        }
    }
}

func (wp *WorkerPool) Submit(job func()) {
    select {
    case wp.jobs <- job:
    case <-wp.quit:
    }
}

func (wp *WorkerPool) Stop() {
    wp.once.Do(func() { // Once ensures it's only called once
        close(wp.quit)
        close(wp.jobs)
        wp.wg.Wait()
    })
}

// Usage
func main() {
    pool := NewWorkerPool(5)
    defer pool.Stop()

    var mu sync.Mutex
    results := make([]int, 0)

    var wg sync.WaitGroup
    for i := 0; i < 20; i++ {
        wg.Add(1)
        n := i
        pool.Submit(func() {
            defer wg.Done()
            // Simulate work
            time.Sleep(10 * time.Millisecond)
            mu.Lock()
            results = append(results, n*n)
            mu.Unlock()
        })
    }

    wg.Wait()
    fmt.Println("Results:", results)
}

A Rate Limiter with a Mutex #

type RateLimiter struct {
    mu         sync.Mutex
    tokens     float64
    maxTokens  float64
    refillRate float64 // tokens per second
    lastRefill time.Time
}

func NewRateLimiter(maxTokens, refillRate float64) *RateLimiter {
    return &RateLimiter{
        tokens:     maxTokens,
        maxTokens:  maxTokens,
        refillRate: refillRate,
        lastRefill: time.Now(),
    }
}

func (rl *RateLimiter) Allow() bool {
    rl.mu.Lock()
    defer rl.mu.Unlock()

    // Refill tokens based on the elapsed time
    now := time.Now()
    elapsed := now.Sub(rl.lastRefill).Seconds()
    rl.tokens = min(rl.maxTokens, rl.tokens+elapsed*rl.refillRate)
    rl.lastRefill = now

    if rl.tokens < 1 {
        return false
    }

    rl.tokens--
    return true
}

func min(a, b float64) float64 {
    if a < b {
        return a
    }
    return b
}

Detecting Race Conditions #

Go provides a race detector that can be enabled when building or testing:

# Run with the race detector
go run -race main.go

# Test with the race detector
go test -race ./...

# Build with the race detector (for staging/canary)
go build -race -o myapp main.go
// The race detector will catch this:
var counter int

go func() { counter++ }() // write
go func() { counter++ }() // simultaneous write — RACE!

// And this:
m := map[string]int{}
go func() { m["key"] = 1 }() // write
go func() { _ = m["key"] }() // simultaneous read — RACE!
Always run go test -race ./... in your CI/CD pipeline. The race detector adds ~5-10x memory overhead and ~2-20x execution time, but it’s very effective at catching race conditions that are hard to find manually. Don’t deploy to production with the race detector enabled because of its significant overhead.

When to Switch to Alternatives #

Keep using sync if:
  ✓ Mutual exclusion with Mutex for concurrently accessed data
  ✓ RWMutex for data read more often than written
  ✓ WaitGroup for waiting for goroutines to finish
  ✓ Once for thread-safe singleton initialization
  ✓ Pool for recycling expensive objects and reducing GC pressure

Consider channels if:
  ✗ Communication between goroutines (sending data, not just synchronization)
  ✗ Pipeline processing — channels are more expressive for data flows
  ✗ Fan-out / fan-in patterns
  ✗ "Share memory by communicating" — Go's recommended philosophy

Consider sync/atomic if:
  ✗ Simple counters (int32, int64, uint64)
  ✗ Boolean flags (on/off)
  ✗ Atomic pointer swaps
  ✗ Operations faster than Mutex for primitive types

Consider golang.org/x/sync if:
  ✗ errgroup — WaitGroup + error handling + context cancellation
  ✗ semaphore — limit the number of concurrent operations
  ✗ singleflight — deduplicate identical concurrent requests

Summary #

  • Always defer mu.Unlock() right after a successful mu.Lock() — this ensures the Mutex is always released even on panic or early return.
  • RWMutex for data read more often than written — many goroutines can RLock() simultaneously, but only one can Lock(). Effective when reads » writes.
  • WaitGroup.Add() must be called before the goroutine starts, not inside it — the race between Add and Wait is a common bug.
  • sync.Once for singletons and lazy initialization — thread-safe without manual locking, but can’t be reset and doesn’t handle errors elegantly.
  • sync.Pool for expensive objects frequently created and discarded — always Reset() objects before using them from the pool, and don’t store important state in the pool because the GC can clear it at any time.
  • sync.Map for concurrent caches with rarely conflicting keys — for high contention (many goroutines accessing the same keys), map + Mutex is often faster.
  • Use go test -race routinely — the race detector is the best tool for catching race conditions invisible in code review.
  • Deadlocks happen when goroutines wait on each other — avoid them by always locking Mutexes in the same order across all goroutines, and avoiding nested locks when possible.
  • Consider channels before Mutex — if you can model the problem as communication (sending data between goroutines), channels often produce cleaner, easier-to-understand code.

← Previous: Context   Next: Log Slog →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact