幕 IV · 深水区CH 23进度 23/25
事故图鉴:十种线上并发 bug 的解剖
这一章是全书的查询手册。十个真实的 bug 模式,每一个都给出:症状(你在监控上看到什么)、成因(哪一章的知识)、复现(怎么让它必现)、修法、以及最重要的 —— 怎么在 code review 里一眼认出它。它们的共同点是:每一个都长得很正常。没有一个会让编译器皱眉,没有一个会在测试里挂掉。它们都是在生产环境的某个凌晨,第一次露出獠牙。
#1 · 阻塞事件循环
| 症状 | 所有请求的延迟同时飙升(不是某些请求,是全部)。CPU 使用率可能还很低。P99 变成一条锯齿。 |
| 成因 | 第 5、13 章。事件循环是单线程的,一个人犯规,全场停摆。 |
| 凶手 | time.sleep()、requests.get()、同步 DB 驱动、open().read() 大文件、一个 CPU 密集的 for 循环 —— 出现在 async def 里。Node 里: fs.readFileSync、JSON.parse 一个 50MB 的字符串。 |
| 复现 | 在 handler 里加一行 time.sleep(1),压测。QPS 直接掉到 1。 |
| 修法 | 换异步库;躲不掉就 await asyncio.to_thread(fn);纯 Python 的 CPU 活用 ProcessPoolExecutor。 |
| 怎么发现 | PYTHONASYNCIODEBUG=1 —— 它会直接打印出「这个回调跑了 XXX 秒」。这是最有效的一招。 |
| Review | 在 async def 函数体里,任何一行没有 await 的 IO 调用,都要停下来问一句。 |
#2 · goroutine / task 泄漏
| 症状 | 内存单调上升,永不回落。重启后正常,然后又慢慢涨。最终 OOM。 goroutine 数量曲线是一条直线向上。 |
| 成因 | 第 18 章。发送到没人接的 channel / 从没人发的 channel 接收 / 忘了 defer cancel() / worker 没有退出路径。 |
| 复现 | 循环调用那个函数一万次,看 runtime.NumGoroutine()。 |
| 修法 | 每个 go f() 都要能回答「谁等它、谁取消它」。用 errgroup。 |
| 怎么发现 | ① goleak(一行代码,加进 CI)。② pprof: curl localhost:6060/debug/pprof/goroutine?debug=1 —— 看哪个栈上聚集了几千个 goroutine。③ 把 NumGoroutine() 加进监控。 |
| Review | 看到裸的 go func(),就问三个问题:谁等它?谁取消它?它 panic 了会怎样?(答案:整个进程崩。) |
#3 · 线程池死锁(池里的任务在等池里的任务)
val pool = Executors.newFixedThreadPool(2) // 只有 2 个线程
pool.submit { // 任务 A,占了线程 1
val sub = pool.submit { work() } // 提交子任务 B —— 但池满了,B 在队列里排队
sub.get() // ❌ A 阻塞等 B
} // B 等 A 让出线程
pool.submit { // 任务 C,占了线程 2,同样的事
pool.submit { work() }.get() // ❌
}
// 两个线程都被 A 和 C 占着,而 A 和 C 在等永远排不进来的子任务。
// 【永久死锁。而且没有任何报错。】
| 症状 | 服务完全卡死,不处理任何请求,CPU 是 0。没有任何异常日志。 |
| 成因 | 第 7、9 章。资源(线程)耗尽 + 循环等待。 |
| 修法 | ① 永远不要在一个池里提交子任务再等它。 ② 如果必须:用两个不同的池(父任务一个池,子任务另一个池)。 ③ Kotlin/Go:协程/goroutine 没有这个问题(它们不占线程)—— 但如果你在协程里 runBlocking,问题就回来了。 |
| Review | 在一个 submit / execute 的 lambda 里,看到又一个 submit + get()。 |
#4 · 忘了 await / 忘了 join
async def handler(req):
save_to_db(data) # ❌ 忘了 await!协程对象被造出来,然后被扔了
return "ok" # 数据静静地没有被保存
# 警告:RuntimeWarning: coroutine 'save_to_db' was never awaited
# 但它只是个 warning,在生产日志里会淹没在一万行 INFO 里。
| 症状 | 数据莫名其妙地没保存。接口返回成功,但副作用没发生。而且是概率性的(有时进程活得够久,task 跑完了)。 |
| 修法 | python -W error::RuntimeWarning(把 warning 变成 error)用 mypy / pyright(它们能查出「coroutine 对象被当值用了」) Kotlin 里对应的是: launch { } 忘了 join()(但结构化并发帮你兜住了) |
| Review | 任何调用 async 函数但没有 await 的地方。 |
#5 · 无界队列 → OOM
| 症状 | 平时正常,流量高峰时 OOM。而且是「延迟先崩,然后内存崩」。 |
| 成因 | 第 21 章。λ > μ + 无界队列 = 必然 OOM。 |
| 凶手 | Executors.newCachedThreadPool()newFixedThreadPool(n) + 默认的无界 LinkedBlockingQueueChannel(Channel.UNLIMITED)asyncio.Queue()(不带 maxsize)以及最隐蔽的:在循环里 go f() / scope.launch {} / create_task() |
| 修法 | 每个队列都有界。消费者数量固定。队列满了的行为必须是一个显式的决定,并且有指标。 |
| Review | 任何一个不带容量参数的队列/池构造。 任何一个「在循环里启动并发任务」的地方。 |
#6 · 共享可变状态(经典竞态)
// 看起来毫无问题的代码
results := make(map[string]Result)
for _, url := range urls {
g.Go(func() error {
results[url] = fetch(url) // ❌ 并发写 map!
return nil
})
}
| 症状 | Go:直接崩(fatal error: concurrent map writes)—— 算是幸运的,因为 Go 会检测这个。Java:不会崩,但数据静静地错了(HashMap 在并发写时甚至可能形成环,导致 CPU 100% 死循环)。 |
| 成因 | 第 8 章。 |
| 修法 | ① 最好的:不共享。用一个 slice,每个 goroutine 写自己的下标(results[i] = ...,i 是循环变量)—— 不需要任何锁。② 用 channel 收集结果。 ③ 实在要共享:加锁 / sync.Map / ConcurrentHashMap。 |
| 怎么发现 | go test -race。就这一个命令。加进 CI。 |
| Review | 任何在并发闭包里被写的、闭包外定义的变量。 |
#7 · 循环变量捕获(Go 1.22 之前)
// Go 1.21 及之前:这是一个经典的坑
for _, url := range urls {
go func() {
fetch(url) // ❌ 所有 goroutine 共享【同一个】url 变量!
}() // 大概率全都 fetch 了最后一个 url
}
// 1.21 及之前的修法:
for _, url := range urls {
url := url // 手动 shadow 一下
go func() { fetch(url) }()
}
// 或者:
for _, url := range urls {
go func(u string) { fetch(u) }(url)
}
// ✅ Go 1.22 起:【循环变量是每轮独立的】。这个 bug 从语言层面消失了。
◆ 定论 · Go 1.22 修掉了 Go 历史上最著名的一个坑
这是一个罕见的破坏性变更(Go 一向以兼容性著称)。但 Go 团队算了一笔账:这个坑造成的 bug,比任何依赖旧行为的代码都多得多。
它靠 go.mod 里的语言版本来门控(go 1.22 以上才启用新行为)—— 这是一个很聪明的设计。
但要注意:如果你的代码库还在 go 1.21,这个坑还在。去看一眼你的 go.mod。
#8 · 异常被静默吞掉
// ❌ async 的异常,不 await 就永远不会抛
val d = async { throw IOException("boom") }
// 忘了 d.await() —— 但在 coroutineScope 里,它还是会取消整个 scope
// 而在 supervisorScope 里 —— 【异常就静静地躺在 Deferred 里,永远没人看】
// ❌ runCatching 吞掉 CancellationException(第 15、22 章)
runCatching { api.call() }
# ❌ 裸 create_task,异常没人取 asyncio.create_task(background_job()) # 日志里会出现(可能几分钟后,而且没有上下文): # ERROR:asyncio:Task exception was never retrieved
// ❌ goroutine 里的 panic,【没有人能 recover 它】
go func() {
panic("boom") // ← 整个进程崩溃!
}()
// ✅ 每个长期运行的 goroutine 都该有:
go func() {
defer func() {
if r := recover(); r != nil {
log.Error("goroutine panic", "err", r, "stack", debug.Stack())
}
}()
work()
}()
| 症状 | 「这个功能有时候就是不生效,但也不报错」。最难查的一类 bug。 |
| 修法 | Kotlin/Python:用结构化并发(coroutineScope / TaskGroup)—— 它们保证每个异常都被收集和重抛。Go:每个 goroutine 加 recover;用 errgroup(它会收集 error)。 |
| Review | 任何一个并发任务,它抛的异常最终会去哪?如果答不上来 —— 那就是被吞了。 |
#9 · 伪共享(性能,不是正确性)
| 症状 | 「加了核反而更慢」。多线程版本比单线程还慢。CPU 100%,但吞吐很低。 |
| 成因 | 第 6 章。两个不相关的变量挤在同一个 64 字节的缓存行里,两个核为它疯狂来回抢。 |
| 修法 | 填充(padding)到 64 字节;@Contended;或者根本不共享(每线程一个计数器,最后 sum —— 这就是 LongAdder 的做法)。 |
| Review | 只在高频并发的计数器/统计数组上关心它。业务代码里 99% 不需要想这件事。 |
#10 · 弱超时(超时了但活还在跑)
| 症状 | 你的服务在超时/降级,但下游的负载一点没降。然后雪崩。 |
| 成因 | 第 22 章。「不等了」≠「取消了」。 |
| 凶手 | Go:http.Get(url) 而不是 http.NewRequestWithContext;db.Query 而不是 db.QueryContext。Kotlin:在 withContext(IO) 里调阻塞式 HTTP 客户端。Python:任何同步阻塞调用。 |
| 怎么验证 | 让服务超时,看下游 QPS 有没有降。这个实验大多数团队从没做过。 |
| Review | 每一个网络/DB 调用,问:ctx 传进去了吗? |
一份可以贴进 PR 模板的审查清单
◆ 并发代码 Code Review 清单
【生命周期】
- □ 每个并发任务,谁等它?谁取消它?(Go:有没有裸
go f()?Kotlin:有没有GlobalScope?Python:有没有裸create_task?) - □ 每个
WithCancel/WithTimeout,有没有defer cancel()?(go vet能查) - □ 每个
for range ch,谁负责close(ch)? - □ 每个长期运行的 worker,有没有
<-ctx.Done()退出分支?
【背压】
- □ 所有队列/线程池/channel,都是有界的吗?
- □ 有没有「在循环里启动并发任务」?(= 隐形的无界队列)
- □ 队列满了的行为,是一个显式的决定吗?有指标吗?
【取消】
- □
ctx有没有一路传到最底层的网络/DB 调用?(弱超时检查) - □ 有没有
catch (e: Exception)/runCatching/except BaseException吞掉取消? - □ 清理代码用了
NonCancellable/WithoutCancel/shield吗?
【共享状态】
- □ 并发闭包里写的每一个外部变量,有没有同步?
- □ 能不能干脆不共享?(各写各的下标 / 用 channel 收集)
- □ 临界区里有没有调用外部代码(回调、IO、日志)?(死锁温床)
【事件循环】(Python / Node)
- □
async def里有没有任何同步阻塞调用? - □ 有没有 CPU 密集的循环?
【CI】
- □
go test -race/ TSan 跑了吗? - □
goleak加了吗? - □ goroutine / task 数量进监控了吗?
✚ 动手
1. 把上面那份清单贴进你的 PR 模板。(真的。这是这本书最实用的一个产出。)
2. 拿它审一遍你项目里最复杂的那个并发模块。我打赌你会找到至少三处。
3. 把 -race 和 goleak 加进 CI。这两行的投入产出比高得离谱。
下一章:走出这三门语言,看看别人是怎么解这道题的。JS 用单线程换掉了所有竞态,Java 用运行时绕过了染色,Erlang 用隔离换来了九个九,Rust 用类型系统在编译期消灭了数据竞争。四条路,四种代价。