幕 III · 三种活法CH 11进度 11/25

并发模型谱系:线程、事件循环、协程、Actor

接下来八章要钻进三门语言的内部。但在进去之前,先把地图画出来 —— 否则你会以为「Kotlin 的 suspend」「Go 的 goroutine」「Python 的 async def」是三种不同的东西它们不是。它们是同一个思想的三种实现,而它们的差异,可以用三个问题完全刻画:谁来调度?要不要染色?怎么共享状态?把这三个问题的答案填进表格,二十年的语言之争就会变成一张一目了然的图。

四种模型抢占 vs 协作CSPActorM:N 调度

三个问题,划分整个谱系

◆ 定论 · 问对这三个问题,你就有了地图

① 谁来调度? —— 内核(抢占式)还是用户态(大多是协作式)?
这决定了:一个死循环会不会卡死别人;切换成本是微秒还是纳秒。

② 执行单元和 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.jsRedisNginx、Netty、libuv
◆ 定论 · Node 和 Redis 的选择,其实很有智慧

「单线程」听起来像是个缺陷。但看它买到了什么:整个并发编程里最难的那一半(竞态、锁、内存模型)——全部消失了。

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 的 asyncio 不是 M:N,是 N:1

这是一个很多人没意识到的关键区别,而它解释了 Python 异步的全部痛苦

  • Go / KotlinM:N。协程被调度到多个 OS 线程上,真的能用满多核
  • Python asyncioN: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 那句著名的口号:

◆ 定论 · Do not communicate by sharing memory; instead, share memory by communicating.

不要用共享内存来通信;要用通信来共享内存。

意思是:与其让十个 goroutine 抢一个加了锁的 map,不如让一个 goroutine 独占这个 map,其他人通过 channel 把请求发给它

数据只有一个 owner,所有权通过 channel 转移。竞态就从结构上消失了 —— 因为从头到尾只有一个人在碰它。

(有意思的是:Rust 用类型系统把这条口号变成了编译期强制的规则 —— 所有权、借用检查、Send/Sync。第 24 章。)

但要诚实:Go 自己没有强制这一点。Go 标准库里到处是 sync.Mutex,Go 团队也说过「该用锁的时候就用锁」。这句口号是一种默认倾向,不是一条戒律

一张总表

OS 线程 事件循环 协程 M:N Actor
调度内核抢占用户态协作用户态
(Go 半抢占)
用户态
(BEAM 抢占)
映射1:1N:1M:NM: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 定义了三十年的语言,和它正在发生的一场地震。