幕 V · 收束CH 25进度 25/25

该选哪个:一张决策表,和一份自检清单

最后一章不讲任何新东西。它只做一件事:把前面 24 章压成一个你能在设计评审上当场用出来的流程。因为知识如果不能在「周三下午两点、有人问你要不要上异步」的那一刻变成一个判断,它就只是一堆你读过的字。这一章有三样东西:一棵决策树、一份自检清单、以及一个可能不太讨喜、但我认为是全书最重要的结论 —— 大多数时候,你并不需要并发。

决策树自检清单回到第 1 章诚实的结论

先做一次决策

三个问题,一个答案。但先说清楚:这棵树的第一个问题,是全书的核心。

◆ 定论 · 第一个问题永远是「时间花在哪」

不要猜。去 profile。

「我们是 IO 密集的」和「我们是 CPU 密集的」,是两个可以被测量的事实。而团队里对这件事的直觉,历史上的准确率大约是掷硬币的水平。

怎么测:

  • 最粗糙但最有效的:把一个典型请求的耗时拆开。总时间 = 等 DB + 等下游 + 等磁盘 + 真的在算分布式追踪(Jaeger / OpenTelemetry)会直接把这张图画给你。
  • Go:go tool pprof(CPU profile:真正在算的时间)+ 对比 wall clock。
  • Python:py-spy(它能看到线程在等什么)。
  • JVM:async-profiler(-e wall 模式,能看到阻塞时间)。

如果 CPU 时间只占总耗时的 5% —— 你是 IO 密集的。异步会有用,多核不会。
如果 CPU 时间占 90% —— 你是 CPU 密集的。异步毫无用处,只有多核有救。

一张贴在墙上的表

如果你的问题是 Kotlin Go Python
并发发几个请求,一起等回来 coroutineScope { async {} ... awaitAll() } errgroup.WithContext asyncio.TaskGroup
CPU 密集的计算 withContext(Dispatchers.Default) 不用管(调度器会抢占)
worker 数 = NumCPU()
ProcessPoolExecutor
(或下沉到 numpy / Rust)
调一个阻塞的老库 withContext(Dispatchers.IO) 不用管(netpoller / 摘 P) await asyncio.to_thread(fn)
限流(最多 N 个并发) Semaphore(N).withPermit g.SetLimit(N) asyncio.Semaphore(N)
超时(而且要真取消) withTimeout(5.seconds) context.WithTimeout
+ 把 ctx 传到底
async with asyncio.timeout(5)
生产者-消费者 + 背压 Channel(256) / Flow.buffer(256) make(chan T, 256) asyncio.Queue(maxsize=256)
只关心最新值(UI 状态) StateFlow / .conflate() cap=1 的 chan + selectdefault 自己实现(覆盖式的单槽)
共享一个计数器 AtomicInteger / Mutex atomic.Int64 直接写(GIL 罩着)
但 3.14t 里要加锁了
互斥(协程内) kotlinx.coroutines.sync.Mutex
不是 synchronized
sync.Mutex asyncio.Lock
不是 threading.Lock
找竞态 JCStress / 靠 review go test -race 3.14t + TSan
找泄漏 结构化并发帮你挡了 goleak + pprof asyncio.all_tasks()

回到第 1 章那八道题

现在再答一遍。如果你能用一句话说清「为什么」,而不只是「是什么」—— 这本书就没白读。

Q1用 asyncio 改造 CPU 密集的接口,会变快吗?

不会。异步只能把「等待」的时间卖给别人,而 CPU 密集任务没有等待可卖

更糟:那个跑得正欢的协程不会主动让出事件循环,会把整个进程的所有请求一起卡住。

→ 第 1 章那个 demo,把负载切到「CPU 密集」,加速比是 1.0×。

Q2「Python 多线程是废的」对吗?

只对 CPU 密集成立。所有阻塞 IO 在进内核前都会释放 GIL(Py_BEGIN_ALLOW_THREADS)—— 所以 10 个线程抓 10 个网页,是真的接近 10 倍。

