幕 IV · 深水区CH 23进度 23/25

事故图鉴:十种线上并发 bug 的解剖

这一章是全书的查询手册。十个真实的 bug 模式,每一个都给出:症状(你在监控上看到什么)、成因(哪一章的知识)、复现(怎么让它必现)、修法、以及最重要的 —— 怎么在 code review 里一眼认出它。它们的共同点是:每一个都长得很正常。没有一个会让编译器皱眉,没有一个会在测试里挂掉。它们都是在生产环境的某个凌晨,第一次露出獠牙。

查询手册症状 → 成因code review 清单

#1 · 阻塞事件循环

症状所有请求的延迟同时飙升(不是某些请求,是全部)。CPU 使用率可能还很低。P99 变成一条锯齿。
成因第 5、13 章。事件循环是单线程的,一个人犯规,全场停摆。
凶手time.sleep()requests.get()、同步 DB 驱动、open().read() 大文件、一个 CPU 密集的 for 循环 —— 出现在 async def
Node 里:fs.readFileSyncJSON.parse 一个 50MB 的字符串。
复现在 handler 里加一行 time.sleep(1),压测。QPS 直接掉到 1。
修法换异步库;躲不掉就 await asyncio.to_thread(fn);纯 Python 的 CPU 活用 ProcessPoolExecutor
怎么发现PYTHONASYNCIODEBUG=1 —— 它会直接打印出「这个回调跑了 XXX 秒」。这是最有效的一招。
Reviewasync 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) + 默认的无界 LinkedBlockingQueue
Channel(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.NewRequestWithContextdb.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. -racegoleak 加进 CI。这两行的投入产出比高得离谱。

下一章:走出这三门语言,看看别人是怎么解这道题的。JS 用单线程换掉了所有竞态,Java 用运行时绕过了染色,Erlang 用隔离换来了九个九,Rust 用类型系统在编译期消灭了数据竞争。四条路,四种代价。