并发:一个论点
为什么 SuperJ 没有线程,以及为什么这是正确的答案,而非限制。
TL;DR
共享状态并发 — 多个执行上下文修改公共内存 — 是一种没有正确性下界的工程风格。对于任何非平凡程序,它无法被证明既无数据竞争又无死锁;其中第二条在数学上是不可判定的。业界花了二十年,在每一种提供它的语言里产出有缺陷的"线程安全"代码,而借用检查器尽管强大,也只是缩小了失败面,并未消除并发击败人类推理的根本原因。
SuperJ 下了一个不同的赌注:不共享状态。在一个进程内只有一个线程、一个事件循环、一个因果故事。跨进程时,状态只通过一个全序事件流 — SEDA sequencer — 共享,这让一致性成为事件序列的属性,而非调度器的属性。这不是对缺失线程的权宜之计。它是一个关于系统应当如何构建的论点,基于三个被证明为真的结论。
1. 线程与进程对 OS 而言是同一回事
在内核层面没有范畴性的区分。一个 Mach 线程与一个 Mach task 的主线程都是线程;一个 Linux task 就是一个 task。OS 调度的是执行上下文;"线程"与"进程"是用户空间的故事,关于这些上下文是否共享地址空间。
那种共享不是性能特性 — 它是人们称之为"并发问题"的那一整类 bug 的根源。选择进程而非线程并不放弃并发(OS 无论如何都给你并发);它移除了那把容易走火的枪。独立地址空间的性能代价 — 跨进程 IPC — 是真实但有界的,而对于 SuperJ 所针对的工作负载(mmap 支撑的队列),它是亚微秒级且确定性的。
常见的说法"线程比进程便宜"仅在上下文切换代价上成立,而这在一个设计正确的事件系统中不是瓶颈。瓶颈是运行共享状态的各核之间的缓存一致性,而那个代价是线程付出的,而非线程避免的。
2. 死锁自由是不可判定的
这不是观点。判定任意并发程序是否会进入死锁状态的一般问题可归约到停机问题:你可以把图灵机的停机编码为资源获取模式,所以一个能证明死锁自由的算法就能判定停机。这个证明在并发理论界是常识,也是为什么没有实用工具尝试它的原因。
后果:没有静态检查、没有语言特性、没有类型系统能给你死锁自由。你能得到更窄的失败模式 — Rust 的 Send/Sync 证明数据竞争不存在,这是一个严格更弱的、语法性的属性(关于内存访问模式,可静态检查)。但"无畏并发"不是"无畏正确性"。你得到一个窄保证,却交易掉其余一切:死锁、活锁、优先级反转、丢失唤醒,以及 — 关键地 — 确定性,这是没有任何共享状态系统能提供的。
这就是为什么实测记录是现在这个样子。二十年的 Java、C#、Go 与 C++ 产出了一个由有能力的人、在比 C 人体工学得多的语言里写成的、易死锁、易竞争、非确定性损坏的"线程安全"代码语料库。借用检查器缩小了失败模式;它没有改变并发击败人类推理的原因,即正确性依赖于人类从未写下的全局交错。
3. 唯一可驾驭的共享状态是全序
如果你无法安全地共享可变状态,协调的唯一方式就是不共享它 — 让每块状态恰好由一个执行上下文拥有,并把变更作为有序事件流传播。这是 LMAX Disruptor 洞察的推广,也是 SEDA 所实现的:
- 每个 Application 拥有自己的状态。没有其他上下文触碰它。
- Application 之间通过向一个只有自己写的队列发布命令来通信。
- 一个单独的 Sequencer 排空所有命令队列,分配一个单调的全局
seq,盖上墙上时钟时间戳,并把结果写入一个事件队列。 - 每个 Application 用自己的游标读取同一个事件队列,看到同一个全序。
正确性现在是事件序列的属性,而非调度器的属性。没有交错需要推理,因为排序边界内没有并发。从 seq 0 重放逐位复现相同状态,因为状态转移函数对事件流是纯的。这是共享状态系统无论付出多少代价都无法提供的属性:确定性。
4. 延迟/一致性的权衡是根本性的
施加全序有代价,而这个代价不可协商。两个不可能性结论界定它:
- FLP(Fischer-Lynch-Paterson,1985)。在一个异步网络中,哪怕只有一个故障进程,确定性共识也是不可能的 — 没有协议总能终止。你无法分布式地既安全又活跃地选出领导者、就一个值达成一致,或安装一个全序。
- CAP。一致性 + 可用性 + 分区容忍:三选二,而在真实网络上你不能放弃分区容忍。
SEDA 通过不尝试分布式共识来解决这一点。Sequencer 由构造成为排序权威 — 一个单一的串行化点 — 这是把 CAP 中一致性优先于可用性的选择显式化并本地化。你付出的代价:
- 延迟。每个事件都要经过 Sequencer,所以往返延迟 = Sequencer 跳 + 队列深度。SEDA 实测的 137 ns/轮(M4 Max,进程内)表明这个代价绝对值很小,但它非零且在架构上不可消减。
- 单点故障。如果 Sequencer 进程死亡,排序停止。这是被接受的:事件队列就是日志,重启从
seq 0重放。可用性通过重启恢复,而非通过分布式故障转移 — 而 FLP 说你无论如何都无法安全地拥有它。
没有架构技巧能避免这一点。任何维持一致全局序的系统都在某处付出串行化代价,而 FLP 说你无法用聪明来伪造它。业界不断尝试(无锁结构、RCU、最终一致性、CRDT),又不断重新发现这个权衡是根本性的。SEDA 把代价前置、集中在一个可见之处,而不是把它分散到隐藏在"并发"代码里的成千上万次锁获取与一致性缺失中。
5. 为什么 SEDA 比裸跨 CPU mmap 更快
这个 SEDA 对 mmap 的基准不是奇闻。SEDA 进程内实测 137 ns/轮;裸跨进程 mmap 自旋实测 152 ns/轮 — SEDA 胜过了它所构建于其上的东西。那个结果是这个论点的经验签名。
原因是缓存一致性。两个进程在一个共享 mmap 缓存行上自旋,每次标志翻转都要付出跨 CPU 失效流量 — 缓存行在核之间乒乓,每次反弹是数十到数百纳秒的不可避免延迟。SEDA 进程内从不跨核:Sequencer 与 Application 共享缓存层级,事件队列在 L1/L2 中是热的,没有一致性协议要等待。
"并发"路径(裸 mmap)付出一致性税不是因为它需要,而是因为架构迫使两个进程通过共享内存协调。移除架构,代价就消失。这是深层要点:人们与"并发"关联的大部分开销并非并行做事所固有 — 它是并行做事的同时共享状态所固有。停止共享,开销就消失。
6. 你放弃了什么,老实说
这个论点不是声称 SuperJ 能做 Rust 能做的一切。它声称的是一个有用且庞大的子集,而边界是真实的:
- 没有进程内并行。一个真正需要 8 个核的 CPU 密集任务必须是 8 个进程,而非 8 个线程。这改变了部署形态(更多进程,它们之间有 IPC),但不改变正确性。对于 SuperJ 所针对的工作负载 — 事件驱动服务器、数据流水线、解析器、网络热路径 — 每进程一个核是正确的粒度,而 SEDA 让跨进程协调很廉价。
- 没有编译期内存安全。arena 分配给出确定性回收,而非静态安全。一个 arena 释放后的 use-after-free 是运行时崩溃,不是编译错误。Rust 的价值在于它的速度附带一个证明;SuperJ 的价值在于它的速度不附带证明的人体工学代价。哪个权衡胜出取决于你在构建什么以及谁在构建。
- 没有跨任意类型的通用泛型抽象。 Rust 的单态化 trait 在用户定义类型上给出零成本抽象。SuperJ 的擦除式泛型 + 无 vtable 的具体分派覆盖了 SDK 的需求,但无法像 Rust 那样承载一个通用库生态。
这些是设计上永久的,不是 TODO。它们是确定性与人体工学赌注的代价。
7. 综合
业界把"SuperJ 缺乏并发"框定为错误的。SuperJ 有一个刻意不同的并发模型,用进程内原生并行换取三个没有任何共享状态系统能提供的属性:
- 确定性。相同输入 → 相同事件序列 → 相同状态。重放逐位一致。bug 可复现。测试不依赖时序。
- 可推理性。一个有能力的人可以把整个系统装在脑子里,因为正确性对一个线程与一个事件顺序是局部的,而非跨交错是全局的。
- 无一致性税的低延迟。热路径无锁,用户代码无原子操作,无跨 CPU 缓存乒乓。SEDA 的数字证明"并发"基线在为它不需要的一致性买单。
这个赌注是:对于瓶颈是正确性与尾延迟而非原始核数的那一大类系统,这是更好的架构。那些不可能性结论 — 不可判定的死锁自由、FLP、CAP — 说这个权衡不可回避;你在某处付出它。SuperJ 把它前置、集中在一个可见的、可重放之处,而非分散到成千上万次隐藏的锁获取中。
这就是那个论点。它不是说线程坏、事件好。它是说共享可变状态是错误的原语,而全序事件系统是正确的原语 — 数学说,你无法同时拥有前者的人体工学与后者的保证。