并发模型三流派:CSP / Actor / 线程

并发模型不是 CSP / Actor / 线程谁更先进,而是在状态归谁、等待归谁、失败归谁三件事上做不同责任分配。用同一个任务编排器横切 Go / Erlang / Java,看责任如何在代码表面落位。

封面

很多并发模型比较,问的是"谁更先进"。这篇换个问法:状态归谁,等待归谁,失败归谁。

很多文章讲并发模型,喜欢从一个大表开始:线程、协程、Actor、CSP、async/await,各自一列,优缺点排开。

这种写法看起来完整,但读完很容易只记住几个标签:Go 是 CSP,Erlang 是 Actor,Java 是线程。

问题就在这里。

真实工程里,你不会因为一个模型"更先进"就选它。你真正关心的是:这段并发代码出了问题以后,我该去哪里看?谁能改状态?谁在等?谁接住失败?

这篇不做先进性排名。但"不排名"不等于"三种模型完全等价"——某些场景客观偏好某种模型,电信级长连接和毫秒级故障转移下 Actor 有结构性优势。本文反对的是脱离场景的抽象排名,不是反对场景化判断。

我会用同一个任务编排器,把 Go、Erlang、Java 三种心智模型放在同一张白板上看。不是看语法谁漂亮,而是看三件事:

  1. 状态归谁:谁拥有结果,谁能修改,谁负责一致性。
  2. 等待归谁:阻塞、超时、取消、背压由谁表达。
  3. 失败归谁:一个子任务失败后,错误被关在哪里,谁决定恢复或扩散。

这三个问题,比"CSP / Actor / 线程谁好"更接近工程现场。

错误问题 vs 正确问题:从"谁更先进"转向"三问框架"

1. 先固定一个任务:聚合三个下游

先别急着下定义。

想象一个用户画像接口。它要同时请求三个下游:

  • profile:基础资料,通常很快。
  • billing:付费状态,偶尔返回业务错误。
  • risk:风控标签,偶尔超时。

聚合层要做的事也很普通:并发发起请求,等结果回来,组装成一个响应。如果某个下游超时,不能让整个请求无限挂着;如果某个下游明确失败,要决定其他任务还要不要继续跑。

这个小场景写了三份最小实现:

语言 / 风格 实现路径 运行场景
Go / CSP 风格 goroutine + channel + context success、timeout、worker-error
Java / virtual thread virtual thread + Future/CompletionService success、timeout、worker-error
Erlang / Actor 风格 process + message + link/EXIT success、timeout、worker-error

同一个任务编排器:三个下游、三种责任表达方式

代码不测性能。这里没有 QPS,也没有延迟排名。

这么做是有意的。并发模型讨论很容易被性能数字带偏:哪个更快、哪个开销更低、哪个能撑更多连接。这些问题当然重要,但不是这篇要解决的事。此场景偏短生命周期聚合,Actor 的长生命周期优势未覆盖——边界先放这里。

一个模型性能再好,如果团队不知道失败以后谁来收场,它还是会在生产环境里变成黑盒。反过来,一个模型看起来不够"酷",但责任边界清楚,排障时反而更省命。

技术选型最后比的,往往是事故发生时谁能最快定位、最快止血,谁能避免下一次复发。

只看一件事:同一个工程任务,在不同模型里,责任被放到了哪里。

并发模型最容易被讲成抽象概念。但工程师真正付出的成本,通常不在"能不能并发",而在"并发以后谁负责收尾"。

下面进入三问。

2. 第一问:状态归谁

先看状态。

这个任务里,状态主要是"已经拿到了哪些下游结果"。它看起来只是一个结果集合,但并发一上来,问题就变了:多个任务能不能同时改它?如果能,谁保证一致性?如果不能,结果怎么汇合?

Go 的写法很典型。

Go 版本里,每个 worker goroutine 不直接修改聚合结果。它只把一个不可变的 Result 发到 channel。真正持有结果列表的是聚合 goroutine——社区惯用 errgroup 进一步封装这个模式。

实验输出里,这个观察写成一句话:

场景 状态所有权观察
Go success / timeout / worker-error aggregator goroutine owns the result slice; workers only send immutable Result values

翻译成人话就是:worker 只交信封,不改账本。账本在聚合者手里。

这就是 Go 偏 CSP 心智模型的好处。它鼓励你把协作关系写到通信结构里。channel 不只是传值,它也在暗示:谁能说话,谁能接收,谁能关闭这条路。

但这里要补一条边界。

