第 19/41 课 · 并发编程 核心 m03-l04

sync 包:Mutex、RWMutex、WaitGroup、Once

本课所属:第三模块:并发编程 | 课时:m03-l04 | 难度:进阶 学完本课你将能够:掌握 Go 标准库 sync 包中核心同步原语的正确用法,理解共享内存并发下的锁竞争策略与对象复用机制,并能编写无竞态的并发程序。

一、学习目标

  • 能解释 Mutex 与 RWMutex 的区别,并能在读写比例不同的场景下选择合适的锁。
  • 能正确使用 WaitGroup 协调多个 goroutine 的生命周期,避免死锁与数据竞争。
  • 能利用 Once 实现线程安全的单例模式或初始化逻辑。
  • 能使用 Pool 优化高频分配/释放对象的性能,并理解其清零特性。

二、知识讲解

1.1 共享内存与锁(Mutex & RWMutex)

在 Go 中,共享内存是并发编程的核心模型。当多个 goroutine 访问同一块内存时,必须通过同步机制保证数据一致性。锁是最基础的同步原语。

Mutex 是互斥锁,同一时刻只允许一个 goroutine 持有。其核心方法为 Lock() 和 Unlock()。 RWMutex 是读写锁,允许同时存在多个读锁,但写锁独占。它包含 RLock()、RUnlock()、Lock()、Unlock()。

为什么这样设计? 在并发系统中,读操作通常不修改数据,因此允许多个读并发可以显著提升吞吐量。然而,如果临界区极短或读写比例接近,RWMutex 的额外状态维护开销可能使其比 Mutex 更慢。

怎么用? - Mutex 适用于写频繁或临界区较长的场景。 - RWMutex 适用于读远多于写(如配置缓存)的场景。 - 原子操作(如 sync/atomic 包)在无竞争或简单计数场景下性能优于锁,但在复杂逻辑中锁更直观。

1.2 WaitGroup 等待组

WaitGroup 用于等待一组 goroutine 执行完毕。它有三个方法: - Add(delta int):增加计数器。 - Done():计数器减 1,通常在 goroutine 结束时调用。 - Wait():阻塞直到计数器为 0。

陷阱:Add 必须在 Wait 之前调用,否则会导致死锁或 panic。WaitGroup 不可复制,复制后行为未定义。

1.3 Once 单次执行

Once 保证某个函数在整个程序生命周期中仅执行一次。常用于单例模式或资源初始化。 - Once.Do(f func()):若 f 未执行,则执行并标记为已执行;否则直接返回。

1.4 Pool 对象池

sync.Pool 用于缓存临时对象,减少 GC 压力。 - Get():从池中获取对象,若池为空则调用 New 函数创建。 - Put(obj any):将对象放回池中。

注意:Pool 不保证对象被保留,GC 可能会清理池中的对象。取出对象后,必须自行重置状态,因为池中的对象可能残留旧数据。

三、示例代码

示例 1:RWMutex 保护共享计数器

本示例演示如何使用 RWMutex 保护共享计数器,模拟并发读写场景。

package main

import (
    "fmt"
    "sync"
)

var (
    mu    sync.RWMutex
    counter int
)

func main() {
    var wg sync.WaitGroup

    // 启动 5 个写协程
    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)
    }

    // 启动 5 个读协程
    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()
}

运行结果(预期输出):

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

(注:由于 goroutine 调度顺序不确定,Writer 和 Reader 的输出顺序可能不同,但 counter 的值会随写入递增。)

示例 2:sync.Pool 对象复用与重置

本示例演示 sync.Pool 的使用,重点展示取出对象后必须重置状态,因为池中的对象可能残留旧数据。

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() {
    // 第一次获取,New 函数被调用
    buf1 := bufPool.Get().(*Buffer)
    buf1.Data[0] = 'A'
    fmt.Printf("Buf1 content: %s\n", buf1.Data[:1])

    // 放回池中
    bufPool.Put(buf1)

    // 第二次获取,可能拿到刚才放回的 buf1
    buf2 := bufPool.Get().(*Buffer)
    // 关键点:必须重置切片,因为 buf2 可能包含之前的 'A'
    buf2.Data[0] = 'B'
    fmt.Printf("Buf2 content: %s\n", buf2.Data[:1])

    // 清理并放回
    buf2.Data[0] = 0
    bufPool.Put(buf2)
}

