Go并发-sync包四剑客:Mutex、RWMutex、WaitGroup、Once-从入门到原理
开篇:
Go 语言有一句经典名言:“Don’t communicate by sharing memory; share memory by communicating.”(不要通过共享内存来通信,而要通过通信来共享内存。)Channel 在 Go 的并发世界里确实占据了 C 位,但现实中我们不可能永远只用 Channel。
好比现实中的交通,Channel 像是规划好的单行道,大家按顺序走;而sync包提供的工具,更像是红绿灯、斑马线、闸机这些基础设施——看似不起眼,但没有它们,交通就会陷入混乱。
今天就来聊聊sync包里最常用的四个“基础设施”:Mutex、RWMutex、WaitGroup和Once。我将用通俗的语言讲清楚它们怎么用、为什么这么设计,以及底层到底发生了什么。
一、Mutex:一把“会思考的锁”
1.1 它是什么?
sync.Mutex是 Go 中最基础的互斥锁。同一时刻,只有一个 goroutine 能持有它。其他想拿锁的 goroutine,只能在门外排队等着。
1.2 基本用法
varmu sync.Mutexvarcounterintfuncincrement(){mu.Lock()counter++mu.Unlock()}就是这么简单。Lock()和Unlock()成对出现,中间的代码就是临界区——同一时刻只有一个 goroutine 能进去。
1.3 底层长什么样?
sync.Mutex的结构体非常小巧,只有两个字段:
typeMutexstruct{stateint32// 锁的状态(位图)semauint32// 信号量}state 字段是一个 int32 整数,但通过位运算被拆成了多个“状态位”:
- Locked(第0位):1 表示锁被占用,0 表示空闲。
- Woken(第1位):是否有被唤醒的 goroutine 正在尝试抢锁。
- Starving(第2位):是否处于饥饿模式。
- Waiters(其余位):有多少 goroutine 在排队等待。
一个 int32 能存这么多信息,全靠位运算。这也是 Go 源码里常见的“抠门”优化——能用 1 个 bit 绝不用 1 个 byte。
sema 字段是一个信号量,负责在锁被占用时阻塞等待的 goroutine,在锁释放时唤醒它们。它通过runtime_SemacquireMutex和runtime_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 基本用法
varrwmu sync.RWMutexvardatamap[string]stringfuncread(keystring)string{rwmu.RLock()deferrwmu.RUnlock()returndata[key]}funcwrite(key,valuestring){rwmu.Lock()deferrwmu.Unlock()data[key]=value}2.3 底层实现
RWMutex 的结构体是这样的:
typeRWMutexstruct{w Mutex// 复用互斥锁writerSemuint32// 写锁信号量readerSemuint32// 读锁信号量readerCountint32// 读锁计数器readerWaitint32// 写锁等待时需要等待的读锁数量}读锁的获取:每次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 基本用法
varwg sync.WaitGroupfori:=0;i<10;i++{wg.Add(1)gofunc(idint){deferwg.Done()// 干活...}(i)}wg.Wait()// 阻塞直到所有 goroutine 完成Add(1)在启动 goroutine之前调用,Done()在 goroutine 结束时调用(相当于Add(-1)),Wait()阻塞等待计数器归零。
3.3 底层实现
WaitGroup 的结构体很“狡猾”:
typeWaitGroupstruct{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 永远用指针。
// ❌ 错误funcdoWork(wg sync.WaitGroup){...}// ✅ 正确funcdoWork(wg*sync.WaitGroup){...}四、Once:只做一次,说到做到
4.1 它是什么?
sync.Once保证传入的函数无论被调用多少次,都只执行一次。
它和init()函数的区别在于:
init()在包加载时执行。Once.Do()在第一次调用时执行——也就是延迟初始化。
4.2 基本用法——单例模式
varonce sync.Oncevarinstance*SingletonfuncGetInstance()*Singleton{once.Do(func(){instance=&Singleton{}})returninstance}就这么几行,一个并发安全的单例就搞定了。
4.3 为什么不用“双重检查锁”?
很多语言里实现单例要用“双重检查锁”(Double-Checked Locking):
ifinstance==nil{mu.Lock()ifinstance==nil{instance=&Singleton{}}mu.Unlock()}但这段代码在 Go 里不是并发安全的。因为instance = &Singleton{}这行可能被编译器重排——先分配内存赋值给instance,再初始化字段。其他 goroutine 可能看到instance != nil但里面的字段还没初始化完成。
sync.Once完美解决了这个问题。
4.4 底层实现:原子 + 互斥锁的完美配合
Once 的结构体只有两个字段:
typeOncestruct{doneuint32// 标识是否已执行m Mutex// 互斥锁}Do()方法的实现非常精妙:
func(o*Once)Do(ffunc()){ifatomic.LoadUint32(&o.done)==0{o.doSlow(f)}}func(o*Once)doSlow(ffunc()){o.m.Lock()defero.m.Unlock()ifo.done==0{deferatomic.StoreUint32(&o.done,1)f()}}关键设计思路:
- 快速路径:先用原子操作读取
done,如果已经是 1,直接返回。这是绝大多数情况,开销极小。 - 慢速路径:如果
done == 0,进入doSlow(),加锁后再次检查done——防止多个 goroutine 同时进入。 - 延迟标记:
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包。选对了工具,并发编程才能既安全又高效。