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
四、常见错误与避坑指南
-
Mutex 被复制使用 - 错误写法:
var mu sync.Mutex; mu2 := mu; mu2.Lock()- 原因:sync.Mutex包含内部状态,复制后会导致两个实例竞争同一把锁或状态混乱,引发未定义行为。 - 正确做法:始终传递指针或确保使用原始变量。 -
WaitGroup 的 Add 在 Wait 之后调用 - 错误写法:先调用
wg.Wait(),再在 goroutine 中调用wg.Add(1)。 - 原因:Wait会一直阻塞直到计数器为 0。如果在之后才Add,Wait永远不会返回,导致死锁。 - 正确做法:确保所有Add调用在Wait之前完成。 -
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处理并发写入。
五、最佳实践
- 锁的选择:如果数据主要是读取且临界区有一定长度,使用
RWMutex;否则优先使用Mutex。读占比低于 70% 时,RWMutex可能更慢。 - 锁的粒度:尽量缩小临界区,避免在持有锁时进行 I/O 或耗时计算。
- Pool 的使用:从
Pool获取对象后,务必重置其状态(如清空切片、重置字段),因为池中的对象可能残留旧数据。 - Once 的线程安全:
Once.Do是线程安全的,适合用于单例初始化,但避免在Do的函数中再次调用Do,可能导致死锁。
六、练习题
- 概念题:解释
sync.Mutex和sync.RWMutex在并发读场景下的性能差异及适用条件。 - 编码题:编写一个程序,使用
WaitGroup启动 10 个 goroutine,每个 goroutine 计算 1 到 100 的和,最后汇总所有结果并打印总和。 - 进阶/调试题:以下代码存在竞态条件,请找出问题并修复。 ```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 机制及其在并发通信中的应用。