SCHEDULER · 调度台 · 一本关于「等待」的书

A T   T H E   S A M E   T I M E

面试官问「并发和并行有什么区别」,你能背出标准答案。但如果他接着问:为什么 asyncio 对 CPU 密集任务一点用都没有Python 多线程到底是不是废的Go 的 conn.Read() 看起来在阻塞,凭什么不占线程为什么你的「异步」服务会 OOM?—— 这些才是真正要命的问题,而它们全都指向同一个地方。

这本书的第一件事,是把六个词钉死:同步/异步、阻塞/非阻塞、并发/并行 —— 它们是三组正交的问题,各自回答「谁来等」「线程在干嘛」「有几个执行单元」。混着用了二十年,混出了无数错误的直觉。
第二件事,是把这三组词一路拽到底:从内核的 epoll_wait,到 CPU 的缓存行,到 Kotlin 的状态机、Go 的 GMP、Python 的 GIL,最后回到你今天要写的那个函数。

25 章 · 5 幕 15 个零依赖互动 Demo Kotlin · Go · Python 真调度模拟 / 竞态复现 / 事件循环单步
▶ 从一杯咖啡开始
幕 I · 正名 DEFINITIONS

六个被混用了二十年的词

在写任何一行并发代码之前,先把词典修好。这五章不讲任何语言 —— 它们讲的是三组正交的问题,以及为什么把它们搅在一起会毁掉你的直觉。

01 「同时」是一种错觉:从一杯咖啡说起 先做八道题。然后用一个真的调度模拟器,把同一批任务用三种方式跑一遍:同步阻塞 420ms、异步并发 180ms、多核并行 140ms。再把任务换成 CPU 密集的 —— 结论会当场翻转。这个翻转就是全书的主轴。 ▸ 三种排布 02 同步与异步:一个关于「谁来等」的问题 同步不是「慢」,异步也不是「快」。这一对词只回答一件事:发出调用之后,我这条执行线还能不能往下走。同一个 HTTP 请求,四种写法(同步 / 回调 / Future / await)的时序泳道摆在一起,差别一目了然。 ▸ 调用时序 03 阻塞与非阻塞:一个关于「线程在干嘛」的问题 「同步」和「阻塞」不是同义词 —— 它们甚至不在一个层面上。一个说的是调用契约,一个说的是线程的下场。四种组合全部存在,包括那个最容易被忽略的「异步 + 阻塞」。附一张能贴在工位上的四象限表。 04 并发与并行:Rob Pike 那句话到底在说什么 「并发是同时应对很多事,并行是同时很多事」—— 这句话被引用了一万遍,但很少有人说清它的后果:并发是一种代码结构,并行是一种运行时属性。顺带算清 Amdahl 定律那道账:为什么 90% 可并行的程序,1000 个核也只能快 10 倍。 ▸ Amdahl 定律 05 五种 IO 模型:异步的地基是 epoll 一个线程凭什么能照看十万个连接?答案不在语言里,在内核里。从阻塞 read、到忙轮询、到 select/poll/epoll,再到 io_uring。Node 的事件循环、Python 的 asyncio、Go 的 netpoller —— 剥到底全是同一个 epoll_wait ▸ 服务员模型
幕 II · 机器 THE MACHINE

所有并发 bug 都源自这一层

竞态、死锁、可见性问题不是「高级话题」—— 它们是你今天写的那行 counter++ 的直接后果。这一幕从缓存行讲到 CAS,把 bug 的物理成因摊开。

06 硬件的真相:多核、缓存行与指令重排 你以为你在读内存,其实你在读缓存。你以为代码按顺序执行,其实 CPU 和编译器都在合法地重排它。本章讲清 MESI、写缓冲区、伪共享(一个 64 字节的缓存行如何让你的性能掉 10 倍),以及「内存屏障」到底屏蔽了什么。 07 线程:一个 8MB 的承诺,和 1 微秒的税 线程贵在哪?栈(预留的连续内存)、上下文切换(陷内核、刷 TLB、缓存全凉)、以及调度器在几万个线程面前的崩溃。把并发数拖到 10 万,看线程、goroutine、协程、Task 各自要多少内存 —— 差距是 500 倍 ▸ 成本计算器 08 竞态:原子性、可见性、有序性 三个不同的 bug,被统称为一个词,于是没人分得清。counter++ 会丢更新(原子性)、volatile 治的是另一种病(可见性)、双检锁曾经错了十年(有序性)。demo 会真的把 LOAD/ADD/STORE 随机交错跑 1000 次,让你亲眼看它丢。 ▸ 竞态复现器 09 锁:互斥、读写、以及死锁的四个条件 从 futex(为什么没竞争的锁几乎免费)讲到读写锁的写者饥饿。然后是死锁:四个条件,打破任何一个就没有死锁 —— 而工程上最好使的那条规则只有一句话:给所有锁定一个全局顺序。demo 会画出等待图并检测环。 ▸ 死锁等待图 10 无锁:CAS、ABA,与你大概率不该写它的理由 CAS 是所有原子类、所有无锁队列、Go 的 sync/atomic、Java 的 AtomicInteger 的地基。本章讲清它怎么工作、ABA 问题怎么咬人、内存序(acquire/release)在说什么 —— 以及一个不讨喜的忠告:除非你在写基础库,否则请用锁。
幕 III · 三种活法 THREE WAYS

Kotlin、Go、Python 各自的答案

同一个问题(「等待的时候别占着线程」),三门语言给了三个完全不同的答案:编译期的状态机用户态的调度器解释器里的事件循环。九章拆到底。