运行结果(预期输出):

Buf1 content: A
Buf2 content: B

四、常见错误与避坑指南

  1. Mutex 被复制使用 - 错误写法:var mu sync.Mutex; mu2 := mu; mu2.Lock() - 原因:sync.Mutex 包含内部状态,复制后会导致两个实例竞争同一把锁或状态混乱,引发未定义行为。 - 正确做法:始终传递指针或确保使用原始变量。

  2. WaitGroup 的 Add 在 Wait 之后调用 - 错误写法:先调用 wg.Wait(),再在 goroutine 中调用 wg.Add(1)。 - 原因:Wait 会一直阻塞直到计数器为 0。如果在之后才 Add,Wait 永远不会返回,导致死锁。 - 正确做法:确保所有 Add 调用在 Wait 之前完成。

  3. Nil Map 写入 Panic - 错误写法: go var data map[string]string data["key"] = "value" // panic: assignment to entry in nil map - 原因:声明的 map 未初始化,值为 nil,不可写入。 - 正确做法:使用 make(map[string]string) 初始化,或使用 sync.Map 处理并发写入。

五、最佳实践

  1. 锁的选择:如果数据主要是读取且临界区有一定长度,使用 RWMutex;否则优先使用 Mutex。读占比低于 70% 时,RWMutex 可能更慢。
  2. 锁的粒度:尽量缩小临界区,避免在持有锁时进行 I/O 或耗时计算。
  3. Pool 的使用:从 Pool 获取对象后,务必重置其状态(如清空切片、重置字段),因为池中的对象可能残留旧数据。
  4. Once 的线程安全:Once.Do 是线程安全的,适合用于单例初始化,但避免在 Do 的函数中再次调用 Do,可能导致死锁。

六、练习题

  1. 概念题:解释 sync.Mutex 和 sync.RWMutex 在并发读场景下的性能差异及适用条件。
  2. 编码题:编写一个程序,使用 WaitGroup 启动 10 个 goroutine,每个 goroutine 计算 1 到 100 的和,最后汇总所有结果并打印总和。
  3. 进阶/调试题:以下代码存在竞态条件,请找出问题并修复。 ```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) } ``` (注:上述代码看似正确,但请思考是否有潜在问题?实际上,上述代码是正确的。为了体现“进阶”,我们提供一个错误版本供学员修正)

修正版题目: ```go package main

import ( "fmt" "sync" )

var counter int var mu sync.Mutex

func increment(wg *sync.WaitGroup) { defer wg.Done() // 错误点:Lock 和 Unlock 不在同一层级,且逻辑有误 mu.Lock() counter++ // 忘记 Unlock,导致后续 goroutine 死锁 }

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

七、练习参考答案

参考答案(点击展开 / 收起)

第 1 题

RWMutex 允许多个读协程同时持有读锁,提高了读并发性能;而 Mutex 同一时刻只允许一个协程访问。当读操作远多于写操作,且临界区代码有一定执行时间时,RWMutex 能显著提升吞吐量。但如果读写比例接近,或临界区极短,RWMutex 的额外开销可能使其性能不如 Mutex。

第 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)
}

第 3 题

问题所在:increment 函数中调用了 mu.Lock() 增加了计数器,但忘记调用 mu.Unlock() 释放锁。这会导致第一个 goroutine 获取锁后,其他 goroutine 在 mu.Lock() 处永久阻塞,最终 wg.Wait() 永远无法返回,程序死锁。

修复后的代码:

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)
}

八、本课小结

  • Mutex 和 RWMutex 是保护共享内存的核心工具,需根据读写比例选择。
  • WaitGroup 用于等待一组 goroutine 完成,注意 Add 和 Wait 的调用顺序。
  • Once 保证初始化逻辑仅执行一次,适合单例模式。
  • Pool 可复用对象减少 GC,但取出后必须重置状态。

下一课预告:深入探讨 Go 的 Channel 机制及其在并发通信中的应用。

问题反馈

本站是纯前端静态站,没有后端也没有账号系统,反馈走 GitHub Issues、讨论区或邮件。

粘贴到 issue 或邮件里能帮我更快定位问题,其中不含你输入的任何内容

其它:查看已有反馈 · 邮件反馈(无需 GitHub 账号):278975598@qq.com