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,最后回到你今天要写的那个函数。
幕 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 章。附 select、close、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 章压成一个你能在设计评审上当场用出来的决策流程。