→ 第 12 章。而且好的 C 扩展(numpy)也会在长计算前放开 GIL —— 所以「用 numpy 绕过 GIL」是真的。

Q310 万个 goroutine 各自 conn.Read(),会有 10 万个线程吗?

不会。可能只有 8 个。conn.Read() 底下是 netpoller(epoll):runtime 把 goroutine gopark 掉,M 立刻回去跑别的 G

→ 第 16 章。你写同步代码,跑的是 epoll。这是 Go 最漂亮的一手。

Q4「同步」和「阻塞」是一回事吗?

不是,它们甚至不在一个层面上。

  • 同步/异步 = 调用契约:我发出调用后,还能不能往下走
  • 阻塞/非阻塞 = 线程的下场:等待期间,那个 OS 线程被占着还是被交还

→ 第 3 章那个四象限表。本书的主角是右上角那一格:同步风格的代码 + 非阻塞的行为。Kotlin 用编译器做到,Go 用 runtime 做到,Java 21 用虚拟线程做到。

Q5两个线程各 counter++ 1000 次,一定是 2000 吗?

不一定。而且它「大部分时候是对的」—— 这才是最恶毒的地方。

counter++ = LOAD / ADD / STORE 三条指令。两个 LOAD 挨在一起,就会互相覆盖。

→ 第 8 章那个复现器。而且注意:volatile 治不了这个(它管可见性,不管原子性)。

Q6无界队列,来多少收多少,很棒吗?

那是你下一次 OOM 的完整成因。

λ > μ 时,无界队列线性增长,永不回落。它没有消除压力,只是把背压推迟到内存耗尽的那一刻

而且按 Little 定律,在 OOM 之前,延迟已经先崩了 —— 队列里排着 1 万个请求,新来的要等 10 秒,而客户端超时是 3 秒。你的服务器在 100% 满负荷地做无用功。

→ 第 21 章。

Q7go doWork() 之后,谁负责等它、取消它?

没有人。这就是问题所在。

go f() 让控制流跳出去,再也不回来 —— 它和 goto 犯的是同一个错误:打破了黑箱规则

→ 第 18、20 章。解药叫结构化并发,Kotlin/Python/Java/Swift 都有了,而 Go 是唯一还没有的那个

Q895% 可并行的程序,100 个核能快多少倍?

约 16.8 倍。而且哪怕无限个核,天花板也只有 20 倍。

Amdahl:1 / (0.05 + 0.95/100) ≈ 16.8。天花板 = 1/(1−p) = 20。

而这还是乐观的 —— 它没算争锁和缓存一致性(USL 的两项)。真实系统里,核加到某个点之后,性能会掉头向下

→ 第 4 章。这就是「把线程池从 32 调到 64,QPS 反而掉了」的物理成因。

一份可以贴进 PR 模板的清单

◆ 并发代码 Code Review 清单(完整版)

【第一性问题】

  • 这里真的需要并发吗?(见下一节。)
  • □ 我们是 CPU 密集还是 IO 密集?测过吗,还是猜的?

【生命周期】(第 18、20 章)

  • □ 每个并发任务,谁等它?谁取消它?它 panic / 抛异常了会怎样?
  • □ Go:有裸 go f() 吗?Kotlin:有 GlobalScope 吗?Python:有裸 create_task 吗?
  • □ 每个 WithCancel/WithTimeout,有 defer cancel() 吗?
  • □ 每个 for range ch谁负责 close
  • □ 每个长期 worker,有 <-ctx.Done() 退出分支吗?

【背压】(第 21 章)

  • □ 所有队列 / 线程池 / channel,都有界吗?
  • □ 有「在循环里启动并发任务」吗?(= 隐形的无界队列)
  • □ 队列满了的行为是显式决定吗?有指标吗?

