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

📅 2026/7/13 16:31:23
并发模型三流派:CSP / Actor / 线程
很多并发模型比较问的是谁更先进。这篇换个问法状态归谁等待归谁失败归谁。很多文章讲并发模型喜欢从一个大表开始线程、协程、Actor、CSP、async/await各自一列优缺点排开。这种写法看起来完整但读完很容易只记住几个标签Go 是 CSPErlang 是 ActorJava 是线程。问题就在这里。真实工程里你不会因为一个模型更先进就选它。你真正关心的是这段并发代码出了问题以后我该去哪里看谁能改状态谁在等谁接住失败这篇不做先进性排名。但不排名不等于三种模型完全等价——某些场景客观偏好某种模型电信级长连接和毫秒级故障转移下 Actor 有结构性优势。本文反对的是脱离场景的抽象排名不是反对场景化判断。我会用同一个任务编排器把 Go、Erlang、Java 三种心智模型放在同一张白板上看。不是看语法谁漂亮而是看三件事状态归谁谁拥有结果谁能修改谁负责一致性。等待归谁阻塞、超时、取消、背压由谁表达。失败归谁一个子任务失败后错误被关在哪里谁决定恢复或扩散。这三个问题比CSP / Actor / 线程谁好更接近工程现场。1. 先固定一个任务聚合三个下游先别急着下定义。想象一个用户画像接口。它要同时请求三个下游profile基础资料通常很快。billing付费状态偶尔返回业务错误。risk风控标签偶尔超时。聚合层要做的事也很普通并发发起请求等结果回来组装成一个响应。如果某个下游超时不能让整个请求无限挂着如果某个下游明确失败要决定其他任务还要不要继续跑。这个小场景写了三份最小实现语言 / 风格实现路径运行场景Go / CSP 风格goroutine channel contextsuccess、timeout、worker-errorJava / virtual threadvirtual thread Future/CompletionServicesuccess、timeout、worker-errorErlang / Actor 风格process message link/EXITsuccess、timeout、worker-error代码不测性能。这里没有 QPS也没有延迟排名。这么做是有意的。并发模型讨论很容易被性能数字带偏哪个更快、哪个开销更低、哪个能撑更多连接。这些问题当然重要但不是这篇要解决的事。此场景偏短生命周期聚合Actor 的长生命周期优势未覆盖——边界先放这里。一个模型性能再好如果团队不知道失败以后谁来收场它还是会在生产环境里变成黑盒。反过来一个模型看起来不够酷但责任边界清楚排障时反而更省命。技术选型最后比的往往是事故发生时谁能最快定位、最快止血谁能避免下一次复发。只看一件事同一个工程任务在不同模型里责任被放到了哪里。并发模型最容易被讲成抽象概念。但工程师真正付出的成本通常不在能不能并发而在并发以后谁负责收尾。下面进入三问。2. 第一问状态归谁先看状态。这个任务里状态主要是已经拿到了哪些下游结果。它看起来只是一个结果集合但并发一上来问题就变了多个任务能不能同时改它如果能谁保证一致性如果不能结果怎么汇合Go 的写法很典型。Go 版本里每个 worker goroutine 不直接修改聚合结果。它只把一个不可变的Result发到 channel。真正持有结果列表的是聚合 goroutine——社区惯用errgroup进一步封装这个模式。实验输出里这个观察写成一句话场景状态所有权观察Go success / timeout / worker-erroraggregator 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-erroreach 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-errorcaller 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]errortimeoutActor 风格的等待不是共享一个 context而是某个 process 在等消息。这会改变排查入口。你会去看 mailbox、process 状态、消息有没有堆积而不是先看某个共享取消对象。Go 的等待关系通常更显眼一些。在实验里整体超时由context.WithTimeout定义。worker 里用select同时等两个东西自己的模拟耗时或者ctx.Done()。聚合者看到第一个错误后调用cancel()兄弟 goroutine 通过同一个 context 收到取消。go-timeout的输出是字段输出completed[billing profile]canceled[risk]errorrisk 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]erroroverall timeout after 70ms这说明 virtual thread 主要解决等待能不能便宜一点。至于等待语义怎么设计还得回到应用编排。把这三种模型放在一起第二问会变得很清楚Erlang 把等待写成 parent process 对 mailbox 的接收边界。Go 把等待和取消写成context/select/channel的协作协议。Java virtual thread 把等待保留在同步调用栈里让 JVM 承接更多阻塞成本但取消策略仍在应用层。不要只问哪个模型更适合高并发。先问你的团队更想在代码哪里看到等待process 边界里通信结构里还是同步调用栈里4. 第三问失败归谁状态和等待还只是开胃菜。并发模型真正拉开差距的地方是失败。因为并发系统里失败不是一个点。一个子任务失败以后至少有三个问题跟着来失败先被谁看到兄弟任务要不要取消这个失败会不会越过当前边界影响更上层实验里加了两个失败场景timeoutrisk比整体超时更慢。worker-errorbilling明确返回错误。Go 的 worker-error 输出是字段输出completed[profile]failed[billing]canceled[risk]errorbilling 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]errorbilling returned error这里的失败处理仍然靠应用编排 Future捕获ExecutionException记录第一个错误取消未完成任务。Java 对兄弟任务收束这个问题已经给出了官方答案StructuredTaskScopeJEP 480已正式进入 JDK。它的设计思路是把一组子任务绑进同一个作用域作用域退出时所有子任务必须结束——要么全成功要么第一个失败触发其余取消。最小示例try(var scopenew StructuredTaskScope.ShutdownOnFailure()){SubtaskStringprofilescope.fork(()-fetchProfile());SubtaskStringbillingscope.fork(()-fetchBilling());SubtaskStringriskscope.fork(()-fetchRisk());scope.join();// 等待全部完成或第一个失败 scope.throwIfFailed();// 有失败则抛出returnnew 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