Lesson 19/41 · Concurrency Core m03-l04

The sync Package: Mutex, RWMutex, WaitGroup, Once

Module 3: Concurrency | Lesson: m03-l04 | Level: Intermediate After this lesson you will be able to: use the core synchronization primitives in Go's sync package correctly, understand lock contention and object reuse under shared-memory concurrency, and write race-free concurrent programs.

Learning Objectives

  • Explain the difference between Mutex and RWMutex, and choose the right lock for different read/write ratios.
  • Use WaitGroup correctly to coordinate the lifecycles of multiple goroutines, avoiding deadlocks and data races.
  • Use Once to implement thread-safe singletons or one-time initialization.
  • Use Pool to optimize high-frequency object allocation/recycling, and understand its zeroing behavior.

Core Concepts

1.1 Shared Memory and Locks (Mutex & RWMutex)

In Go, shared memory is the core concurrency model. When multiple goroutines access the same memory, a synchronization mechanism must guarantee data consistency. Locks are the most basic synchronization primitive.

A Mutex is a mutual-exclusion lock: only one goroutine may hold it at a time. Its core methods are Lock() and Unlock(). An RWMutex is a reader/writer lock: multiple read locks may be held concurrently, but a write lock is exclusive. It provides RLock(), RUnlock(), Lock() and Unlock().

Why this design? In a concurrent system, reads usually do not modify data, so allowing concurrent reads can significantly improve throughput. However, if the critical section is very short or the read/write ratio is close, the extra state-maintenance cost of RWMutex can make it slower than a plain Mutex.

How? - Mutex fits write-heavy scenarios or longer critical sections. - RWMutex fits read-mostly scenarios (such as a config cache). - Atomic operations (such as the sync/atomic package) outperform locks in contention-free or simple counting scenarios, but locks are clearer for complex logic.

1.2 WaitGroup

A WaitGroup waits for a group of goroutines to finish. It has three methods: - Add(delta int): increments the counter. - Done(): decrements the counter by one, usually called at the end of a goroutine. - Wait(): blocks until the counter reaches zero.

Pitfall: Add must be called before Wait, or you get a deadlock or panic. WaitGroup must not be copied; copying it leads to undefined behavior.

1.3 Once

Once guarantees that a function runs exactly once for the entire lifetime of the program. It is commonly used for singletons or resource initialization. - Once.Do(f func()): if f has not run, it runs and is marked as done; otherwise it returns immediately.

1.4 Pool

sync.Pool caches temporary objects to reduce GC pressure. - Get(): fetches an object from the pool; if the pool is empty, it calls the New function to create one. - Put(obj any): returns an object to the pool.

Note: Pool does not guarantee objects are retained; the GC may clean up pooled objects. After fetching an object, you must reset its state yourself, because pooled objects may carry leftover data.

Example Code

Example 1: RWMutex Protecting a Shared Counter

This example uses an RWMutex to protect a shared counter, simulating concurrent reads and writes.

package main

import (
    "fmt"
    "sync"
)

var (
    mu      sync.RWMutex
    counter int
)

func main() {
    var wg sync.WaitGroup

    // start 5 writer goroutines
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            mu.Lock()
            counter++
            fmt.Printf("Writer %d: counter is %d\n", id, counter)
            mu.Unlock()
        }(i)
    }

    // start 5 reader goroutines
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            mu.RLock()
            fmt.Printf("Reader %d: counter is %d\n", id, counter)
            mu.RUnlock()
        }(i)
    }

    wg.Wait()
}

Expected output:

Writer 0: counter is 1
Reader 0: counter is 1
Writer 1: counter is 2
Reader 1: counter is 2
Writer 2: counter is 3
Reader 2: counter is 3
Writer 3: counter is 4
Reader 3: counter is 4
Writer 4: counter is 5
Reader 4: counter is 5

(Note: because goroutine scheduling order is nondeterministic, the interleaving of Writer and Reader lines may differ, but the counter value increases with each write.)

Example 2: sync.Pool Object Reuse and Reset

This example shows sync.Pool usage, focusing on the fact that you must reset state after fetching an object, because pooled objects may carry leftover data.

package main

import (
    "fmt"
    "sync"
)

type Buffer struct {
    Data []byte
}

var bufPool = sync.Pool{
    New: func() any {
        return &Buffer{Data: make([]byte, 64)}
    },
}

func main() {
    // first Get: the New function is called
    buf1 := bufPool.Get().(*Buffer)
    buf1.Data[0] = 'A'
    fmt.Printf("Buf1 content: %s\n", buf1.Data[:1])

    // return it to the pool
    bufPool.Put(buf1)

    // second Get: may return the buf1 we just put back
    buf2 := bufPool.Get().(*Buffer)
    // Key point: must reset the slice, because buf2 may contain the old 'A'
    buf2.Data[0] = 'B'
    fmt.Printf("Buf2 content: %s\n", buf2.Data[:1])

    // clear and return
    buf2.Data[0] = 0
    bufPool.Put(buf2)
}