【取消】(第 22 章)

  • ctx 传到最底层的网络/DB 调用了吗?(弱超时检查:让服务超时,看下游 QPS 有没有降
  • □ 有 runCatching / catch (e: Exception) / except BaseException 吞掉取消吗?
  • □ 清理代码用了 NonCancellable / WithoutCancel / shield 吗?

【共享状态】(第 8、9 章)

  • □ 并发闭包里写的每个外部变量,有同步吗?
  • 能不能干脆不共享?(各写各的下标 / 用 channel 收集 / 不可变 + 整体替换)
  • □ 临界区里有调用外部代码吗?(回调、IO、日志 —— 死锁温床
  • □ 多把锁的申请顺序,全局一致吗?

【事件循环】(第 5、13 章)

  • async def 里有任何同步阻塞调用吗?
  • □ 有 CPU 密集的循环吗?

【CI】

  • go test -race / TSan 跑了吗?
  • goleak 加了吗?
  • □ goroutine / task / 队列长度,进监控了吗?

那个不太讨喜的结论

◆ 定论 · 大多数时候,你并不需要并发

读完 24 章,你现在知道并发能买到什么了。现在我要告诉你它的定价。

并发的成本,从来不是「写起来麻烦」。是这些:

  • 你失去了「读代码就能知道它在干什么」的能力。(第 20 章:黑箱规则被打破了。)
  • 你的 bug 变成了不可复现的。本地跑一万次都对,生产环境一天错一次。(第 8 章。)
  • 你的栈追踪断了。调试器变得没那么好使。
  • 你需要一整套新的工具和纪律:race detector、goleak、背压、取消传播、优雅退出。
  • 而这套纪律,会随着团队人数增加而漏。

所以,在写下第一个 go / launch / create_task 之前,先问三个问题:

  1. 串行版本真的不够快吗?(测过吗?)
  2. 瓶颈真的在我这里吗?还是在那个所有请求都要过的数据库?(你并发了,它没有 —— 第 4 章。)
  3. 能不能用一个更笨但更简单的办法?加个缓存?把 N+1 查询改成一次批量查询?把那个 O(n²) 的循环改成 O(n)?

我见过太多「上了异步,性能没变,bug 多了十倍」的项目。它们的共同点是:没有人在动手之前,把第 1 章那三种排布的账算清楚。

并发是一个强大的工具。而强大的工具,最好的用法是「在确实需要的时候,精确地用一次」,而不是「因为它很酷,所以到处用」。

如果你只带走五句话

◆ 全书的五句话
  1. 一切并发问题都是「等待」问题。并发的全部意义,就是把「等」的时间卖给别人。而 CPU 密集的任务没有等待可卖 —— 那里只有多核有救。
  2. 同步/异步、阻塞/非阻塞、并发/并行,是三组正交的问题。分别回答「谁来等」「线程在干嘛」「有几个执行单元」。混着用,就会做错决定。
  3. 你用的每一个异步框架,剥到底都是一个 while(1) { epoll_wait(); }所以「不要阻塞事件循环」是一条从内核一路传上来的物理规则。
  4. 没有人能强行杀死一个正在跑的任务。取消只能是协作式的 —— 这是逻辑上的必然,不是设计缺陷。而「不等了」不等于「取消了」
  5. 每一个队列都必须有界。无界队列不是「没有背压」,是「把背压推迟到 OOM」。
✚ 最后一次动手

1. 把那份 review 清单贴进 PR 模板。这是这本书最实用的一个产出。

2. -race / goleak / PYTHONASYNCIODEBUG 加进 CI。三行配置,消灭三类事故。

3. 找一个团队里的人,问他第 1 章的 Q4(同步和阻塞是一回事吗)。如果他答错了 —— 把这本书发给他。

4. 下次有人说「我们上异步吧」,问他:我们是 CPU 密集还是 IO 密集?测过吗?


— 完 —

「同时」是一种被制造出来的错觉。
现在你知道它是怎么造出来的,代价是什么,以及什么时候不该造它。