Go并发-sync包四剑客:Mutex、RWMutex、WaitGroup、Once-从入门到原理

开篇:

Go 语言有一句经典名言:"Don't communicate by sharing memory; share memory by communicating."(不要通过共享内存来通信,而要通过通信来共享内存。)Channel 在 Go 的并发世界里确实占据了 C 位,但现实中我们不可能永远只用 Channel。

好比现实中的交通,Channel 像是规划好的单行道,大家按顺序走;而 sync 包提供的工具,更像是红绿灯、斑马线、闸机这些基础设施------看似不起眼,但没有它们,交通就会陷入混乱。

今天就来聊聊 sync 包里最常用的四个"基础设施":MutexRWMutexWaitGroupOnce。我将用通俗的语言讲清楚它们怎么用、为什么这么设计,以及底层到底发生了什么。


一、Mutex:一把"会思考的锁"

1.1 它是什么?

sync.Mutex 是 Go 中最基础的互斥锁。同一时刻,只有一个 goroutine 能持有它。其他想拿锁的 goroutine,只能在门外排队等着。

1.2 基本用法

go 复制代码
var mu sync.Mutex
var counter int

func increment() {
    mu.Lock()
    counter++
    mu.Unlock()
}

就是这么简单。Lock()Unlock() 成对出现,中间的代码就是临界区------同一时刻只有一个 goroutine 能进去。

1.3 底层长什么样?

sync.Mutex 的结构体非常小巧,只有两个字段:

go 复制代码
type Mutex struct {
    state int32   // 锁的状态(位图)
    sema  uint32  // 信号量
}

state 字段是一个 int32 整数,但通过位运算被拆成了多个"状态位":

  • Locked(第0位):1 表示锁被占用,0 表示空闲。
  • Woken(第1位):是否有被唤醒的 goroutine 正在尝试抢锁。
  • Starving(第2位):是否处于饥饿模式。
  • Waiters(其余位):有多少 goroutine 在排队等待。

一个 int32 能存这么多信息,全靠位运算。这也是 Go 源码里常见的"抠门"优化------能用 1 个 bit 绝不用 1 个 byte。

sema 字段 是一个信号量,负责在锁被占用时阻塞等待的 goroutine,在锁释放时唤醒它们。它通过 runtime_SemacquireMutexruntime_Semrelease 与 Go 运行时交互。

1.4 获取锁的过程:从乐观到悲观

当一个 goroutine 调用 Lock() 时,并不是直接去睡觉等锁。它有一套精密的策略:

第一步:乐观自旋

它会先猜测:"持有锁的那个家伙可能马上就释放了,我何必去睡觉(系统调用)呢?在 CPU 上转几圈等等吧。"

这个"转圈"就是自旋 ------执行大约 30 次 PAUSE 指令,空转消耗 CPU 但避免了昂贵的线程上下文切换。如果自旋期间锁被释放了,它就通过 CAS 原子操作直接抢到锁。这就是所谓的 Fast Path(快路径),性能极高。

第二步:信号量等待

如果自旋了好几次还没抢到,那就认怂了。goroutine 会调用 runtime_SemacquireMutex,把自己放入信号量队列,真正地休眠 。等到持有者 Unlock 时通过信号量把它唤醒。

这种"先自旋再休眠"的混合策略,让 Mutex 在竞争不激烈时非常高效,在竞争激烈时也不会让 CPU 空转到天荒地老。

1.5 正常模式 vs 饥饿模式:公平与效率的博弈

这是 Mutex 最精妙的设计之一。

正常模式 下,被唤醒的等待者需要和新来的 goroutine 一起竞争锁。但新来的 goroutine 本来就在 CPU 上运行,而被唤醒的需要上下文切换,所以新来的大概率抢到锁

这看起来不公平------老实排队的人可能永远抢不到。但好处是吞吐量极高,因为锁总是被正在运行的 goroutine 拿到,没有额外的调度开销。

饥饿模式 下,一旦某个 goroutine 等待超过 1 毫秒 ,Mutex 就会切换模式。此时新来的 goroutine 不再自旋,也不参与竞争,直接乖乖去队尾排队。锁的所有权会直接交接给队首的等待者。

什么时候切回正常模式?当获得锁的 goroutine 是队列中最后一个,或者它的等待时间不足 1ms 时。

简单说:正常模式追求吞吐,饥饿模式保证公平。Mutex 在这两者之间动态切换,像一个聪明的交通调度员------不堵车时让大家随便走,堵车时严格按顺序放行。