Go 不是"无共享状态语言"。Go 项目里 mutex、atomic、WaitGroup、context 到处都是。把 Go 写成"只能 channel"是误导。更准确的说法是:Go 的默认心智模型鼓励你先问,能不能通过通信把状态所有权收束起来;如果收不住,再用锁和原子操作。

Erlang 的状态边界更硬一点。

在 Actor 风格里,每个 process 天然拥有自己的局部状态。外部不能直接伸手改它,只能发消息影响它。Erlang 实验里,worker process 完成后给 parent 发消息,parent 负责聚合。

输出里是这样:

场景 状态所有权观察
Erlang success / timeout / worker-error each process owns its local state; parent aggregates only messages

这个表达很 Actor:实体先存在,通信再发生。

CSP 更像"我关心通道和协作关系";Actor 更像"我关心实体和边界"。两者都是消息传递,但重心不同。CSP 的问题常常是"这条通信路径怎么设计";Actor 的问题常常是"这个状态属于哪个实体"。

Java virtual thread 的位置又不一样。

virtual thread 不会替你决定状态归谁。它解决的是阻塞线程的开销问题,共享状态如何保证一致性,仍然是应用层需要自己设计的。

Java 实验里,聚合状态仍然是调用方普通对象和集合。virtual thread 让每个子任务可以继续写成同步阻塞代码,但它没有把共享状态变成私有状态,也不会自动给你 Actor 边界。

输出里故意写得很直白:

场景 状态所有权观察
Java success / timeout / worker-error caller keeps aggregation state in ordinary objects; virtual threads do not change shared-state semantics

这句话可以放进正文里反复提醒自己:virtual thread 改变了等待成本,但没有改变状态语义。

第一问的答案大概是:

  • Go 倾向于让状态通过通信汇聚。
  • Erlang 倾向于让状态先属于一个 process。
  • Java virtual thread 让你保留熟悉的对象和调用栈,但状态边界仍要自己设计。

先别急着分高下。我们只看第一类责任:状态到底放在谁手里。

状态所有权三分法:通道、实体、对象上下文

3. 第二问:等待归谁

并发代码第二个麻烦是等待。

你发出三个下游请求,不可能一直等。risk 慢了怎么办?整体超时谁说了算?已经成功的结果要不要保留?还没结束的任务谁去取消?

先看边界感最强的方案。

Erlang 的等待像 mailbox 里的时间边界。Erlang 版本用 parent process 收 worker 消息,receive ... after 定义整体等待时间。risk 超时后,parent 杀掉还没完成的 worker。

erlang-timeout 的输出是:

字段 输出
completed [billing,profile]
canceled [risk]
error timeout

Actor 风格的等待不是"共享一个 context",而是"某个 process 在等消息"。这会改变排查入口。你会去看 mailbox、process 状态、消息有没有堆积,而不是先看某个共享取消对象。

Go 的等待关系通常更显眼一些。

在实验里,整体超时由 context.WithTimeout 定义。worker 里用 select 同时等两个东西:自己的模拟耗时,或者 ctx.Done()。聚合者看到第一个错误后调用 cancel(),兄弟 goroutine 通过同一个 context 收到取消。

go-timeout 的输出是:

字段 输出
completed [billing profile]
canceled [risk]
error risk canceled: context deadline exceeded

你能从这组输出里看到 Go 的风格:等待和取消不是藏在运行时深处,而是直接写在应用编排代码里。

这也是 Go 并发代码读起来有时"啰嗦"的原因。你要传 context,要处理 channel,要决定谁 close,要防止 goroutine 泄漏。它不替你消失这些问题。它只是把问题摆在桌面上。

Java virtual thread 的等待最容易被误解。

很多人听到 virtual thread,会以为 Java 的并发模型就变成了另一种东西。其实没有。virtual thread 的关键价值是:你可以继续写看起来同步的阻塞代码,而不会像平台线程那样为每个阻塞请求付出昂贵资源成本。

Java 版本用 Executors.newVirtualThreadPerTaskExecutor() 提交任务,再用 CompletionService 等结果。代码仍然是 Future 编排:谁超时,谁取消,谁收错误,都由应用层决定。

java-timeout 的输出是:

字段 输出
completed [billing, profile]
canceled [risk]
error overall timeout after 70ms

这说明 virtual thread 主要解决"等待能不能便宜一点"。至于等待语义怎么设计,还得回到应用编排。

