跳到主要内容

并发模型

并发模型是对一个问题的不同回答:独立的执行流如何在不破坏彼此数据的前提下协调? 每种模型在性能、可组合性和会产生的 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 在某个值上达成一致,就用通道发送,而不是让它们指向同一个变量。
  • 优点: 可组合。一个消费通道的函数不关心数据是谁生产的。容易加入中间件(过滤器、扇出、扇入)。
  • 缺点: Go 的通道默认无缓冲,有界缓冲会产生死锁隐患。goroutine 泄漏(被遗忘的 goroutine 阻塞在永远不会被接收的通道上)是常见的生产 bug。
  • 何时使用: 希望在没有共享状态痛点的情况下获得并发的通用服务端代码。Go 在系统相关工作(Kubernetes、Docker、Prometheus)中的流行,很大程度上源于 CSP 是默认选择。

4. Actor 模型

Carl Hewitt 1973 年的模型,在 Erlang 和 Akka(Scala/JVM)中有最著名的实现。万物皆 Actor——一个拥有私有状态和邮箱的轻量级进程。

Actor 依次接收消息,一次一条。
状态从不共享——只有消息跨越 Actor 边界。
故障由监督者处理,而非 Actor 自己。
  • 你共享什么: 什么都不共享。Actor 状态是私有的,消息是不可变的。
  • 位置透明: 无论 Actor 在同一进程、同一机器还是跨网络,Actor 引用的使用方式都一样。Erlang/OTP 是为此特性至关重要的分布式电信系统而设计的。
  • 通过监督实现容错: Actor 被组织成监督层次结构。当一个 Actor 崩溃时,它的监督者决定重启它、重启它的兄弟,还是向上升级。Joe Armstrong 的"让它崩溃"哲学正源于此。
  • 适用场景: 需要软实时保证、高容错性和热代码升级的分布式系统。WhatsApp 在 Erlang 上、Discord 在 Elixir 上,都以巨大规模运行,这是著名的案例。
  • 不适用场景: 对延迟敏感的 CPU 密集型工作(消息传递有开销),以及自然分解方式并非"Actor + 邮箱"的问题。

Akka 后来从纯 Actor 模型转向了有类型的 Actor,因为无类型消息处理极易出错。核心洞见——隔离、消息传递、监督——仍然是持久的贡献。

5. 软件事务内存(STM)

把内存更新当作数据库事务对待。多个线程推测性地修改共享状态;运行时在提交时校验,冲突时重试。Clojure 的 ref/dosync 和 Haskell 的 STM monad 是最知名的实现。

(dosync
(alter account-a - 100)
(alter account-b + 100))
  • 你共享什么: 指向不可变值的事务性引用。
  • 语义: 类似 MVCC 或纯函数——提交、中止、重试。运行时保证 dosync 块的原子性。
  • 优点: 组合简单。不像锁(两个加锁操作组合可能死锁),两个事务组合起来就是一个更大的事务。
  • 缺点: 性能。事务簿记开销大。竞争会放大:大量事务冲突时,重试循环可能主导开销。尽管研究多年,STM 从未成为主流。
  • 适用场景: Haskell 的 STM 在函数式代码中协调少量共享可变单元时非常出色。Clojure 的 STM 与持久化数据结构(重试代价低)配合良好。其他几乎没有哪里真正采用过。

模型比较

模型共享状态?可组合?分布式?典型 bug
单线程异步是(同一线程)尚可事件循环阻塞、漏写 await
共享内存线程竞态、死锁、可见性 bug
CSP非原生goroutine 泄漏、缓冲死锁
Actor 模型是(透明)邮箱溢出、类型问题
STM事务性地竞争、重试风暴

几点实用观察

语言往往比问题更能决定模型。 团队选择语言的理由很多(生态、招聘、性能),并发模型作为副作用被继承下来。Erlang 强制 Actor;Go 强制 CSP;Java 给你线程。要有意识地根据问题匹配模型——否则你会用你技术栈里最容易的那种,而那不一定对。

真实系统会混合模型。 一个典型的生产服务可能运行多线程事件循环(异步 + 线程做 CPU 密集工作)、与一个内部使用 STM 的数据库通信、并通过 Actor 风格的消息队列(Kafka 在结构上与 Actor 模型相近)与兄弟服务通信。理解每个模型本身,是为了能处理组合后的系统。

最难的并发 bug 来自模型错位。 在异步运行时里使用共享内存线程原语,或在 Actor 边界间共享可变状态——这些是最糟糕的生产事故藏身之处。如果你看到一个只在高负载下重现的 bug,检查系统的两个部分是否假设了不同的并发模型。

参见

  • 跳表 —— ConcurrentSkipListMap 是共享内存无锁数据结构的一个案例。
  • 布隆过滤器 —— 并发布隆过滤器实现是常见的系统工程面试题。
  • 如何扩展一个 Web 服务? —— X 轴克隆假设你的并发模型允许在没有共享状态的前提下运行多个实例。
Want to keep learning more?