万字详解|Go 进阶(五):sync 包底层:Mutex 自旋 4 次转饥饿,WaitGroup 漏 1 个 Done 挂起 3000 goroutine
你好,我是利威尔xu。
这是《Go 进阶》系列的第五篇。前四篇我们把 GMP 调度器、GC 三色标记、内存分配器、channel 底层挨个拆了一遍------这篇轮到并发编程的另一半江山:sync 包 。channel 负责"传话",sync 负责"站岗",线上 90% 的并发事故,案发现场都能在 sync 里找到同伙。
这篇文章适合谁:
- 天天用
sync.Mutex,但从没读过runtime/sema.go,不知道锁还有"饥饿"这种状态的; - 线上遇到过 goroutine 只涨不降、pprof 拉起来一片
semacquire,看不出到底谁没放锁的; - 正在准备 Go 后端面试,被问「Mutex 的饥饿模式是什么」只会答"等太久了会抢"的。
先把结论放在这,后文逐个给源码和案例验证:
sync 的坑,八成出在"你以为它原子"。Mutex 的自旋和饥饿、WaitGroup 的两条 panic 红线、RWMutex 的读者优先------每一处精妙设计的背后,都对应一种固定的踩坑姿势。理解了状态机,90% 的并发挂起看一眼 pprof 就能定位。
本文所有源码基于 Go 1.25.4 (
runtime/sema.go、sync/mutex.go、sync/waitgroup.go、sync/pool.go、sync/map.go),不同版本行号会有漂移,但结构和流程近几年没动过大手术。文中涉及的测试代码都可以直接跑,结论随机器会有波动,别背数字,背方法。第六节附了一张 sync 类故障速查表和 30 秒定位清单,排查 goroutine 泄漏时直接翻------建议先收藏这篇,半夜被告警叫起来的时候你不会想从头推原理。
文章目录
- [万字详解|Go 进阶(五):sync 包底层:Mutex 自旋 4 次转饥饿,WaitGroup 漏 1 个 Done 挂起 3000 goroutine](#万字详解|Go 进阶(五):sync 包底层:Mutex 自旋 4 次转饥饿,WaitGroup 漏 1 个 Done 挂起 3000 goroutine)
-
- [一、先说事故:一次漏掉的 Done,挂起 3000 个 goroutine](#一、先说事故:一次漏掉的 Done,挂起 3000 个 goroutine)
- [二、Mutex:一把锁的四种状态,和 4 次自旋的耐心](#二、Mutex:一把锁的四种状态,和 4 次自旋的耐心)
-
- [2.1 先看 state 的位域:一个整数装下四种信息](#2.1 先看 state 的位域:一个整数装下四种信息)
- [2.2 Lock 的快路径:一次 CAS 定生死](#2.2 Lock 的快路径:一次 CAS 定生死)
- [2.3 慢路径:自旋 4 次的耐心,和 1 毫秒的暴怒](#2.3 慢路径:自旋 4 次的耐心,和 1 毫秒的暴怒)
- [2.4 Unlock:放锁也是一门学问](#2.4 Unlock:放锁也是一门学问)
- [2.5 一段能跑的代码:把状态机演出来](#2.5 一段能跑的代码:把状态机演出来)
- 三、RWMutex:读者优先的代价,写者怎么活
-
- [3.1 readerCount 的负数戏法](#3.1 readerCount 的负数戏法)
- [3.2 谁饿死谁:一个经典的权衡](#3.2 谁饿死谁:一个经典的权衡)
- [四、WaitGroup:三步语义与两条 panic 红线](#四、WaitGroup:三步语义与两条 panic 红线)
-
- [4.1 状态合并:一个 uint64 装下两个计数器](#4.1 状态合并:一个 uint64 装下两个计数器)
- [4.2 两个真实的坑](#4.2 两个真实的坑)
- [4.3 Go 1.25 的新玩具:WaitGroup.Go](#4.3 Go 1.25 的新玩具:WaitGroup.Go)
- [五、Once / Pool / Map:三个"用对了就没事"的组件](#五、Once / Pool / Map:三个"用对了就没事"的组件)
-
- [5.1 Once:双检锁的官方实现](#5.1 Once:双检锁的官方实现)
- [5.2 Pool:不是缓存,是"借还中心"](#5.2 Pool:不是缓存,是"借还中心")
- [5.3 Map:读多写极少时的替代品](#5.3 Map:读多写极少时的替代品)
- [六、可复用资产:sync 类故障速查表 + 30 秒定位清单](#六、可复用资产:sync 类故障速查表 + 30 秒定位清单)
-
- [6.1 sync 类故障速查表](#6.1 sync 类故障速查表)
- [6.2 30 秒定位清单](#6.2 30 秒定位清单)
- [6.3 一段可直接抄的"防呆"模板](#6.3 一段可直接抄的"防呆"模板)
- 七、本章小结:三句话带走
一、先说事故:一次漏掉的 Done,挂起 3000 个 goroutine
先讲个我排查过的真实故障(细节做了脱敏)。一个订单导出服务,凌晨批量任务跑起来后,监控告警 goroutine 数从常态 200 飙到 3000+,而且只涨不降。拉 pprof 一看,阻塞栈清一色长这样:
text
goroutine 2847 [semacquire, 2 minutes]:
sync.runtime_Semacquire(0x00c0001a0090)
sync.(*WaitGroup).Wait(0x00c0001a0090?)
main.exportOrders.func2()
...
WaitGroup.Wait 挂住,说明计数器永远归不了零。翻代码,问题出在一个"看起来很安全"的写法上:
go
var wg sync.WaitGroup
for _, o := range orders { // 3 万单
wg.Add(1)
go func(o Order) {
defer wg.Done()
if err := exportOne(o); err != nil {
log.Printf("export %s failed: %v", o.ID, err)
return
}
if err := sendCoupon(o); err != nil {
log.Printf("coupon %s failed: %v", o.ID, err)
return
}
}(o)
}
wg.Wait()
这段代码看着挑不出毛病。真实的线上版本比它多一个"凌晨补救"逻辑:有人后来加了个手动 wg.Done() 想提前放行失败的单,又忘了 defer 那份还在------一个 goroutine 被 Done 了两次,另一个从头到尾没被 Done。计数器先跌成负数触发 panic,被外层的 recover 吞掉后,负的计数让 Wait 永远等不到零。
3000 个 goroutine 挂在 Wait 上,每个都攥着自己那份订单数据不撒手------内存跟着一起飙。这就是 sync 包的典型事故形态:不是锁竞争,是账算错了。
WaitGroup 的比喻很好懂:团建签到表 。人来一个签一个(Add),签完到划掉一个(Done),组织者(Wait)等签到数归零才开饭。少签一个人不划,全团饿肚子;多划一个,表直接撕了(panic)。第四、五节我们会把 Add/Done/Wait 的状态机拆开,先看 sync 家族里最常被面试问的大哥------Mutex。
面试追问 :
WaitGroup的计数器为什么用Add而不是在go语句前直接Add(1)就完事?→ 因为批量场景要一次Add(n),减少一次原子操作只是次要收益,真正的原因是**「等所有 goroutine 都创建完再 Wait」这个顺序保证**:Add传正数可以和 goroutine 创建并发,Wait必须看到全部 Add。
二、Mutex:一把锁的四种状态,和 4 次自旋的耐心
Mutex 是 sync 包里使用频率最高的组件,也是面试问得最深的。它的全部秘密藏在一个 32 位的整数里。
2.1 先看 state 的位域:一个整数装下四种信息
打开 runtime/sema.go,Mutex 的状态常量长这样:
go
// runtime/sema.go(Go 1.25.4)
const (
mutexLocked = 1 << iota // 1:锁已被持有
mutexWoken // 2:有 goroutine 被叫醒,正在来抢锁的路上
mutexStarving // 4:锁处于饥饿模式
mutexWaiterShift // 3:等待者数量从第 3 位开始存
)
一个 uint32 被切成三份:最低位记"锁没锁",第二位记"有人被叫醒",第三位记"饿不饿",剩下 29 位记"排队的队有多长" 。这种位压缩不是炫技------Mutex 的生命周期里状态要反复读写,一个原子操作能搞定的事,绝不拆成两个字段两个原子操作。
锁本身的定义极其朴素:
go
// sync/mutex.go
type Mutex struct {
state int32
sema uint32
}
就两个字段:state 是上面那张状态字,sema 是信号量句柄,睡眠和唤醒全靠它。注意 Mutex 零值即可用------state=0 天然表示"没锁、没人等、不饿"。
2.2 Lock 的快路径:一次 CAS 定生死
Lock 的第一段是快路径,competition-free 的场景下整个加锁就是一次原子操作:
go
// sync/mutex.go(结构,细节有省略)
func (m *Mutex) Lock() {
// 快路径:锁是空闲的,CAS 抢到手就走
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
return
}
// 慢路径:有人持锁,或者有人排队,或者锁饿了
m.lockSlow()
}
这段代码值得逐字看:CompareAndSwapInt32(&m.state, 0, mutexLocked) 的意思是"如果 state 此刻是 0(完全空闲),就原子地把它改成 1 "。为什么是 0 而不是"没有 locked 位"?因为 state=0 意味着"没人持锁、没人被叫醒、不饥饿、队空"------这是最完美的状态,直接进场,一秒都不耽搁。这就是无竞争时 Mutex 只要几纳秒的原因:绝大多数锁竞争,死在这行 CAS 之前。
2.3 慢路径:自旋 4 次的耐心,和 1 毫秒的暴怒
CAS 失败,说明锁被人占着,进入 lockSlow。这里有两个反直觉的设计,是面试的重灾区。
第一个:不是上来就睡,而是先自旋。
lockSlow 里有一段自旋逻辑,条件大致是:多核(GOMAXPROCS > 1)、排队人数还不多、P 没满、自旋次数没超过 4 次 (active_spin = 4,runtime/proc.go)。自旋就是"跑步机上空跑"------CPU 空转耗着,等持锁的人释放。
为什么不直接睡?因为睡眠贵 。一次 semacquire 要把 goroutine 挂到等待队列、让出 P、触发调度,短临界区场景下,等你睡下再醒来,锁早放了,白折腾一趟。自旋是用 CPU 时间换调度开销------赌的是"持锁的人几纳秒就放"。
但耐心是有限的:自旋 4 次还抢不到,老实睡觉。4 这个数字是运行时多年调出来的平衡点,不是玄学。
第二个:等太久,锁会"饿",整个切换模式。
自旋完还是抢不到,goroutine 就把自己的等待者计数 +1,睡死在 sema 上。醒来时如果发现------自己等的时间超过了 1 毫秒 (starvationThresholdNs = 1e6,runtime/sema.go)------它不抢了,直接把锁切成饥饿模式 (mutexStarving 位置 1)。
饥饿模式下的规矩全变了:
- 正常模式(默认):后来者可以跟等待者抢,抢到就走,效率高,但队尾的人可能一直被插队;
- 饥饿模式 :锁释放时直接交接给队首等待者(FIFO),后来者连 CAS 都不许试,只能乖乖排队。直到队首拿到锁、且它的等待时间短于 1 毫秒,锁才切回正常模式。
为什么要有饥饿模式?因为正常模式的"可抢"在高竞争下会饿死人 。想象 10000 个 goroutine 抢一把锁,持锁时间又短,队尾的 goroutine 每次醒来都发现"锁又被新人抢了"------它可能永远排不到。饥饿模式用牺牲吞吐的方式保公平:极端竞争下,"人人有锁拿"比"整体最快"重要。
面试追问:自旋为什么要求多核?→ 单核下自旋纯属浪费------持锁的 goroutine 跑在唯一的核上,你一自旋它反而没机会跑,死锁到 4 次超时才睡。多核下持锁者可能在别的核上,自旋才等得来。
2.4 Unlock:放锁也是一门学问
放锁同样分两条路,判据就一条:有没有人在饿肚子。
go
// sync/mutex.go(结构示意)
func (m *Mutex) Unlock() {
// 快路径:没人等,清掉 locked 位就走
new := atomic.AddInt32(&m.state, -mutexLocked)
if new != 0 {
// 有人等(或者处于饥饿/唤醒中),走慢路径
m.unlockSlow(new)
}
}
unlockSlow 里的决策树值得记住:
| 现场 | 动作 |
|---|---|
| 正常模式 + 有等待者 | 唤醒队首,但不切所有权 ------被叫醒的 goroutine 要和所有后来者重新竞争(mutexWoken 置位就是告诉后来者"有人正在赶来,你抢可以,但想想清楚") |
| 饥饿模式 + 有等待者 | 直接把锁交接给队首,mutexLocked 和队首的等待计数一起转过去,后来者无感 |
| 没等待者 | 清状态,收工 |
看明白这张表,你就看明白了 Mutex 的灵魂:它不是在"公平"和"效率"之间选边站,而是用饥饿阈值(1ms)做动态切换。日常低竞争走效率优先,打疯了自动切公平优先。
2.5 一段能跑的代码:把状态机演出来
光看源码不过瘾,下面这段小程序能让你"看见"等待者的堆积和饥饿切换(需要 go run -race 一起跑,观察 sync 内部状态可以用 runtime.SetBlockProfileRate 配合 pprof):
go
// mutex_demo.go ------ Go 1.25.4 可跑
package main
import (
"fmt"
"runtime"
"sync"
"time"
)
func main() {
var mu sync.Mutex
var wg sync.WaitGroup
start := time.Now()
// 10000 个 goroutine 抢一把锁,临界区极短,模拟高竞争
for i := 0; i < 10000; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
mu.Lock()
// 临界区:短到几乎是空的,逼出"可抢"行为
_ = id
mu.Unlock()
}(i)
}
wg.Wait()
fmt.Printf("10000 个 goroutine 抢锁完成,耗时 %v\n", time.Since(start))
fmt.Printf("当前 goroutine 数 = %d(应回落到接近 1)\n", runtime.NumGoroutine())
}
跑完关注两个数:总耗时 和结束后 goroutine 数 。如果 Wait 之后 goroutine 数回不到 1,说明有 goroutine 没退干净------排查思路和第一节的事故一模一样。至于耗时本身,不同机器差别很大,别拿来当基准,它的作用是让你对"10000 抢 1"的量级有个体感。
面试追问 :
Mutex可以拷贝吗?→ 绝对不行。go vet的copylocks检查会拦你:拷贝一把"正被等待"的锁,等于把队首的 goroutine 遗弃在旧地址上,新锁的等待者永远等不到唤醒。Mutex、RWMutex、WaitGroup全都不可拷贝,sync包里带noCopy标记的都要当心。
三、RWMutex:读者优先的代价,写者怎么活
RWMutex 是"读多写少"场景的标配:读锁共享,写锁独占。但它底层比 Mutex 更绕,坑也更深。核心全在 readerCount 这一个字段的玩法上。
3.1 readerCount 的负数戏法
RWMutex 的定义:
go
// sync/rwmutex.go
type RWMutex struct {
w Mutex // 写者之间、写者与读者切换时的仲裁锁
writerSem uint32 // 写者等待信号量
readerSem uint32 // 读者等待信号量
readerCount atomic.Int32 // 读者账本
readerWait atomic.Int32 // 写者关门时,门里还剩几个读者
}
readerCount 是一个会"人格分裂"的计数器:
- 正数:当前持读锁的读者数;
- 负数 :有写者在排队,用
rwmaxReaders(1 << 30)做"关门标记"。
写者拿锁的第一步不是抢 w,而是干一件事:
go
// sync/rwmutex.go(结构示意)
func (rw *RWMutex) Lock() {
rw.w.Lock() // 先当"普通写者"互斥
r := rw.readerCount.Add(-rwmaxReaders) // 把 readerCount 砸成负数:关门
if r != 0 && rw.readerWait.Add(r) != 0 {
// 门里还有 r 个读者没走完,睡,等最后一个读者走时叫醒
runtime_SemacquireRWMutex(&rw.writerSem, false, 0)
}
}
readerCount -= rwmaxReaders 这一步是整个设计的点睛之笔:新读者来了一看 readerCount 是负的,就知道"写者在排队,这门关了" ,乖乖去睡 readerSem。写者借此实现"读者优先"------哦不,准确说是**"写者关门权"**:新读者进不来,门里的老读者读完就走,最后一个读者离开时把写者叫醒。
3.2 谁饿死谁:一个经典的权衡
这套机制下,写者最怕的不是读者多,是读者源源不断 。老读者还没走完、新读者又被放进来的那几纳秒里,写者的等待时间就会被拉长。Go 的取舍是明确的:写者能关门挡住新读者,但要等存量读者------"读者优先,但写者有刹车"。
给使用者的结论很朴素:RWMutex 不是银弹 。临界区极短(就几次内存读写)时,直接用 Mutex------RWMutex 的 readerCount 原子操作 + 写者仲裁,比一把朴素互斥锁还贵。读锁的价值在"临界区真的长"(比如遍历大 map、序列化大对象)时才兑现。
面试追问 :
RWMutex的读锁可以递归吗?→ 不可以,和Mutex一样,同 goroutine 二次RLock在有写者排队时会直接死锁------第二次读锁看到"关门"去睡觉,而写者等他这个"老读者"读完,他等写者放行,互相等到天荒地老。Go 的锁全部不可重入,别带着 Java 的ReentrantLock思维写 Go。
四、WaitGroup:三步语义与两条 panic 红线
回到第一节的事故。WaitGroup 的源码短得像玩具,但每一行都在防呆。
4.1 状态合并:一个 uint64 装下两个计数器
go
// sync/waitgroup.go(Go 1.25.4)
type WaitGroup struct {
noCopy noCopy
state atomic.Uint64 // 高 32 位:Add 的计数;低 32 位:Wait 的等待者数
sema uint32
}
Add 的计数器和 Wait 的等待者数挤在同一个 uint64 里,一次原子操作同时改两边------这是 WaitGroup 全部并发安全的基础。为什么能合?因为两个 32 位字段各自的上限(Add 累计数、Wait 并发数)都远够用,省一次原子操作就是省一份竞争。
三步语义对应三个方法,每个都有红线:
go
// sync/waitgroup.go(结构示意)
func (wg *WaitGroup) Add(delta int) {
state := wg.state.Add(uint64(delta) << 32) // 只动高 32 位
v := int32(state >> 32) // 取回当前计数
if v < 0 {
panic("sync: negative WaitGroup counter") // ← 红线一
}
if v > 0 || w == 0 {
return
}
// 计数归零:唤醒所有 Wait 的 goroutine
}
func (wg *WaitGroup) Done() { wg.Add(-1) }
func (wg *WaitGroup) Wait() {
// 计数为零直接返回;否则等待者 +1,睡
for {
state := wg.state.Load()
v := int32(state >> 32)
if v == 0 {
return // 账已平,走人
}
// 有 goroutine 正在 Add 且计数 > 0 时,Wait 不允许并发进入(防竞态)
if wg.state.CompareAndSwap(state, state+1) {
runtime_Semacquire(&wg.sema)
// 被唤醒后如果 state 没归零,继续等
if wg.state.Load() != 0 {
continue
}
return
}
}
}
红线一:counter 为负就 panic 。Done 多调一次,或者 Add 传了负且超过当前计数,立刻 panic------这就是第一节那个"提前 Done 又 defer Done"的现场。panic 不是 bug,是账本在向你报警。
红线二:Add 和 Wait 不能并发 。Wait 里那段 CompareAndSwap 的注释写得很直白:如果 Wait 执行时计数还没加完(有 goroutine 正在 Add 且计数大于 0),行为是未定义的。规范用法永远是:先把所有的 Add(n) 做完,再去 Wait。
4.2 两个真实的坑
坑一:Add 放错位置 。Add 必须在"启动 goroutine 之前"调用,而且最好一次 Add(len(tasks))。散着写 Add(1) 的问题是:Wait 可能先于某些 Add 执行,直接违反红线二。
坑二:WaitGroup 复用时的节奏错位 。等完一轮,计数器归零,看起来"重置"了------但如果有上一轮掉队的 goroutine 才刚 Done,负数照样 panic。复用的正确姿势是:确认上一轮全部结束(不只是 Wait 返回)再开新一轮。拿不准就别复用,重新声明一个,成本为零。
4.3 Go 1.25 的新玩具:WaitGroup.Go
Go 1.25 给 WaitGroup 加了个小方法,把最常见的用法又收窄了一行:
go
// Go 1.25+:一行启动并自动登记
var wg sync.WaitGroup
for _, task := range tasks {
wg.Go(func() { process(task) }) // 等价于 wg.Add(1) + go func(){ defer wg.Done(); ... }
}
wg.Wait()
wg.Go 帮你把 Add、go、defer Done 三件事一次做对------结构性消灭"忘 Done"和"Add 放错位置"两类事故。新项目直接上,老项目改造时注意 Go version 声明。
面试追问 :为什么
WaitGroup不提供"带超时的 Wait"?→ 因为做不到干净。超时返回后,那些还没Done的 goroutine 怎么办?杀掉?Go 没有提供安全的 goroutine 强杀手段。要超时控制,用context+errgroup或自己用 channel 兜底,别指望WaitGroup。
五、Once / Pool / Map:三个"用对了就没事"的组件
5.1 Once:双检锁的官方实现
Once.Do 保证一个函数全局只执行一次。它的实现是教科书级的双检锁:
go
// sync/once.go(结构示意)
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
if o.done.Load() == 0 { // 抢到锁再检一次
defer o.done.Store(1)
f()
}
}
外层 atomic 快检、内层 Mutex 串行化 + 复查------这就是并发编程里"只初始化一次"的标准答案。注意两个使用细节:传给 Do 的函数如果 panic,done 不会置位 (下次还会再试,这是有意为之);Go 1.21 起还有 OnceFunc/OnceValue 系列包装,返回值的场景更顺手。
5.2 Pool:不是缓存,是"借还中心"
sync.Pool 的误解率极高。它的正确心智模型是社区共享的工具箱 :用的时候 Get 借一个,用完 Put 还回去,GC 会定期把池子清空 (每个 P 有一份 private + shared,外加一份 victim cache 做两代过渡)。
所以铁律两条:不能往池里放有状态的东西 (还回来的时候必须是"干净的",否则下一个借的人拿到脏数据);不能假设 Get 到的一定是刚 Put 的 (GC 可能刚清过,也可能借到别人的)。对象创建成本高的场景(bytes.Buffer、大 slice、编解码器)用池收益最大,把它当"加速缓存"用的,早晚出诡异 bug。
5.3 Map:读多写极少时的替代品
sync.Map 适合"写一次读多次"的配置型场景:内部 read(atomic 只读 map)+ dirty(可写 map)双结构,读命中 read 零同步,写走 dirty 并累计 miss 次数,miss 攒够了就把 dirty 提升为新的 read。
判断标准很简单 :key 集合基本不变、值偶尔更新 → sync.Map;读写都频繁、key 集合动态 → 老老实实 RWMutex + map,别折腾。
面试追问 :
sync.Pool里的对象什么时候被清空?→ GC 的时候。所有 Pool 对象在 GC 的 clear 阶段被回收,这也是它"不适合当缓存"的根本原因。想跨 GC 存活的对象,请用正经缓存结构。
六、可复用资产:sync 类故障速查表 + 30 秒定位清单
这一节是全文最"干"的部分,也是我最建议你收藏的部分。下次线上再遇 goroutine 只涨不降,直接翻这张表,不用再从原理开始推。
6.1 sync 类故障速查表
| pprof 阻塞栈特征 | 大概率根因 | 定位手法 | 修法 |
|---|---|---|---|
sync.(*WaitGroup).Wait 挂住,goroutine 数只涨不降 |
有 goroutine 漏 Done,或 Done 次数对不上(含 panic 被 recover 吞掉) |
数 Add/Done 配对;业务分支里的提前 Done 全局搜一遍 |
wg.Go(Go 1.25+)或 defer wg.Done() 放函数第一行 |
sync.runtime_Semacquire + sync.(*Mutex).Lock |
锁未释放(持锁路径 panic/return 漏 Unlock)或锁顺序死锁 | pprof 看持锁 goroutine 卡在哪;go test -race 跑出锁序 |
defer mu.Unlock() 紧跟 Lock;全局锁序约定 |
sync.runtime_Semacquire + sync.(*RWMutex).RLock |
写者关门期间新读者在睡,等存量读者读完 | 看是否有写者长期持锁 | 缩短写临界区;评估是否该用普通 Mutex |
sync.(*Once).doSlow 挂住 |
Do 的函数内部又调了同一个 Once.Do(自引用) |
栈里找递归的 doSlow |
Once 的函数内禁止再触达自身 |
崩溃栈 sync: negative WaitGroup counter |
Done 多于 Add(最常见:提前 Done + defer Done 双release) |
panic 栈直接指到行 | 删掉手动 Done,只保留 defer |
| goroutine 数涨了但 pprof 里没阻塞栈 | 不是 sync 问题,查 channel(见本系列第四篇)或漏退出条件的 for 循环 | 对比 NumGoroutine 曲线与请求量曲线 |
补退出条件/context 取消 |
6.2 30 秒定位清单
顺序执行,五步内定位:
text
1. runtime.NumGoroutine() 拉曲线:是"涨了不降"还是"正常波动"?
2. pprof goroutine dump:阻塞榜首的关键词是 chan / semacquire / IO wait?
- semacquire → 本篇文章的 sync 家族,查上表
- chan send/recv → 去看《Go 进阶(四)channel 底层》
3. 是 WaitGroup.Mutex 类:全局搜 Add/Done、Lock/Unlock 配对,重点搜 return 前的分支
4. 不确定锁序?go build -race 跑一遍压测,race detector 直接点名冲突点
5. 修完回归:再拉一次 NumGoroutine 曲线,确认任务结束后回落到基线
6.3 一段可直接抄的"防呆"模板
go
// safe_waitgroup.go ------ Go 1.25+ 推荐写法,结构性消灭漏 Done
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
tasks := []string{"a", "b", "c"}
for _, t := range tasks {
wg.Go(func() { // Add + go + defer Done 三合一
fmt.Println("processing", t)
})
}
wg.Wait() // 只在此处 Wait,Add 已全部完成
}
七、本章小结:三句话带走
Mutex的精髓是动态公平:CAS 快路径 + 4 次自旋 + 1ms 饥饿阈值,低竞争拼效率、高竞争保 FIFO,饥饿模式是公平的刹车片;RWMutex读者优先但写者有刹车 :readerCount的负数戏法实现关门,临界区短就用普通Mutex,别为"感觉高级"付原子操作税;WaitGroup的账必须平 :先Add再Wait、defer Done放第一行、复用前确认上一轮清零------Go 1.25 起优先wg.Go,让编译器帮你记账。
💡 本章面试要点(高频追问一句话版):
- Mutex 的 state 怎么布局?→ locked/woken/starving 三位 + 29 位等待者计数,一个原子操作改全部;
- 什么时候自旋?→ 多核、队短、P 没满,最多 4 次,超了就睡;
- 饥饿模式解决什么?→ 正常模式可抢导致队尾饿死,1ms 阈值切换 FIFO 保公平;
- RWMutex 写者怎么挡新读者?→
readerCount -= rwmaxReaders变负,新读者看负数就睡; - WaitGroup 两条 panic 红线?→ counter 为负、Add 与 Wait 并发;
- Pool 为什么不能当缓存?→ GC 会清空,且不保证 Get 到刚 Put 的。
如果这篇文章帮你把「加锁就是 mu.Lock()」的旧认知换成了「一张状态字 + 两本账」,建议收藏------第六节的 sync 类故障速查表和 30 秒定位清单,下次排查 goroutine 泄漏时直接翻,对照 pprof 栈就能定位,不用再从源码开始推。
一个问题留给你,一句话就能答:你现在的项目里,sync.Mutex 和 sync.RWMutex 是怎么选的------是看了临界区长度,还是默认哪个顺手用哪个?欢迎评论区报一下你的选择依据,我看看有多少人的"读多写少"其实根本没到用 RWMutex 的临界区长度。
下一篇预告:《Go 进阶(六)》------context 底层与取消传播:一棵取消树怎么在 3 层调用间秒传 Done,WithValue 又为什么被叫做"代码异味"。老规矩,源码 + 线上案例 + 排查清单三件套。
系列导航:
- 《万字详解|Go 进阶(一)GMP 调度器》 https://blog.csdn.net/2604_97047541/article/details/168453593
- 《万字详解|Go 进阶(二)GC 三色标记》 https://blog.csdn.net/2604_97047541/article/details/168771937
- 《万字详解|Go 进阶(三)内存分配器》 https://blog.csdn.net/2604_97047541/article/details/169167664
- 《万字详解|Go 进阶(四)channel 底层与泄漏排查》 https://blog.csdn.net/2604_97047541/article/details/169463108
- 面试向追问合集:《GMP 调度器 10 个高频追问》 https://blog.csdn.net/2604_97047541/article/details/168559644
作者:利威尔xu | CSDN 专栏《GO保姆级万字详解》