把这三种模型放在一起,第二问会变得很清楚:

  • Erlang 把等待写成 parent process 对 mailbox 的接收边界。
  • Go 把等待和取消写成 context/select/channel 的协作协议。
  • Java virtual thread 把等待保留在同步调用栈里,让 JVM 承接更多阻塞成本,但取消策略仍在应用层。

不要只问"哪个模型更适合高并发"。先问:你的团队更想在代码哪里看到等待?process 边界里,通信结构里,还是同步调用栈里?

等待责任三层图:mailbox、context、virtual thread

4. 第三问:失败归谁

状态和等待还只是开胃菜。并发模型真正拉开差距的地方,是失败。

因为并发系统里,失败不是一个点。一个子任务失败以后,至少有三个问题跟着来:

  1. 失败先被谁看到?
  2. 兄弟任务要不要取消?
  3. 这个失败会不会越过当前边界,影响更上层?

实验里加了两个失败场景:

  • timeoutrisk 比整体超时更慢。
  • worker-errorbilling 明确返回错误。

Go 的 worker-error 输出是:

字段 输出
completed [profile]
failed [billing]
canceled [risk]
error billing returned error

这里的失败边界很应用化。billing 出错后,聚合者调用 cancel()risk 通过 context 退出。Go 没有一个"自动监督者"替你决定失败传播。你要把传播路径写出来。

很多业务系统正需要这种显式控制。

代价也在这里:如果你忘了传 context,忘了监听取消,忘了 drain channel,失败边界就会变成泄漏边界。Go 的并发事故里,goroutine 泄漏经常不是因为 goroutine 这个概念复杂,而是因为退出条件没有被完整表达。

Erlang 的失败更像"边界先存在"。

最小 Erlang 实验里,worker 用 spawn_link 启动——注意 parent 需要先 process_flag(trap_exit, true) 才能把 EXIT 信号转为可处理消息,否则会被级联终止。billing 失败时,parent 收到 EXIT 信号,然后 kill 兄弟 process。

输出是:

字段 输出
completed [profile]
failed [billing]
canceled [risk]
error {task_error,billing}

这当然还不是完整 OTP supervision。真正的 Erlang 工程会有 supervision tree、restart strategy、monitor 等更完整的设计。这里补一个关键区分:link 是双向的(一方死另一方跟着死),monitor 是单向的(只收通知,自己不死)。supervision tree 大量依赖 monitor 来实现"观察而不殉葬"。

但即使在这个最小实验里,你也能看到 Actor 心智模型的味道:失败是 process 边界上的信号,不只是一个返回值。一个实体死了,另一个实体可以观察到它的死亡,并决定下一步。

Java virtual thread 的失败边界则更接近传统线程模型,只是线程变轻了。

java-worker-error 的输出是:

字段 输出
completed [profile]
failed [billing returned error]
canceled [risk]
error billing returned error

这里的失败处理仍然靠应用编排 Future:捕获 ExecutionException,记录第一个错误,取消未完成任务。

Java 对"兄弟任务收束"这个问题已经给出了官方答案:StructuredTaskScope(JEP 480,已正式进入 JDK)。它的设计思路是把一组子任务绑进同一个作用域,作用域退出时所有子任务必须结束——要么全成功,要么第一个失败触发其余取消。最小示例:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<String> profile = scope.fork(() -> fetchProfile());
    Subtask<String> billing = scope.fork(() -> fetchBilling());
    Subtask<String> risk    = scope.fork(() -> fetchRisk());
    scope.join();           // 等待全部完成或第一个失败
    scope.throwIfFailed();  // 有失败则抛出
    return new Result(profile.get(), billing.get(), risk.get());
}
// scope 关闭时,未完成的子任务被自动取消

这比手动 Future 编排清晰不少——失败边界直接写在语法结构里。不过 StructuredTaskScope 的 API 仍在演进(自定义策略、嵌套作用域等场景还有边界),它尚未覆盖 supervision tree 那种"长期实体+重启策略"的场景。

到这里,第三问的差异就出来了:

  • Go:失败边界是应用协议,靠 context、channel、错误处理约定串起来。
  • Erlang:失败边界是 process 语义的一部分,link/monitor/supervision 让失败可观察、可隔离、可恢复。
  • Java:失败从手动 Future 编排走向 StructuredTaskScope 的作用域收束;线程变轻,失败策略正在被语法化。

当三种模型都有明确的责任位置时,争论"谁更先进"反而显得多余。真正的差异在于:失败信号沿着什么路径传播,传播到哪里停下来。

Actor 的核心设计选择,是把状态和失败一起包进实体边界里。这让它在长生命周期、需要隔离和恢复的场景下有天然优势。

