sync 包
问题
Go 的 sync 包提供了哪些并发原语?Mutex 和 RWMutex 怎么选?sync.Map 适合什么场景?
答案
sync.Mutex——互斥锁
var mu sync.Mutex
var count int
func increment() {
mu.Lock()
defer mu.Unlock()
count++
}
Mutex 注意事项
- 零值可用(不需要初始化)
- 不可重入(同一 goroutine 重复 Lock 会死锁)
- 不可拷贝(传参/赋值用指针)
Lock()后必须Unlock(),推荐用defer
sync.RWMutex——读写锁
读写锁允许多个读者同时读,但写者独占:
var rw sync.RWMutex
var cache map[string]string
func get(key string) string {
rw.RLock() // 读锁(多个可同时持有)
defer rw.RUnlock()
return cache[key]
}
func set(key, val string) {
rw.Lock() // 写锁(独占)
defer rw.Unlock()
cache[key] = val
}
| 操作 | Mutex | RWMutex |
|---|---|---|
| 读-读 | 互斥 | 并发 ✅ |
| 读-写 | 互斥 | 互斥 |
| 写-写 | 互斥 | 互斥 |
适用场景:读多写少时用 RWMutex,否则用 Mutex(RWMutex 开销略高)。
sync.WaitGroup——等待组
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done() // 等价于 wg.Add(-1)
doWork(id)
}(i)
}
wg.Wait() // 阻塞直到计数器归零
WaitGroup 注意事项
Add必须在启动 goroutine 之前调用(不能在 goroutine 内部 Add)Add的总数必须和Done的总数一致- 计数器不能为负数(panic)
sync.Once——只执行一次
var once sync.Once
var instance *DB
func GetDB() *DB {
once.Do(func() {
instance = connectDB() // 只会执行一次,并发安全
})
return instance
}
sync.Once 保证:
- 函数只执行一次,即使多个 goroutine 并发调用
- 其他 goroutine 会等待第一次执行完成后才返回
- 如果函数 panic,
Once仍然认为已执行(不会重试)
sync.Map——并发安全 Map
var m sync.Map
// 存储
m.Store("key", "value")
// 读取
val, ok := m.Load("key")
// 读取或存储(key 不存在时存储)
actual, loaded := m.LoadOrStore("key", "default")
// 删除
m.Delete("key")
// 遍历
m.Range(func(key, value any) bool {
fmt.Println(key, value)
return true // 返回 false 停止遍历
})
// Go 1.20+:LoadAndDelete、Swap、CompareAndSwap、CompareAndDelete
sync.Map 适用场景(内部用两个 map + atomic 实现):
- 读多写少:key 一旦写入很少更新
- key 分区:不同 goroutine 操作不同 key
不适用场景(用 RWMutex + map 更好):
- 写操作频繁
- key 集合变化大
sync.Cond——条件变量
var mu sync.Mutex
cond := sync.NewCond(&mu)
queue := make([]int, 0)
// 生产者
go func() {
for i := 0; i < 10; i++ {
mu.Lock()
queue = append(queue, i)
cond.Signal() // 唤醒一个等待者
mu.Unlock()
}
}()
// 消费者
go func() {
for {
mu.Lock()
for len(queue) == 0 {
cond.Wait() // 等待信号(自动释放锁,收到信号后重新获取)
}
item := queue[0]
queue = queue[1:]
mu.Unlock()
process(item)
}
}()
| 方法 | 说明 |
|---|---|
Wait() | 释放锁,挂起等待,被唤醒后重新获取锁 |
Signal() | 唤醒一个等待者 |
Broadcast() | 唤醒所有等待者 |
实际开发中 Channel 通常比 Cond 更好用
sync.Cond 使用复杂且容易出错。大多数场景可以用 Channel 替代。Cond 的优势在于 Broadcast 唤醒所有等者,如果需要这个语义且频繁触发,Cond 比每次关闭 Channel 更高效。
sync.Pool——对象池
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func process() {
buf := bufPool.Get().(*bytes.Buffer) // 获取(可能复用旧对象)
defer func() {
buf.Reset()
bufPool.Put(buf) // 归还
}()
buf.WriteString("hello")
// 使用 buf...
}
sync.Pool 特点:
- 对象可能被 GC 清理(不是持久缓存)
- 用于减少高频临时对象的内存分配和 GC 压力
- 标准库中
fmt.Fprintf、encoding/json等大量使用
常见面试问题
Q1: Mutex 是可重入的吗?
答案:不是。Go 的 sync.Mutex 不是可重入锁(Recursive Lock)。同一个 goroutine 对已锁定的 Mutex 再次 Lock 会死锁。
mu.Lock()
mu.Lock() // 死锁!当前 goroutine 永久阻塞
Go 故意不提供可重入锁,因为:如果你需要重入,说明代码设计有问题——应该将锁的粒度调整为不需要重入。
Q2: RWMutex 的写锁饥饿问题?
答案:
Go 的 RWMutex 实现了写优先策略——当有写锁等待时,新的读锁请求会被阻塞,防止读锁饥饿写锁。
但如果读操作极其频繁,写锁仍然可能长时间等待。
Q3: WaitGroup 和 Channel 等待 goroutine 完成哪个好?
答案:
| 场景 | sync.WaitGroup | Channel |
|---|---|---|
| 只等待完成,不收集结果 | ✅ 简单直接 | 需要额外代码 |
| 需要收集每个 goroutine 的结果 | 需配合其他机制 | ✅ 自然支持 |
| 需要错误处理 | 用 errgroup | 可以 |
| 动态数量的 goroutine | ✅ | 可以 |
通常 WaitGroup 更简单,errgroup(基于 WaitGroup)更适合需要错误收集的场景。
Q4: sync.Pool 的对象什么时候被清理?
答案:
每次 GC 时,sync.Pool 中的对象可能被全部清理。Go 1.13 优化了策略——引入 victim cache,GC 时先移到 victim,下次 GC 才真正清理,给了第二次机会。
所以 Pool 不适合做缓存(随时可能丢失),只适合做临时对象复用。