并发模型谱系:线程、事件循环、协程、Actor
接下来八章要钻进三门语言的内部。但在进去之前,先把地图画出来 —— 否则你会以为「Kotlin 的 suspend」「Go 的 goroutine」「Python 的 async def」是三种不同的东西。它们不是。它们是同一个思想的三种实现,而它们的差异,可以用三个问题完全刻画:谁来调度?要不要染色?怎么共享状态?把这三个问题的答案填进表格,二十年的语言之争就会变成一张一目了然的图。
三个问题,划分整个谱系
① 谁来调度? —— 内核(抢占式)还是用户态(大多是协作式)?
这决定了:一个死循环会不会卡死别人;切换成本是微秒还是纳秒。
② 执行单元和 OS 线程的映射关系是什么? —— 1:1、N:1、还是 M:N?
这决定了:能开多少个;能不能用满多核。
③ 怎么共享状态? —— 共享内存 + 锁,还是消息传递?
这决定了:你会遇到哪一类 bug。
模型一:OS 线程(1:1)
你的任务 T1 T2 T3 T4
│ │ │ │ 每个任务 = 一个 OS 线程
OS 线程 M1 M2 M3 M4 1 : 1
└──┬──┴──┬──┴──┬──┘
CPU 核 C1 C2 C3 内核抢占式调度
| 谁调度 | 内核,抢占式。时间片到了就换人,不需要你的配合。 |
| 映射 | 1:1 |
| 共享状态 | 共享内存 + 锁 |
| 好处 | ✅ 真并行(能用满多核) ✅ 抢占式 —— 一个死循环不会饿死别人 ✅ 代码最好写(就是普通的顺序代码) ✅ 栈追踪完整、调试器好使 |
| 代价 | ❌ 贵(栈 + 上下文切换,第 7 章) ❌ 开不了几万个 ❌ 共享内存 → 竞态、死锁(第 8、9 章) |
| 谁在用 | Java(21 之前)、C++、Rust 的 std::thread、Python 的 threading |
模型二:事件循环 + 回调(N:1)
你的任务 T1 T2 T3 … T10000 一万个任务
└───┴───┴──────┘
OS 线程 M1 ← 全挤在一个线程上! N : 1
│
CPU 核 C1 ← 永远只用一个核
| 谁调度 | 你自己写的那个 while(1) { epoll_wait(); }(第 5 章)。协作式 —— 只有当你的代码「让出」(返回 / await)时,才轮到下一个。 |
| 映射 | N:1(一万个任务,一个线程) |
| 共享状态 | 不需要锁!单线程,天然互斥。 |
| 好处 | ✅ 极便宜(一个任务就是一个闭包) ✅ 没有竞态、没有死锁、不需要锁 —— 这是它最被低估的优点 ✅ 十万连接毫无压力 |
| 代价 | ❌ 回调地狱(第 2 章) ❌ 一个阻塞调用,全场停摆 ❌ 用不了多核(要用就得开多进程) ❌ CPU 密集任务 = 灾难 |
| 谁在用 | Node.js、Redis、Nginx、Netty、libuv |
「单线程」听起来像是个缺陷。但看它买到了什么:整个并发编程里最难的那一半(竞态、锁、内存模型)——全部消失了。
Redis 的作者 antirez 说得很直白:Redis 是单线程的,因为瓶颈从来不是 CPU,是内存和网络。而单线程换来的「不需要任何锁」,让 Redis 的代码简单到令人发指,也让它的延迟极其可预测。
这是一个被严重低估的工程决策:用「不用多核」换「不用思考并发」。对绝大多数 IO 密集的服务来说,这笔买卖是划算的 —— 因为你可以开多个进程来用满多核(Node 的 cluster、Nginx 的 worker、Redis 的分片)。
模型三:协程 / 绿色线程(M:N)—— 本书的主角
你的任务 G1 G2 G3 G4 … G100000 十万个协程
└──┬──┘ └──┬──┘
OS 线程 M1 M2 M3 M4 ← 只有几个(≈核数) M : N
│ │ │ │
CPU 核 C1 C2 C3 C4 ← 全用上了!
| 谁调度 | 用户态调度器。大多是协作式(在挂起点让出)—— 但 Go 是个例外,它有异步抢占(第 16 章)。 |
| 映射 | M:N —— 这是关键。十万个协程,跑在几个 OS 线程上,而那几个线程能铺满所有核。 |
| 共享状态 | 看语言:Kotlin 共享内存(+ 结构化并发),Go 提倡 channel(但也能共享内存)。 |
| 好处 | ✅ 同时拿到事件循环的便宜和线程的多核 ✅ 代码读起来是同步的(第 2 章的第 ② 格) ✅ 十万个并发单元 = 几 MB 内存 |
| 代价 | ❌ 函数染色(Go 除外) ❌ 协作式的那些还是会卡(CPU 死循环、阻塞调用) ❌ 调试更难(栈追踪可能是断的) ❌ 共享内存的所有问题都还在 —— 协程不能免疫竞态! |
| 谁在用 | Go(goroutine)、Kotlin(协程)、Python(asyncio —— 但它其实是 N:1,见下)、Rust(tokio)、Java 21(虚拟线程) |
这是一个很多人没意识到的关键区别,而它解释了 Python 异步的全部痛苦:
- Go / Kotlin:M:N。协程被调度到多个 OS 线程上,真的能用满多核。
- Python asyncio:N:1。所有协程挤在一个线程上(因为 GIL 让多线程跑 Python 字节码没有意义)。它本质上就是模型二(事件循环),只是用 async/await 语法把回调藏起来了。
后果:Python asyncio 继承了事件循环的全部缺点(一个阻塞调用全场停摆、用不了多核),但没有继承它的优点(它引入了函数染色,把生态劈成了两半)。
这不是在黑 Python。这是 GIL 强加的约束下,一个合理的设计。但你必须知道你买的是什么 —— 否则你会期待 Go 的性能,然后失望。
模型四:Actor —— 不共享任何东西
┌─────────┐ 消息 ┌─────────┐ │ Actor A │ ──────► │ Actor B │ │ 私有状态 │ │ 私有状态 │ 没有共享内存! │ 邮箱 │ ◄────── │ 邮箱 │ 只有异步消息 └─────────┘ └─────────┘ 每个 Actor: · 有自己的私有状态(别人碰不到) · 有一个邮箱(消息队列) · 一次只处理一条消息 → 内部天然是单线程的 → 不需要锁!
| 谁调度 | 用户态调度器。Erlang/BEAM 是抢占式的(按「归约计数」抢占,每个进程跑 2000 次归约就被换下来)—— 这是它和其他所有协程系统最大的区别。 |
| 共享状态 | 完全不共享。只有不可变消息。 |
| 好处 | ✅ 从根上消灭了竞态和死锁(没有共享的东西,怎么竞争?) ✅ 天然分布式 —— 消息发给本机 Actor 还是远程 Actor,代码一样 ✅ 容错:Actor 崩了就重启(「let it crash」+ 监督树) |
| 代价 | ❌ 消息传递有开销(拷贝) ❌ 调试很难(异步消息流,栈追踪没用) ❌ 要求你彻底重新组织代码 —— 这不是一个库,是一种世界观 |
| 谁在用 | Erlang / Elixir(BEAM)、Akka(Scala/Java)、Microsoft Orleans |
爱立信的 AXD301 交换机,用 Erlang 写的,据称达到了 99.9999999% 的可用性(九个九 —— 一年停机约 31 毫秒)。
这个数字的背后不是「Erlang 代码没有 bug」,而是一个完全不同的哲学:
- 「Let it crash」 —— 不要写防御性代码去处理每一个异常。让那个 Actor 崩掉。
- 监督树 —— 它的「监督者」发现它崩了,把它重启到一个已知的好状态。
- 因为 Actor 之间完全隔离(各有各的堆!BEAM 里每个进程独立 GC),一个崩溃不会污染任何别人。
这在共享内存的世界里是不可能的 —— 一个线程崩了,谁知道它有没有把共享状态改坏一半?「隔离」是「可恢复」的前提。
(本站有一本 《九个九》 专门讲这个。)
CSP:Go 选的那条路
Go 的 channel 来自 Tony Hoare 1978 年的 CSP(Communicating Sequential Processes)。它和 Actor 很像,但有一个关键区别:
| Actor(Erlang) | CSP(Go) | |
|---|---|---|
| 寻址 | 你把消息发给「某个 Actor」(有身份、有地址 PID) | 你把消息发给「某个 channel」 —— 发送方不知道谁会收 |
| 耦合 | 发送方要知道接收方是谁 | 完全解耦:channel 是「管道」,两头互不认识 |
| 邮箱 | Actor 自带一个无界邮箱 | channel 可以是无缓冲的(同步会合)或有界的 → 天生有背压(第 17、21 章) |
| 共享内存 | 禁止 | 允许(Go 有 sync.Mutex)—— 只是不提倡 |
Go 那句著名的口号:
不要用共享内存来通信;要用通信来共享内存。
意思是:与其让十个 goroutine 抢一个加了锁的 map,不如让一个 goroutine 独占这个 map,其他人通过 channel 把请求发给它。
数据只有一个 owner,所有权通过 channel 转移。竞态就从结构上消失了 —— 因为从头到尾只有一个人在碰它。
(有意思的是:Rust 用类型系统把这条口号变成了编译期强制的规则 —— 所有权、借用检查、Send/Sync。第 24 章。)
但要诚实:Go 自己没有强制这一点。Go 标准库里到处是 sync.Mutex,Go 团队也说过「该用锁的时候就用锁」。这句口号是一种默认倾向,不是一条戒律。
一张总表
| OS 线程 | 事件循环 | 协程 M:N | Actor | |
|---|---|---|---|---|
| 调度 | 内核抢占 | 用户态协作 | 用户态 (Go 半抢占) | 用户态 (BEAM 抢占) |
| 映射 | 1:1 | N:1 | M:N | M:N |
| 能开多少 | 几千 | 几十万 | 几十万~百万 | 几百万 |
| 用满多核 | ✅ | ❌ | ✅ | ✅ |
| 死循环会卡死别人 | ❌ 不会 | ✅ 会(全场) | Kotlin/Py:会 Go:不会 | ❌ 不会 |
| 需要锁 | ✅ | ❌ | Kotlin:✅ Go:视情况 | ❌ |
| 函数染色 | ❌ | ✅(回调) | Kotlin/Py:✅ Go:❌ | ❌ |
| 代表 | Java(<21) C++ | Node Redis Nginx | Go Kotlin Python* Java 21+ | Erlang Elixir Akka |
* Python 的 asyncio 严格说是 N:1(事件循环 + async 语法糖),不是真正的 M:N。
- Python(1991)选了「简单的 C 扩展生态」 → 于是有了 GIL → 于是多线程不能并行 → 于是并发只能走事件循环 → 于是 asyncio 是 N:1 → 于是有了染色,且用不了多核。
整条因果链,起点是 1992 年的一个决定。而 free-threaded Python 正在试图把这个起点改掉(第 12 章)。 - Go(2009)选了「并发是一等公民」 → 从第一天就设计了 M:N 调度器 + netpoller → 于是不需要染色 → 但也因此,「取消」和「生命周期」被完全甩给了程序员(第 18、20 章)。
- Kotlin(2011,协程 2018)选了「不改 JVM」 → 于是只能在编译器层面做(CPS 变换)→ 于是有染色 → 但也因此,它是唯一从第一天就把「结构化并发」做进语言的(第 15、20 章)。
没有哪个是「对」的。它们是在不同的约束下,做出的不同权衡。接下来八章,就是逐个拆开这些权衡的后果。
1. 把上面那张总表打印出来。下次有人问你「Go 和 Python 的并发有什么区别」—— 不要背概念,直接指着「映射」和「函数染色」两行说。
2. 问自己:我的项目在用哪个模型?如果答案是「Spring Boot + 线程池」—— 那是模型一。然后问:我们的线程数是多少?核数是多少?(回到第 7 章。)
下一章:进第一个房间。Python —— 那个被 GIL 定义了三十年的语言,和它正在发生的一场地震。