Expected output:

Buf1 content: A
Buf2 content: B

Common Mistakes & Pitfalls

  1. Copying a Mutex - Bad code: var mu sync.Mutex; mu2 := mu; mu2.Lock() - Why: sync.Mutex carries internal state; copying it makes two instances contend over the same lock or corrupt state, causing undefined behavior. - Correct approach: always pass a pointer, or make sure you use the original variable.

  2. Calling WaitGroup's Add after Wait - Bad code: calling wg.Wait() first, then calling wg.Add(1) inside a goroutine. - Why: Wait blocks until the counter is zero. If you Add afterwards, Wait never returns and you deadlock. - Correct approach: make sure all Add calls happen before Wait.

  3. Writing to a nil map panics - Bad code: go var data map[string]string data["key"] = "value" // panic: assignment to entry in nil map - Why: a declared map that is not initialized is nil and cannot be written to. - Correct approach: initialize with make(map[string]string), or use sync.Map for concurrent writes.

Best Practices

  1. Choose the right lock: if data is mostly read and the critical section has some length, use RWMutex; otherwise prefer Mutex. When reads are under ~70% of operations, RWMutex can be slower.
  2. Lock granularity: keep critical sections as short as possible; avoid doing I/O or expensive computation while holding the lock.
  3. Using Pool: after fetching an object from Pool, always reset its state (clear slices, reset fields), because pooled objects may carry leftover data.
  4. Thread safety of Once: Once.Do is thread-safe and fits singleton initialization, but do not call Do again from inside the function passed to Do — it can deadlock.

Exercises

  1. Concept question: explain the performance difference between sync.Mutex and sync.RWMutex under concurrent reads, and the conditions under which each is appropriate.
  2. Coding exercise: write a program that uses WaitGroup to start 10 goroutines, each computing the sum from 1 to 100, then aggregates and prints the total.
  3. Advanced/debugging exercise: the following code has a race condition. Find the problem and fix it. ```go package main

import ( "fmt" "sync" )

var counter int var mu sync.Mutex

func increment(wg *sync.WaitGroup) { defer wg.Done() mu.Lock() counter++ mu.Unlock() }

func main() { var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go increment(&wg) } wg.Wait() fmt.Println("Counter:", counter) } ``` (Note: the code above looks correct, but consider whether there is a latent problem? Actually, the code above is correct. To make it "advanced", we provide a buggy version for learners to fix.)

The buggy version: ```go package main

import ( "fmt" "sync" )

var counter int var mu sync.Mutex

func increment(wg *sync.WaitGroup) { defer wg.Done() // Bug: Lock and Unlock are not paired, and the logic is wrong mu.Lock() counter++ // forgot Unlock, so later goroutines deadlock }

func main() { var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go increment(&wg) } wg.Wait() fmt.Println("Counter:", counter) } ```

Reference Answers

Reference Answers (click to expand / collapse)

Question 1

RWMutex lets multiple reader goroutines hold the read lock simultaneously, improving read concurrency; Mutex allows only one goroutine at a time. When reads greatly outnumber writes and the critical section runs for a measurable time, RWMutex significantly improves throughput. But if the read/write ratio is close or the critical section is very short, RWMutex's extra overhead can make it slower than Mutex.

Question 2

package main

import (
    "fmt"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    var total int
    var totalMu sync.Mutex

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            sum := 0
            for j := 1; j <= 100; j++ {
                sum += j
            }
            totalMu.Lock()
            total += sum
            totalMu.Unlock()
        }()
    }

    wg.Wait()
    fmt.Println("Total sum:", total)
}

Question 3

Problem analysis: the increment function calls mu.Lock() and increments the counter but forgets to call mu.Unlock(). After the first goroutine takes the lock, every other goroutine blocks forever at mu.Lock(), and wg.Wait() never returns — the program deadlocks.

Fixed code:

package main

import (
    "fmt"
    "sync"
)

var counter int
var mu sync.Mutex

func increment(wg *sync.WaitGroup) {
    defer wg.Done()
    mu.Lock()
    counter++
    mu.Unlock()
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go increment(&wg)
    }
    wg.Wait()
    fmt.Println("Counter:", counter)
}

Lesson Summary

  • Mutex and RWMutex are the core tools for protecting shared memory; choose by read/write ratio.
  • WaitGroup waits for a group of goroutines; watch the ordering of Add and Wait.
  • Once guarantees initialization runs exactly once and fits singletons.
  • Pool reuses objects to reduce GC pressure, but you must reset state after fetching.

Next lesson preview: context — timeouts, cancellation and request-scoped data propagation.

Feedback

This site is front-end only — no backend, no accounts — so feedback goes to GitHub Issues, Discussions, or email.

Paste it into the issue or email to help track it down — it never includes anything you typed

Also:Browse existing issues · Email feedback (no GitHub account needed):278975598@qq.com