去年有一段时间对 Rust 特别感兴趣,不过由于时间忙,学习了一段时间但是没有使用场景就放弃了,最近这一个月又在回顾今年的目标,其中一点是重新学习一门新的语言,看了 Zig 以后,觉得 Zig 的非常适合学习,虽然不是内存安全,但是简洁的语法,可以无缝和 C 语言交互,编译为一个单体包等这些特性吸引了我,于是我找了一些资料来权衡《Rust 和 Zig》的未来,在 reddit 上找到一篇写的比较好的关于《Rust 和 Zig》对比的博客,同时并做了如下翻译。
文章地址:matklad.github.io/2023/03/26/...
可靠软件
粗略来说,我们都希望写出无缺陷的程序。但细想之下就会发现,其实我们并不苛求程序 100% 正确------至少在大多数领域如此。
现实中几乎所有程序都有缺陷,但依然能正常运行。
举个具体例子:大多数程序使用堆,但几乎没有程序确切知道自己的堆使用情况或剩余空间,调用 malloc 时,我们只是假设堆空间足够,几乎从不检查,同理,所有 Rust 程序都会在内存不足(OOM)时中止,且无法预先声明内存需求,这当然够用,但远非完美。
更准确的说法是:我们追求在程序实用性和开发成本间取得平衡,缺陷会大幅降低实用性,对此有两种工程风格:
- Erlang 风格:承认软硬件的不可靠性,明确设计能容错的程序。
- SQLite 风格:通过严格工程克服不可靠环境。
rust-analyzer 和 TigerBeetle 正是这两种风格的绝佳案例。
rust-analyzer
作为 Rust 的 LSP 服务器,rust-analyzer 天生具有扩展性。优秀的开发工具通常需要覆盖各种小众用例。同时它又是快速迭代的开源项目,需要追赶 rustc 编译器的功能。
此外,IDE 工具的特性决定了可用性远比正确性重要------一个错误的补全建议最多遭人白眼(如果被注意到的话),而服务器崩溃导致语法高显全部消失则会立刻被发现。
因此,rust-analyzer 强烈倾向于「接受软件不完美」这一端。它的设计围绕「存在缺陷」展开:所有功能在运行时严格隔离,单个功能的崩溃不会影响整个进程;关键的是,几乎没有任何代码能接触可变状态,因此 catch_unwind 不会导致状态污染。
开发流程也遵循这一逻辑。例如,新功能 PR 只要核心场景能正确运行就会被合并。如果某些边缘用例导致崩溃也没关系------修复一个可复现的独立功能缺陷,往往是贡献者深入项目的入门捷径。严格的周更发布机制(和 nightly 版本)能快速推送修复。
总体哲学是:专注主流场景以最大化价值,边缘案例会随时间推移逐步完善。
TigerBeetle
TigerBeetle 则完全相反。
这个编译时固定领域模型的数据库(目前实现复式记账)采用分布式架构:六个地理和运维隔离的副本通过消息交换确保以相同顺序处理交易------在允许机器故障(分布式冗余的核心意义)的前提下,这是个异常复杂的问题,因此我们采用非拜占庭式共识算法。
传统共识算法假设存储可靠------一旦数据写入磁盘,总能被正确读取。但现实存储近乎拜占庭式不可靠:磁盘可能返回错误数据却不报错,而一次这样的错误就能破坏共识。TigerBeetle 通过以下方式解决这个问题:允许副本使用其他副本的数据修复本地存储。
工程层面,我们构建的是可靠且可预测的系统,「可预测」意味着真正的确定性:不是限制非确定性因素,而是从零开始用完全确定的手工组件构建整个系统。以下是一些非常规选择(设计文档):
• 硬核模式 :启动时分配全部内存,之后零分配。这消除了所有内存分配的不确定性。
• 极致简洁架构 :例如不使用 JSON/ProtoBuf/Cap'n'Proto 序列化,而是直接将网络字节流强制转换为目标类型。这样做的主要动机不是性能考量,而是为了减少系统组件数量------解析很复杂,但若通信双方受控,发送校验过的原始数据即可。
• 最小化依赖 :所有 IO 操作都由我们自己的代码实现,在 Linux 生产环境甚至不链接 libc。
• 低抽象度:组件间紧密协作。例如核心类型 Message 贯穿整个栈:
- 网络层直接从 TCP 连接将字节写入 Message
- 共识层处理/发送 Message
- 存储层将 Message 写入磁盘
这种设计自然产生简单高效的代码。由于预先分配所有内存,我们甚至没有多余内存来复制数据!(另一点在于:容错分布式系统中,存储不能视为独立黑盒------因为它本身也会故障)。
• 显式上限:没有随便的 u32------所有数据在系统边界都经过严格数值检查。我们限定内存中同时存在的 Message 数量,并精确预分配(源码)。从消息池获取新消息既不会分配也不会失败。
在这种严格的资源显式管理下,所有 IO(包括时间)都被外部化,输入全部显式传递,杜绝环境干扰,因此测试主要针对环境效应的所有可能排列组合------确定性随机化模拟能有效暴露分布式系统实现中的问题。
本质上,TigerBeetle 不是常规「程序」,而是明确编码的有限状态机。
回归正题
对了,差点忘记文章主题是 Rust 和 Zig!
我常想起 Rust 最早的幻灯片。虽然核心设计已有很多变化(Rust 不再只采用旧理念),但许多本质未变。略带刻薄地说:Rust「不适合孤独的天才黑客」,而 Zig...某种程度上适合。
更平和地说,Rust 是构建模块化软件的语言,而 Zig 在某种意义上反模块化。
引用 Bryan Cantrill 的话很贴切:
「我能写出正确释放内存、避免内存破坏的 C 代码...因为我完全掌控软件的天地。但这使得软件组合异常困难------即使你我都能写出内存安全的 C 代码,我们也很难在接口边界达成责任共识。」
这就是 Rust 的核心价值:提供精确表达组件契约的语言,使组件能以机器可验证的方式集成。
Zig 不这样做。它甚至不保证内存安全。我首次编写复杂 Zig 程序的经历是这样的:
「哇!我终于能在结构体里存储指向自身字段的指针了?」
结果 30 秒后
程序:「段错误」
但是!
Zig 比 Rust 小巧得多。虽然你需要将整个程序装在脑中,全面掌控天地以避免资源管理错误,但这样做可能更简单。
用 Zig 重写 Rust 程序不会更简单------相反,结果可能更复杂(且充满段错误)。我注意到许多用「用 defer 替代 RAII」风格编写的 Zig 代码存在资源管理缺陷。
但如果能设计出几乎不需要资源管理的架构(如 TigerBeetle 的预分配模式,或许多嵌入式系统的编译时分配),Zig 能提供显著优势。这很难------简洁总是困难的。但若选择这条路,Zig 会是非常趁手的工具。
Zig 唯一的特性------编译期(comptime)动态类型------涵盖了 Rust 大部分特殊机制。这是有代价的:复杂场景下的实例化错误更棘手。但多数案例变得更简单,因为不需要用类型系统编程。Zig 语言极其简朴:没有闭包(需要就手动打包胖指针);表达能力旨在生成精准汇编,而非高度抽象的源代码。正如 Zig 创始人 Andrew Kelley 所言,Zig 是「生成机器码的领域特定语言(DSL)」。
Zig 强烈偏好显式资源管理。许多 Rust 程序是 Web 服务器------这类程序通常需要并发处理大量独立短期请求。最自然的实现方式是为每个请求分配专用 bump 分配器:释放操作变为空转,请求结束后通过重置偏移量批量「释放」内存。这种方式高效且能天然支持按请求内存分析和限制。但主流 Rust 框架很少这样做------全局分配器足够方便,形成了强大的局部最优。Zig 强制传递分配器,促使你思考最合适的方案!
同样,Zig 标准库比 Rust 更注重分配。集合类型不像 C++ 或(未来的)Rust 那样参数化分配器,而是采用调用点依赖注入(Call Site Dependency Injection)------向每个需要分配的方法显式传递分配器。这种设计更灵活。例如 TigerBeetle 需要几个哈希映射------这些映射在启动时按需固定大小,永不调整。因此我们向初始化方法传递分配器,但不传给事件循环。这样既能使用标准哈希映射,又能确保事件循环绝无可能分配内存(因为它无法接触分配器)。
愿望清单
最后是我的 Zig 改进愿望:
- 定位清晰:Zig 的优势严格限于编写「完美」系统软件。虽然市场细分较小,但很重要。Rust 的问题在于缺乏面向可靠性的高质量高级语言(比如现代 ML),这既扩大了 Rust 的生态位(带来社区动能),也使其难以保持专注。对 Zig 而言,Rust 已扮演「现代 ML」角色,这迫使 Zig 更需要专业化。
- 语义明确:我最担忧 Zig 关于别名、来源、可变性和自引用等问题的语义。不太担心「迭代器失效」类未定义行为------TigerBeetle 运行在 -DReleaseSafe 模式(基本解决空间内存安全),不做动态内存分配(规避了时间内存安全问题),且有全面的模糊测试套件消灭剩余缺陷。真正担忧的是语言本身的语义。目前的理解是:要正确编译类 C 低级语言,必须明确指针语义。我不确定「可移植汇编」是否真实存在------可以创建很少优化且「通常符合预期」的编译器,但准确定义其行为似乎不可能。一旦深入探究指针和内存的本质,就会陷入「字节可能成为毒药」的复杂领域。Rust 尝试精确定义,但没有借用检查器几乎不可能遵守其规则------它们太微妙。当前 Zig 实现对潜在别名指针、含内部指针的结构体拷贝等概念非常模糊。希望 Zig 能明确目标语义。
- IDE 支持:此前博客讨论过。目前 Zig 开发体验不错------语言服务器虽简朴但实用,其余需求用 grep 也能很好满足。但凭借惰性编译模型和缺乏语言外元编程的特性,Zig 在这方面可以更进取。为未来 IDE 支持奠定基础,建议编译器提供面向 IDE 的基础数据模型 API:创建持久化分析进程,接收代码编辑流,无需显式编译请求即可持续更新代码模型。模型可以很简单------只需「给我当前时点的文件 AST」,高级功能后续补充。关键是改变编译器数据流形态:从编辑-编译循环转变为持续更新的世界视图。
- 自包含流程:Zig 让我强烈共鸣的价值观是偏好低依赖、自包含的流程。理想情况下只需获取 ./zig 二进制即可开始。当前最佳实践是将特定版本 ./zig 打包进项目(而非使用系统级 zig)。两方面有待改进:
-
-
获取 Zig:由于需要引导,这是个精细活。不同平台有各自的「运行代码」方式。希望 Zig 提供标准脚本(get_zig.sh/get_zig.bat)或小型可移植二进制文件,让项目能直接内置,使贡献体验完全本地化和自包含:
shell$ ./get_zig.sh $ ./zig build
-
自动化扩展:有了 ./zig 后,可用它驱动更多自动化。虽然已有 ./zig build,但软件开发不止于构建。传统上用一堆平台相关 bash 脚本解决的琐事,希望 Zig 更强势地推动用户用 Zig 编写这些自动化。举例:
shell# BAD: 依赖操作系统 $ ./scripts/deploy.sh --port 92 # OK: 无依赖但冗长 $ ./zig build task -- deploy --port 92 # GREAT: 理想方案 $ ./zig do deploy --port 92
-
总结
- Rust 关乎组合安全性,是比 Scala 更具扩展性的语言。
- Zig 追求完美。它是把锋利而危险的工具。
以上是翻译的文章,从全文的观点来看,作者可能更偏向用 Zig 开发,也许当你看到 Zig 的简洁和一些特性后,也会觉得 Zig 值得尝试,不过我又有另外的思考。
思考
虽然在纠结学习哪门语言(Rust 还是 Zig),但是这个周末我还是在思考一个问题:AI 时代的编程可能会颠覆传统的编程的思考方式?
今年以来我用了几款 AI 编程工具,如 Cursor,Windsurf,并且搭配 GPT-4.1,Claude-3.7-Sonnet 等模型,发现语言的语法其实已经不重要了,同样的功能,AI 可以用几十种语言都实现一遍,功能逻辑只要你描述的够清晰,基本上能满足大部分需求,而且随着大模型的演进,模型会越来越智能,其上下文会越来越大,需求实现可能将来的确不需要写一行代码。
如果这么说:AI 时代的编程可能会颠覆传统的编程的思考方式就是只要详细描述需求和分析场景就行?不是,AI 写的代码功能通过全面的覆盖测试验证,但是并不能确保不会内存泄露,不会有边界问题导致的Core?
那么这就利好 Rust 这类型强校验编程语言,很多内存问题可以通过编译期间直接解决,这样即使是 AI 写的代码,也减少了上线需要关注的安全性问题。
综上所述,我觉得学 Rust 之类在编译器解决大部分问题可能是更明智的选择。
参考
(1)www.scattered-thoughts.net/
(2)matklad.github.io/2023/03/26/...
(3)翻译agent:github.com/linkxzhou/m...