1.6 注意事项

  • Mutex 不支持可重入。同一个 goroutine 不能重复 Lock,否则会死锁。
  • 用完记得 Unlock ,最好配合 defer 使用。
  • Mutex 不能复制,复制后状态会错乱。go vet 会检测这个问题。

二、RWMutex:读多写少的场景利器

2.1 它是什么?

sync.RWMutex 是读写锁。它把锁分成了两种:

  • 读锁(RLock):多个 goroutine 可以同时持有。
  • 写锁(Lock) :只能有一个 goroutine 持有,且持有期间所有读锁和写锁都被阻塞

读多写少的场景下,RWMutex 能显著提升并发性能。

2.2 基本用法

go 复制代码
var rwmu sync.RWMutex
var data map[string]string

func read(key string) string {
    rwmu.RLock()
    defer rwmu.RUnlock()
    return data[key]
}

func write(key, value string) {
    rwmu.Lock()
    defer rwmu.Unlock()
    data[key] = value
}

2.3 底层实现

RWMutex 的结构体是这样的:

go 复制代码
type RWMutex struct {
    w           Mutex    // 复用互斥锁
    writerSem   uint32   // 写锁信号量
    readerSem   uint32   // 读锁信号量
    readerCount int32    // 读锁计数器
    readerWait  int32    // 写锁等待时需要等待的读锁数量
}

读锁的获取 :每次 RLock() 都会把 readerCount 加 1。如果发现 readerCount 变成负数,说明有写锁在等待,当前读锁需要阻塞。

写锁的获取 :先通过内部的 w.Lock() 获取互斥锁,然后把 readerCount 置为负数,告诉后来的读锁"有写锁在等了,你们别进来"。接着等待已经持有的读锁全部释放。

写优先策略 :一旦有写锁在等待,新来的读锁会被阻塞。这保证了写操作不会因为源源不断的读操作而"饿死"------写操作优先

2.4 注意事项

  • RWMutex 适合读多写少的场景。如果读写比例接近 1:1,普通 Mutex 可能反而更快(因为 RWMutex 的读锁也有额外开销)。
  • 读锁不能升级为写锁,否则会死锁。
  • 和 Mutex 一样,不能复制

三、WaitGroup:等待一群"人"干完活

3.1 它是什么?

sync.WaitGroup 就像一个计数器:主 goroutine 设置要等待的任务数量,每个任务完成后计数器减 1,当计数器归零时,所有等待的 goroutine 被唤醒。

3.2 基本用法

go 复制代码
var wg sync.WaitGroup

for i := 0; i < 10; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        // 干活...
    }(i)
}

wg.Wait()  // 阻塞直到所有 goroutine 完成

Add(1) 在启动 goroutine 之前 调用,Done() 在 goroutine 结束时调用(相当于 Add(-1)),Wait() 阻塞等待计数器归零。

3.3 底层实现

WaitGroup 的结构体很"狡猾":

go 复制代码
type WaitGroup struct {
    noCopy noCopy      // 防复制的空结构体
    state1 [3]uint32   // 12 字节的状态数组
}

为什么不用两个独立的字段,而用一个 [3]uint32 数组?

因为 64 位原子操作要求 64 位对齐 ,但 32 位编译器无法保证这一点。为了兼容 32 位和 64 位机器,Go 采用了这种"取巧"的方式------分配 12 字节,通过 state() 函数动态决定哪 8 字节作为状态、哪 4 字节作为信号量。

这 8 字节的状态又被分成了两部分:

  • 高 32 位:计数器(还有多少任务没完成)
  • 低 32 位 :等待者数量(有多少 goroutine 在 Wait() 上阻塞)

Add()Done() 对计数器进行原子操作,而不是用 Mutex 加锁,所以性能更好。

Add() 把计数器从 0 加到正数时,会释放所有在 Wait() 上阻塞的 goroutine。

3.4 那个"绝对不能复制"的秘密

WaitGroup 的文档明确写着:"A WaitGroup must not be copied after first use."

为什么?因为复制出来的 WaitGroup 和原来的共享同一个底层状态 吗?恰恰相反------如果复制,它们各有一套独立的状态,但信号量等内部资源却可能混乱,导致 Wait() 永远等不到、或者提前返回。

更关键的是,WaitGroup 内部有个 noCopy 字段。它本身是个空结构体,不占内存,但 go vet 会检查:任何包含 noCopy 的结构体被值传递时,会发出警告。

