三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

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

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 基本用法

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_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 基本用法

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()}}

关键设计思路

  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。选对了工具,并发编程才能既安全又高效。

← 返回列表