失败边界三路径:取消信号、EXIT 信号、Future 取消

5. 一个辅助观察:责任在代码表面的分布密度

为了避免只凭感觉,还做了一个粗略的辅助观察。

方法很简单:对三份实验源码做关键词扫描,粗略统计"状态/聚合"“等待/调度"“失败/取消"三类责任关键词在代码里出现的相对密度。这只是一个粗略代理指标,不是性能比较,也不是可维护性评分——它只反映一个现象:代码表面上,你的注意力会被拉向哪个方向。

观察结果:

  • Go 代码里,等待和取消关键词(context、cancel、select、Done)分布最密集——你很容易看到协作痕迹。
  • Erlang 代码里,失败边界关键词(EXIT、link、kill、trap_exit)占比最高——process 边界和信号非常显眼。
  • Java 代码里,三类关键词分布相对均匀,状态管理稍密——同步写法保留,取消仍在 Future 编排层。

这个观察不能推出"Go 代码更复杂"或"Erlang 失败处理最好”。它能说明的只是:三种模型会把你注意力拉向不同地方。

模型 写代码前先问
Go 取消路径写完整了吗?
Java virtual thread 同步写法背后的失败收束写清楚了吗?
Erlang / Actor 实体边界和观察关系设计清楚了吗?

模型不会替你收拾现场。它只是让某些责任更容易被看见,另一些责任必须靠你自己补上。

责任显性化观察:状态、等待、失败三类信号在代码表面的位置

6. 反例:别把语言等同于模型

专业读者可能已经想反驳了:

Go 也能用锁啊。Java 也能写 Actor。Erlang 也不只是 mailbox。

这些反驳都对。

这篇文章从头到尾比较的是"默认心智模型”,不是"语言能力上限"。

语言像工具箱,默认心智模型像你最顺手拿起来的那把工具。Go 的工具箱里当然有锁和原子操作,但 goroutine、channel、context 会不断提醒你:协作关系能不能显式写出来?

Java 的工具箱越来越大。virtual thread 让 thread-per-request 这种老写法重新变得可承受,但它并不会自动替你做状态隔离。你仍然要设计对象边界、取消策略、错误传播。

Erlang 的工具箱也不只是"发消息"。如果只把 Erlang 当成 mailbox,你会错过它的核心设计:进程边界、失败信号、监督关系,以及"让它崩溃"背后的恢复语义。

更准确的说法是:

  • Go 不是 CSP 的纯实现,但它偏向用通信结构组织并发。
  • Erlang 不只是 Actor 消息队列,但它偏向用实体边界组织状态和失败。
  • Java 不等于传统重线程;virtual thread 让同步线程模型在高并发 I/O 下重新有吸引力。

一旦你接受这个边界,争论就会少很多。

给语言贴标签已经够多了。真正要问的是:同一个问题,用哪套责任分配方式更容易写对、读懂、排查。

语言能力边界 vs 默认心智模型

7. 最后给一张速查表

如果你在设计一个并发模块,不要先问"我该用 CSP、Actor 还是线程"。

先问三句:

  1. 状态归谁?
  2. 等待归谁?
  3. 失败归谁?

然后再看场景。

场景信号 更该关注的问题 倾向的表达方式
多个 I/O 下游并发等待,取消关系复杂 等待归谁,取消怎么传播 Go channel/context,或 Java virtual thread + 明确 Future 编排
每个实体有长期状态和独立生命周期 状态归谁,失败被关在哪里 Actor/process 边界
团队强依赖同步调用栈可读性 能否保留顺序代码,同时降低等待成本 Java virtual thread
失败恢复比吞吐更重要 谁观察失败,谁负责重启/隔离 Erlang/Actor supervision 思路
状态集中且需要严格共享一致性 谁能改状态,锁或事务在哪里 线程 + 显式同步,或重新拆分状态所有权

并发模型选型速查卡:先问谁负责

这张表不是标准答案。它只是把"模型选择"翻译成"责任选择",让讨论别一上来就变成语言站队。

下次看到并发模型争论时,先别急着站队。

有人说 channel 优雅,你就问:状态归谁?

有人说 Actor 才可靠,你就问:失败归谁?

能把这三件事回答清楚,模型名字反而没那么神秘。

CSP、Actor、线程的区别,在于把状态、等待和失败交给不同的角色负责。

工程里最怕的,不是三种模型给出了不同答案。

是这三个问题,从头到尾没人问过。


原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →