本节摘要:本节是本章前四节的速查形态:按"症状、定位、根因、修复"四栏整理并发程序的高频事故——崩溃、死锁、泄露、偶发错账。凌晨排障时先对症状、再跑仪器、按修复方向动手,全程有据可依。它是全书的实战索引,建议随用随翻、随事故增补。
本章前四节按知识脉络展开,但事故从不按脉络发生——它按症状发生。值班时你看到的是"进程没了""内存爬坡""接口偶发 500",而不是"我违反了守则五"。所以本节反着组织:每一类症状给一组固定的处置动作。本节同时是新增篇目,把原集没有的实战索引补进知识体系,它与 4.2、4.3 的仪器知识互为表里。
症状:服务突然退出,日志尾部是 fatal error,没有堆栈缓冲的机会。
定位:fatal 信息本身就是定位。两种最高频的形态:
fatal error: concurrent map writes——并发写映射,运行时检测到立即终止进程。panic: runtime error: invalid memory address or nil pointer dereference——空指针解引用,堆栈里直接给出事发行。package main import ( "fmt" "sync" ) func main() { counts := map[string]int{} var wg sync.WaitGroup for i := 0; i < 4; i++ { wg.Add(1) go func(tag string) { defer wg.Done() defer func() { if r := recover(); r != nil { fmt.Println("捕获 panic:", r) // 只是兜底展示,修复仍需加锁 } }() counts[tag]++ // 并发写映射:大概率 fatal,进程直接退出 }(fmt.Sprintf("worker-%d", i)) } wg.Wait() } // 典型输出(fatal 不可 recover,进程退出码非零): // fatal error: concurrent map writes
根因与修复:并发写映射没有侥幸可言,Go 的立场是宁可崩也不脏。修复用 4.2 投票案例版本 B 的套路——锁随数据封装;读多写少换 RWMutex;键集固定且极高并发可考虑分片。注意 fatal error 无法用 recover 拦截,defer recover 只对 panic 有效,两者别混为一谈。
预防:守则七(全局可变状态趋零)命中本案要害;评审清单第一行扫的就是它。
症状:程序没崩也没退,所有请求超时;日志出现 fatal error: all goroutines are asleep - deadlock!(全部僵死时运行时才会报,部分僵死只能靠超时暴露)。
定位:僵死必有一处等待闭环。运行时报的堆栈就是闭环现场;未报的场景用 goroutine 剖析看各栈停在哪一行等待。
最常见的三个成因:
// 成因一:自己等自己。无缓冲窗口的发送等接收,接收者却是自己 func selfWait() { ch := make(chan int) ch <- 1 // 发送阻塞等接收,无人接收,永久阻塞 <-ch } // 成因二:两把钥匙交叉。甲持 A 等 B,乙持 B 等 A func crossedLocks(muA, muB *sync.Mutex) { go func() { muA.Lock() time.Sleep(time.Millisecond) // 制造交错窗口 muB.Lock() // 甲在等 B muB.Unlock() muA.Unlock() }() muB.Lock() time.Sleep(2 * time.Millisecond) muA.Lock() // 乙在等 A:闭环成立,双双僵死 muA.Unlock() muB.Unlock() } // 成因三:签到板加减失衡。Done 比 Add 少一次,Wait 永不清零 func brokenWait() { var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done(); work() }() go func() { work() }() // 忘了 Done:Wait 永久等待 wg.Wait() }
根因与修复:死锁的数学本质是等待环。修复方向按环的类型选——窗口类闭环调整收发配对或加缓冲;锁类闭环给全部加锁点排统一顺序(先 A 后 B,无人例外);签到板类闭环用 3.5 三纪律自查,Add 与 Done 必须成对且传递指针。
预防:锁统一排序写进团队规范;不确定的锁序场景,用"先复制再处理"或"只经窗口传值"绕开多锁。
症状:重启时一切正常,运行数小时内存曲线缓步爬升;goroutine 数量基线消失、单调增长;GC 周期越来越密。
定位:goroutine 剖析是金标准。数量对照基线,找出数量异常的栈——泄露的 goroutine 一定停在某个永久阻塞点,栈就是它的"尸检报告":
$ go tool pprof -top -sample_index=goroutine profile.out flat flat% sum% 20458pp 97.2% 97.2% main.consume ← 两万员工卡在同一处
三大泄露来源,各配修复:
// 来源一:迟到者无处可去。无缓冲窗口的发送方等一个已经离开的接收者 func leakFast(src <-chan string) { out := make(chan string) // 无缓冲 go func() { out <- slowTransform(<-src) // 主流程超时返回后,这里永远发送不出去 }() select { case r := <-out: use(r) case <-time.After(50 * time.Millisecond): return // 泄露点:发送方还挂在 out 上 } } // 修复:out 改成 make(chan string, 1),迟到结果有处安放即自然退出 // 来源二:cancel 从未被调用。WithTimeout 的定时器与树节点永久驻留 func leakTimer() { ctx, cancel := context.WithTimeout(context.Background(), time.Second) _ = ctx // 忘了 defer cancel() } // 来源三:消费端提前离场,生产端还在发。range 循环被 break 后无人收尾 func leakRange(in <-chan int) { for v := range in { if v < 0 { return // 直接走人,上游的发送永久阻塞 } } } // 修复:用带取消的 for-select,离场时先通知上游停发或关闭取消窗口
根因与修复:泄露的本质是"有人还在等一个不会来的事件"。三个来源对应三种等待:等接收、等取消、等下游。修复的共同思想是给每种等待配上对等的释放动作——缓冲一格、defer cancel、离场广播。
预防:上线前压一轮长时测试盯 goroutine 基线;评审时对每个 channel 与 WithTimeout 问一句"不来的话谁负责放它走"。
症状:统计差几笔、状态偶尔错乱、低频出现切片越界或空指针,重启后无异常,测试环境难以复现。
定位:直接翻 4.2 的调查流程——go test -race 或 go run -race 复跑出问题的用例,报告会指出读写双方与出生地。偶发 panic 的常见底细是竞态先行:A goroutine 把切片置 nil,B goroutine 恰在此时按下标访问。
根因与修复:按 4.2 的两条路线处理——缩小共享面或加锁。本条不再展开,手册里保留它是为了提示一个纪律:偶发问题的第一反应不是加日志重试,而是开竞态检测器。日志会淹没在正常输出里,检测器直接给出案发现场。
| 症状 | 第一反应 | 仪器 | 高频根因 | 修复方向 |
|---|---|---|---|---|
| 进程退出,日志有 fatal | 看 fatal 类型 | 堆栈本身 | 并发写映射、空指针 | 锁随数据封装;判空与构造函数 |
| 全员僵住、请求全超时 | 找等待环 | 死锁堆栈、goroutine 剖析 | 自等待、交叉锁、签到失衡 | 收发配对、锁排序、Add 与 Done 成对 |
| 内存与 goroutine 爬坡 | 对数量基线 | goroutine 剖析 | 迟到者无缓冲、忘 cancel、离场不广播 | 缓冲一格、defer cancel、取消窗口 |
| 偶发错账或 panic | 开竞态检测 | -race 报告 | 无同步并发读写 | 缩小共享面或加锁 |
这张表的正确用法是"对症状进入,按修复方向出来"。如果四个格子都对不上,说明遇到了新事故——把你的症状、仪器输出与根因补一行进来,这本手册就该长一页。
至此进阶章收官。下一章把全套能力装进工程容器:目录、测试、依赖、部署,以及一个把全书知识串起来的综合实战。