
很多并发模型比较,问的是"谁更先进"。这篇换个问法:状态归谁,等待归谁,失败归谁。
很多文章讲并发模型,喜欢从一个大表开始:线程、协程、Actor、CSP、async/await,各自一列,优缺点排开。
这种写法看起来完整,但读完很容易只记住几个标签:Go 是 CSP,Erlang 是 Actor,Java 是线程。
问题就在这里。
真实工程里,你不会因为一个模型"更先进"就选它。你真正关心的是:这段并发代码出了问题以后,我该去哪里看?谁能改状态?谁在等?谁接住失败?
这篇不做先进性排名。但"不排名"不等于"三种模型完全等价"——某些场景客观偏好某种模型,电信级长连接和毫秒级故障转移下 Actor 有结构性优势。本文反对的是脱离场景的抽象排名,不是反对场景化判断。
我会用同一个任务编排器,把 Go、Erlang、Java 三种心智模型放在同一张白板上看。不是看语法谁漂亮,而是看三件事:
- 状态归谁:谁拥有结果,谁能修改,谁负责一致性。
- 等待归谁:阻塞、超时、取消、背压由谁表达。
- 失败归谁:一个子任务失败后,错误被关在哪里,谁决定恢复或扩散。
这三个问题,比"CSP / Actor / 线程谁好"更接近工程现场。

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 边界里,通信结构里,还是同步调用栈里?

4. 第三问:失败归谁
状态和等待还只是开胃菜。并发模型真正拉开差距的地方,是失败。
因为并发系统里,失败不是一个点。一个子任务失败以后,至少有三个问题跟着来:
- 失败先被谁看到?
- 兄弟任务要不要取消?
- 这个失败会不会越过当前边界,影响更上层?
实验里加了两个失败场景:
timeout:risk比整体超时更慢。worker-error:billing明确返回错误。
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 的核心设计选择,是把状态和失败一起包进实体边界里。这让它在长生命周期、需要隔离和恢复的场景下有天然优势。

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 下重新有吸引力。
一旦你接受这个边界,争论就会少很多。
给语言贴标签已经够多了。真正要问的是:同一个问题,用哪套责任分配方式更容易写对、读懂、排查。

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

这张表不是标准答案。它只是把"模型选择"翻译成"责任选择",让讨论别一上来就变成语言站队。
下次看到并发模型争论时,先别急着站队。
有人说 channel 优雅,你就问:状态归谁?
有人说 Actor 才可靠,你就问:失败归谁?
能把这三件事回答清楚,模型名字反而没那么神秘。
CSP、Actor、线程的区别,在于把状态、等待和失败交给不同的角色负责。
工程里最怕的,不是三种模型给出了不同答案。
是这三个问题,从头到尾没人问过。
原文发布于 止语Lab