4. 同步原语:sync.Mutex 与 RWMutex、sync.WaitGroup、sync.Once、sync.Cond 的使用场景
在 Go 并发编程中,同步原语是协调 goroutine 访问共享资源、控制执行顺序的核心工具。标准库 sync 包提供了互斥锁、读写锁、等待组、一次性执行和条件变量等原语。本章深入剖析每种原语的设计意图、适用场景及典型陷阱,帮助你在容器化高并发服务中做出正确的同步决策。
4.1 互斥锁 sync.Mutex
sync.Mutex 是最基础的同步原语,保证同一时刻只有一个 goroutine 进入临界区。它适用于写多读少或读写比例不确定的场景。
4.1.1 基本用法与性能特征
| 特性 | 说明 |
|---|---|
| 锁定方式 | Lock() 获取锁,Unlock() 释放锁 |
| 公平性 | Go 1.8+ 采用公平锁模式,等待队列按 FIFO 唤醒 |
| 重入性 | 不可重入,同一 goroutine 再次 Lock() 会导致死锁 |
| 性能开销 | 无竞争时约 25ns,高竞争下随等待 goroutine 数量线性增长 |
// 典型用法:保护共享计数器
var (
counter int
mu sync.Mutex
)
func increment() {
mu.Lock()
counter++ // 临界区
mu.Unlock()
}
常见陷阱:忘记调用
Unlock() 会导致永久阻塞;Lock() 与 Unlock() 不成对出现时,建议使用 defer mu.Unlock() 确保释放。
4.2 读写锁 sync.RWMutex
sync.RWMutex 在互斥锁基础上增加了读共享能力:多个读操作可以同时持有读锁,写操作必须独占所有锁。它适用于读多写少的典型场景,如配置缓存、路由表、统计计数器。
4.2.1 三种锁定模式对比
| 操作 | 方法 | 并发读 | 并发写 | 适用场景 |
|---|---|---|---|---|
| 读锁 | RLock() / RUnlock() |
✅ 允许 | ❌ 阻塞 | 只读查询、缓存读取 |
| 写锁 | Lock() / Unlock() |
❌ 阻塞 | ❌ 阻塞 | 数据更新、配置变更 |
| 升级锁 | 不支持 | — | — | 读锁不能直接升级为写锁 |
// 读多写少场景:安全缓存
type SafeCache struct {
mu sync.RWMutex
items map[string]interface{}
}
func (c *SafeCache) Get(key string) interface{} {
c.mu.RLock()
defer c.mu.RUnlock()
return c.items[key]
}
func (c *SafeCache) Set(key string, val interface{}) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = val
}
性能提示:当读操作占比超过 80% 时,RWMutex 比 Mutex 吞吐量高 3~5 倍;但写操作频繁时,RWMutex 因维护读计数器反而更慢。
4.3 等待组 sync.WaitGroup
sync.WaitGroup 用于等待一组 goroutine 全部完成。它是主 goroutine 等待子任务的标准模式,在批处理、并发 RPC 调用、并行计算中广泛使用。
4.3.1 核心方法
| 方法 | 作用 | 典型调用方 |
|---|---|---|
Add(delta int) |
增加计数器,通常在启动 goroutine 前调用 | 主 goroutine |
Done() |
计数器减 1,等价于 Add(-1) |
子 goroutine |
Wait() |
阻塞直到计数器归零 | 主 goroutine |
// 并发下载多个资源
func fetchAll(urls []string) []Result {
var wg sync.WaitGroup
results := make([]Result, len(urls))
for i, url := range urls {
wg.Add(1)
go func(idx int, u string) {
defer wg.Done()
results[idx] = download(u)
}(i, url)
}
wg.Wait()
return results
}
关键规则:
Add() 必须在 Wait() 之前调用;Done() 调用次数必须等于 Add() 累加值,否则 Wait() 会永久阻塞或提前返回。
4.4 一次性执行 sync.Once
sync.Once 保证某个函数在整个程序生命周期中只执行一次,即使被多个 goroutine 同时调用。它常用于单例模式、延迟初始化、全局配置加载。
4.4.1 实现原理与使用模式
sync.Once 内部通过原子操作和互斥锁组合实现:先检查标志位,若未执行则加锁执行,执行完成后设置标志位。后续调用直接返回。
// 线程安全的单例模式
var (
instance *Service
once sync.Once
)
func GetService() *Service {
once.Do(func() {
instance = &Service{
config: loadConfig(),
pool: newConnectionPool(),
}
})
return instance
}
注意:
once.Do() 传入的函数如果 panic,sync.Once 会认为执行完成,后续调用不会再执行。需要确保函数不会 panic 或使用 recover 保护。
4.5 条件变量 sync.Cond
sync.Cond 用于 goroutine 之间的事件通知,让一个或多个 goroutine 等待某个条件成立,然后被唤醒。它比轮询 + 睡眠更高效,适用于生产者-消费者、任务队列、资源池等场景。
4.5.1 核心方法
| 方法 | 行为 | 说明 |
|---|---|---|
Wait() |
自动释放关联锁,挂起 goroutine,被唤醒后重新获取锁 | 必须在锁保护下调用 |
Signal() |
唤醒一个等待的 goroutine | 适用于只有一个消费者能处理 |
Broadcast() |
唤醒所有等待的 goroutine | 适用于条件变化影响所有等待者 |
// 条件变量实现任务队列
type TaskQueue struct {
mu sync.Mutex
cond *sync.Cond
queue []Task
}
func NewTaskQueue() *TaskQueue {
q := &TaskQueue{}
q.cond = sync.NewCond(&q.mu)
return q
}
func (q *TaskQueue) Push(task Task) {
q.mu.Lock()
q.queue = append(q.queue, task)
q.cond.Signal() // 通知一个消费者
q.mu.Unlock()
}
func (q *TaskQueue) Pop() Task {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.queue) == 0 {
q.cond.Wait() // 等待任务
}
task := q.queue[0]
q.queue = q.queue[1:]
return task
}
设计要点:
Wait() 必须在循环中调用(for !condition { cond.Wait() }),防止虚假唤醒。条件变量必须与互斥锁配合使用,不能单独存在。
4.6 同步原语选择决策表
在实际容器化服务中,选择合适的同步原语直接影响系统吞吐量和延迟。以下决策表帮助你快速匹配场景:
| 场景 | 推荐原语 | 原因 |
|---|---|---|
| 保护共享变量,读写比例未知 | sync.Mutex |
实现简单,无读锁维护开销 |
| 读操作远多于写操作(≥80%) | sync.RWMutex |
读并发提升吞吐量 |
| 等待一组 goroutine 完成 | sync.WaitGroup |
专为等待语义设计 |
| 全局单例初始化 | sync.Once |
保证只执行一次,线程安全 |
| goroutine 等待特定条件 | sync.Cond |
避免轮询,精确唤醒 |
| 简单的一次性信号 | chan struct{} |
channel 更轻量,无需锁 |
容器化部署建议:在 Kubernetes Pod 中,goroutine 数量受 CPU 限制影响。使用
sync.WaitGroup 时,建议结合 runtime.GOMAXPROCS 控制并发度,避免过度竞争导致上下文切换开销超过计算收益。