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 因维护读计数器反而更慢。
Mutex vs RWMutex 并发访问模型 sync.Mutex 写锁(独占) G1 等待 G2 等待 G3 等待 所有 goroutine 串行执行 sync.RWMutex 读锁 G1(并发) 读锁 G2(并发) 读锁 G3(并发) 写锁(等待所有读锁释放) 读操作并行,写操作独占

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() }),防止虚假唤醒。条件变量必须与互斥锁配合使用,不能单独存在。
sync.Cond 生产者-消费者通知流程 生产者 Push(task) sync.Cond Signal() / Broadcast() 消费者 Pop() → Wait() 等待队列 Goroutine A Goroutine B Goroutine C Push 后 Signal() 唤醒一个等待的消费者 Pop 时队列为空 自动 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 控制并发度,避免过度竞争导致上下文切换开销超过计算收益。