11 并发模型谱系:线程、事件循环、协程、Actor 先画一张地图,再进三个房间。四种模型、四种取舍:谁来调度(内核 or 用户态)、要不要染色、共享内存还是消息传递。看完这张图,你会明白 Go 和 Python 的分歧不是品味问题,是二十年前的一个架构选择。 12 Python:GIL 到底挡住了什么 「Python 多线程没用」是一句被传坏了的话 —— 它只对 CPU 密集成立。所有阻塞 IO 在进内核前都会释放 GIL,所以多线程抓网页是真的快。四个场景的 GIL 持有时间线摆在一起,再谈 3.14 的 free-threaded:GIL 拆了之后,你那些「靠 GIL 保平安」的代码会开始出竞态。 ▸ GIL 观察台 13 Python:asyncio —— 一个事件循环,和它的染色问题 用一个真的 mini 事件循环单步执行给你看:ready 队列、定时器堆、以及「没活干就睡在 epoll_wait 上」。然后打开「阻塞调用」开关 —— 一行 time.sleep,整个循环停摆,所有请求一起卡死。这是 asyncio 最常见的线上事故。 ▸ 事件循环单步器 14 Kotlin:suspend 是一台状态机 一帧一帧看编译器做的事:塞一个 Continuation 参数、把跨挂起点的局部变量搬进堆上的对象、把函数体切成 switch(label)看到那个 return COROUTINE_SUSPENDED,「挂起 ≠ 阻塞」就再也不是一句口号了。顺带解释「为什么能开十万个协程」。 ▸ CPS 变换台 15 Kotlin:一棵会传播失败与取消的树 Job 树、协作式取消(为什么取消对 Thread.sleep 无效)、async 的异常为什么迟到、SupervisorJob 什么时候真的有用。以及 Kotlin 最大的贡献:结构化并发 —— 让「启动一个协程」变得像调用一个函数一样有始有终。 16 Go:goroutine 与 GMP —— 把调度器搬进用户态 六幕看完 GMP:为什么 M 和 P 要分开(一个 goroutine 陷进 syscall,不能连累它的兄弟)、work stealing 怎么保证没有核在闲着、以及 Go 最漂亮的一手 —— conn.Read() 看起来在阻塞,实际是 netpoller 上的一次 park。你写同步代码,跑的是 epoll。 ▸ GMP 调度台 17 Go:channel 与 select —— 用通信来共享内存 无缓冲 channel 是一次会合,不是一个队列。缓冲区是减震器,不是蓄水池。弹珠台上把缓冲容量拖到 0,再把消费者拖慢 —— 背压是白送的,这一幕会一路撑到第 21 章。附 selectclose、nil channel 的全部语义,和那句 fatal error: all goroutines are asleep 的成因。 ▸ channel 弹珠台 18 Go:context —— goroutine 泄漏是怎么发生的 Go 把「取消」这件事完全交给了你。代价是:goroutine 泄漏是 Go 最常见的生产事故,而且没有任何编译器警告。五种泄漏姿势的解剖,context 的正确用法(和它被滥用成「万能背包」的错误用法),以及怎么用 pprof 抓住它们。 19 同题对答:并发抓 100 个 URL,三种语言 同一个真实需求 —— 并发 100 个请求、限流 10、整体超时 5 秒、单个失败不炸全局、取消要能穿透 —— 三种语言各写一遍,逐行对照。差异不在语法上,在「谁替你管生命周期」这件事上。 ▸ 三语对照台
幕 IV · 深水区 DEEP WATER

写对了才刚开始

并发代码「能跑」是最低标准。真正的分水岭在这四章:能不能取消干净、会不会被压垮、事故来了能不能定位。

20 结构化并发:正在统一三门语言的一个思想 2018 年一篇叫《Go statement considered harmful》的文章指出:go f()goto 是同一种病 —— 它让控制流跳出去,再也不回来。这个思想现在长进了 Kotlin 的 coroutineScope、Python 的 TaskGroup、Java 的 StructuredTaskScope。而 Go 本身,是唯一还没有它的那个。 21 背压:为什么你的「异步」会 OOM 异步让你可以「先接下来再说」—— 于是你接下了永远处理不完的活。无界队列不是「没有背压」,是「把背压推迟到 OOM」。四种策略(无界 / 阻塞 / 丢弃 / conflate)的模拟器,加上 Little 定律:为什么在内存爆掉之前,你的延迟已经先崩了。 ▸ 背压模拟器 22 取消、超时与优雅退出:最难的那部分 取消是协作式的 —— 没人能强行杀死一个正在跑的任务,只能请它自己停。于是有了一堆反直觉:超时了但活还在跑、runCatching 会吞掉取消、清理代码在取消后跑不完。以及优雅退出的完整清单(三门语言各一份)。 23 事故图鉴:十种线上并发 bug 的解剖 每一种都给出:症状、成因、复现方法、修法、以及怎么在 code review 里一眼认出它。阻塞事件循环、goroutine 泄漏、忘了 await、线程池死锁(池里的任务在等池里的任务)、伪共享、异常被吞、Go 1.22 之前的循环变量捕获…… 24 别人的答案:JS、Java 虚拟线程、Erlang、Rust JS 用单线程换掉了所有竞态(代价:一个死循环卡死整个页面)。Java 21 的虚拟线程选择不改语言、改运行时(于是没有函数染色)。Erlang 用抢占式的 Actor 换来了九个九。Rust 用类型系统在编译期消灭数据竞争。四条路,四种代价。
幕 V · 收束 CLOSING

把它变成你的判断力

最后一章不讲新东西,只做一件事:把前 24 章压成一个你能在设计评审上当场用出来的决策流程。