尧图网站设计 尧图网站设计YAOTU DESIGN
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包里最常用的四个“基础设施”Mutex、RWMutex、WaitGroup和Once。我将用通俗的语言讲清楚它们怎么用、为什么这么设计以及底层到底发生了什么。一、Mutex一把“会思考的锁”1.1 它是什么sync.Mutex是 Go 中最基础的互斥锁。同一时刻只有一个 goroutine 能持有它。其他想拿锁的 goroutine只能在门外排队等着。1.2 基本用法varmu sync.Mutexvarcounterintfuncincrement(){mu.Lock()countermu.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;i10;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(){instanceSingleton{}})returninstance}就这么几行一个并发安全的单例就搞定了。4.3 为什么不用“双重检查锁”很多语言里实现单例要用“双重检查锁”Double-Checked Lockingifinstancenil{mu.Lock()ifinstancenil{instanceSingleton{}}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.done0{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()中发生了 panicOnce 会认为没有执行成功下次调用会重新执行。Once 也不能复制同样有noCopy字段。总结工具核心职责底层关键词一句话记住Mutex互斥访问自旋 CAS 信号量 正常/饥饿模式一把会思考的锁RWMutex读写分离读计数器 写优先读多写少用我WaitGroup等待任务完成原子计数器 信号量传我请用指针Once只执行一次原子 done Mutex单例和延迟初始化这四个工具的共同点是都不可复制都有noCopy字段都追求高性能大量使用原子操作而非 Mutex都精妙地利用了底层的信号量机制。回到开头那句话——Channel 和sync包不是对手而是队友。Channel 适合“传递数据”而sync包适合“保护状态”。什么时候用哪个当你需要传递所有权时用 Channel当你需要保护共享资源时用sync包。选对了工具并发编程才能既安全又高效。
返回列表