并发模型

并发模型是对一个问题的不同回答:独立的执行流如何在不破坏彼此数据的前提下协调? 每种模型在性能、可组合性和会产生的 bug 类型之间做出了不同的权衡。语言选择通常会强制模型——你可以在任何语言里写 CSP,但 Go 让它成为阻力最小的路径;你可以在 Erlang 里做线程,但没人真这么干。
1. 单线程异步
一个 OS 线程,多个逻辑任务通过事件循环多路复用。原生 JavaScript、Node.js、Python asyncio、Tokio(单线程运行时)。
回调: fs.read(file, (err, data) => { ... })
Promise: fs.readFile(file).then(data => ...)
async/await: const data = await fs.readFile(file)
- 你共享什么: 所有任务状态都在同一线程内;从结构上就不存在内存数据竞争。
- 你付出什么: 任何 CPU 密集操作都会阻塞整个事件循环。一个慢任务 = 所有人都要等。
- bug 来源: 未处理的 Promise 拒绝、漏写
await、回调顺序隐患、微任务队列过长导致 I/O 饥饿。 - 何时使用: I/O 密集型负载(Web 服务器、API 网关、UI)。Node.js 的流行正是因为多数 Web 负载都是 I/O 密集。
这个模型看起来容易上手,但在规模化运营时却出人意料地棘手。一旦某个请求要做重 CPU 工作,你就需要 worker 池,这时两种模型就同时出现了。
2. 共享内存加锁线程
多个 OS 线程共享地址空间;互斥锁、读写锁、信号量保护临界区。Java、C++、Python 线程(带 GIL)、C#。
- 你共享什么: 一切——内存本身就是通信机制。
- 你付出什么: 锁竞争、死锁、优先级反转、内存模型可见性 bug(由于 CPU 重排或编译器优化导致读到陈旧值)。
- 经典失败模式:
- 死锁: 两个线程按不同顺序获取两把锁。每个经验丰富的系统工程师都调试过这种 bug;大多数还亲自制造过。
- 活锁: 线程不断响应彼此却没有实质进展。
- 竞态条件: 两个线程未同步地更新共享状态;最终状态取决于时序。
- 优先级反转: 低优先级线程持有高优先级线程所需的锁。
- 何时不可避免: 数据库内部、内核、垃圾回收器、高性能库。任何消息传递开销过高、且你具备工程成熟度来管理失败模式的地方。
Rich Hickey 在《Simple Made Easy》中对此模型的批评是关于它为何产生如此多 bug 的最清晰论述:它把"身份"与"值"混为一谈,也没有任何机制来推理时间。
3. 通信顺序进程(CSP)
Tony Hoare 1978 年提出的模型,由 Go 的 goroutine 和 channel、以及 Clojure 的 core.async 发扬光大。进程是匿名的,通道是一等公民。
ch := make(chan int)
go func() { ch <- computeSomething() }()
result := <-ch
- 你共享什么: 默认什么都不共享。状态通过通道流动,而不是通过共享内存。
- 你付出什么: 通道传递的开销,以及设计通信拓扑图的一定认知负担。
- Go 的口号("不要通过共享内存来通信,要通过通信来共享内存")概括了这条纪律:如果你需要两个 goroutine 在某个值上达成一致,就用通道发送,而不是让它们指向同一个变量。