所以记住:传 WaitGroup 永远用指针

go 复制代码
// ❌ 错误
func doWork(wg sync.WaitGroup) { ... }

// ✅ 正确
func doWork(wg *sync.WaitGroup) { ... }

四、Once:只做一次,说到做到

4.1 它是什么?

sync.Once 保证传入的函数无论被调用多少次,都只执行一次

它和 init() 函数的区别在于:

  • init() 在包加载时执行。
  • Once.Do()第一次调用时 执行------也就是延迟初始化

4.2 基本用法------单例模式

go 复制代码
var once sync.Once
var instance *Singleton

func GetInstance() *Singleton {
    once.Do(func() {
        instance = &Singleton{}
    })
    return instance
}

就这么几行,一个并发安全的单例就搞定了。

4.3 为什么不用"双重检查锁"?

很多语言里实现单例要用"双重检查锁"(Double-Checked Locking):

go 复制代码
if instance == nil {
    mu.Lock()
    if instance == nil {
        instance = &Singleton{}
    }
    mu.Unlock()
}

但这段代码在 Go 里不是并发安全的 。因为 instance = &Singleton{} 这行可能被编译器重排------先分配内存赋值给 instance,再初始化字段。其他 goroutine 可能看到 instance != nil 但里面的字段还没初始化完成。

sync.Once 完美解决了这个问题。

4.4 底层实现:原子 + 互斥锁的完美配合

Once 的结构体只有两个字段:

go 复制代码
type Once struct {
    done uint32  // 标识是否已执行
    m    Mutex   // 互斥锁
}

Do() 方法的实现非常精妙:

go 复制代码
func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 0 {
        o.doSlow(f)
    }
}

func (o *Once) doSlow(f func()) {
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

关键设计思路

  1. 快速路径 :先用原子操作读取 done,如果已经是 1,直接返回。这是绝大多数情况,开销极小。
  2. 慢速路径 :如果 done == 0,进入 doSlow(),加锁后再次检查 done------防止多个 goroutine 同时进入。
  3. 延迟标记atomic.StoreUint32(&o.done, 1) 用了 defer,确保 f() 执行完成后才标记完成。

为什么要用 defer 延迟标记?因为如果先标记再执行 f(),万一 f() panic 了,done 已经是 1,这个 Once 就永远"完成"了,但实际上并没有。

这个设计告诉我们:状态变更要放在操作成功之后,否则错误状态会污染整个系统。

4.5 注意事项

  • Once.Do() 传入的函数是同步执行的,多个 goroutine 同时调用时会阻塞等待第一个执行完。
  • 如果 f() 中发生了 panic,Once 会认为没有执行成功,下次调用会重新执行。
  • Once 也不能复制(同样有 noCopy 字段)。

总结

工具 核心职责 底层关键词 一句话记住
Mutex 互斥访问 自旋 + CAS + 信号量 + 正常/饥饿模式 一把会思考的锁
RWMutex 读写分离 读计数器 + 写优先 读多写少用我
WaitGroup 等待任务完成 原子计数器 + 信号量 传我请用指针
Once 只执行一次 原子 done + Mutex 单例和延迟初始化

这四个工具的共同点是:都不可复制 (都有 noCopy 字段),都追求高性能 (大量使用原子操作而非 Mutex),都精妙地利用了底层的信号量机制

回到开头那句话------Channel 和 sync 包不是对手,而是队友。Channel 适合"传递数据",而 sync 包适合"保护状态"。什么时候用哪个?当你需要传递所有权时用 Channel,当你需要保护共享资源时用 sync。选对了工具,并发编程才能既安全又高效。

相关推荐
fulton1 小时前
为什么不用现成的开源工具?NovelOps与6大AI写作工具横向对比
后端
fulton1 小时前
3个月踩坑实录:从想法到270章规划,AI写长篇到底要花多少成本?
后端
fulton1 小时前
为什么AI写到20章就开始"复制粘贴"自己?创意扰动机制详解
后端
艺艺生辉1 小时前
从if-else到策略模式
后端·设计模式
fulton1 小时前
AI写小说失败的第一原因是什么
后端
fulton1 小时前
这段文字有AI味"到底怎么判断
后端
fulton1 小时前
为什么AI写的小说节奏总是崩
后端
fulton1 小时前
为什么AI写到30章角色就崩人设
后端
fulton1 小时前
AI写小说每章都要过安检?8级质量门禁详解
后端