1. 为什么我会同时折腾 Rust、Go 和 Zig
我手头有一台 8G 内存的旧笔记本,某周末想写一个网络代理做本地流量转发。第一反应是用 Go,因为 goroutine 和 net 包实在太顺手;但一想到要精确控制每个连接的内存缓冲,Go 的 GC 和 slice 逃逸又让我心里没底。换 Rust 吧,借用检查器会逼着我认真设计每个生命周期,可这个项目只是周末玩具,我不想把时间全花在跟编译器斗智斗勇上。恰好当时 Zig 发布了新版本,于是我做了一个后来被朋友吐槽"自找麻烦"的决定:同一个代理,用三种语言各写一遍。
这个经历让我对三者的定位有了完全不同的理解。网上关于"Rust 与 Go 之争"的讨论已经够多了,但很少有人认真问一个问题:为什么在 2024 年还有人想造一个新的系统级语言?如果你也有过这种选型摇摆,或者在学 Rust 时被生命周期搞到自闭、写 Go 时又觉得对底层失控,那么这篇从一个普通开发者视角出发的对比笔记,应该能提供一点参考。
先说结论:Zig 不会取代 Rust,也不会取代 Go,但它很可能改写"系统级编程"的边界。它没有 Rust 那样严格的所有权模型,没有 Go 那种托管运行时,却提供了非常接近 C 的性能和更好的安全体验。它不是"最终答案",而是很多人都没意识到的一块拼图。
2. Rust 的"复杂"到底消耗在哪里:不是曲线陡峭,而是日常心智税
2.1 所有权不是规则,而是"设计约束前置"
Rust 学习者最常说的一句话是"借我检查器教我写代码"。这句话看似正面,实际上隐藏了一个关键问题:所有权系统把本该在运行时暴露的问题,提前到了编译期,这当然是好事,但代价是你必须在动手写业务逻辑之前,就把数据结构之间的关系、谁是所有者的边界问题想清楚。
我在写代理项目时体会极深。一个简单的HashMap<SocketAddr, Connection>结构,如果不提前想好 Connection 里放的是引用还是所有权、是否需要Arc<Mutex<...>>,等代码写到 100 行,编译器会用一串红色错误告诉你:重新设计吧。Rust 的 borrow checker 不是在"检查"你的代码,而是在强制你采用一种特定的思维组织方式。这套思维方式长期看收益巨大,但短期看就是实打实的心智税。
更让人头疼的是'static和生命周期标注。写业务代码时,各种库里动不动就要求T: 'static,新手可能只是照抄Box::new或tokio::spawn的写法,完全不懂为什么这里非得加一个生命周期约束。这不是"曲线陡峭"的问题,而是每次编码都要付出额外的认知成本。Rust 的文档和生态非常优秀,但它的优秀恰恰建立在一套庞大而严谨的类型系统之上,这意味着你不可能跳过底层机制去写高端代码。
2.2 async 生态的 Send 边界问题
Rust 的 async 生态在系统级编程里独树一帜,但真正用过的人都知道,这里暗坑很多。tokio::spawn要求 Future 是Send + 'static,于是你不得不在每个结构体里塞Arc<Mutex<T>>,或者设计很多无锁数据结构来绕过锁的负担。更别提早期async_trait宏是标配,到如今标准库逐渐支持 async fn in trait,但各种兼容性问题仍然在不断消耗开发者的精力。
我见过太多人把上半年的时间花在"为什么我的 Future 不满足 Send"这个问题上,最后发现是一个库的底层结构没有实现 Send。这个问题在 Go 里根本不存在,在 Zig 里你甚至不需要考虑 async,因为标准库直接把 async 移除了,你可以用非阻塞 I/O 自己控制。Rust 的 async 确实是高性能高并发的利器,但它的复杂性不像 Go 的 goroutine 那样透明,而是隐藏在 trait 约束和生命周期幽灵里。
2.3 宏:复杂度被藏起来,但没有减少
Rust 的宏系统(特别是过程宏)确实强大,serde的#[derive(Serialize)]写起来优雅得让人上瘾。但你有没有想过,当宏展开出错时的报错信息会让你怀疑人生?当你需要调试一个宏展开的中间结果时,cargo expand几乎是唯一手段,而面对几百行展开代码的体验并不愉快。
宏本质上是在"延迟"复杂性:它把重复代码的生成交给编译器,但生成之后的代码仍然要遵循所有权规则,仍然要处理生命周期,仍然要满足 trait 约束。换句话说,宏简化了书写,但没有简化理解。你在 Rust 生态里可以用很短的代码调用别人封装的复杂抽象,可一旦抽象不满足你的场景,拆开底层时面对的就是成倍的信息量。
3. Go 的极简带来的天花板:当"不用想"变成"没法想"
3.1 GC 是系统级编程绕不过去的坎
Go 的垃圾回收经过这么多年的优化,做云服务、API 网关、网络中间件已经完全没问题。我写过的 Go 服务,日常应用场景下 GC 停顿几乎可以忽略。但系统级编程的边界不止是"日常应用"。当你写的是实时音频处理、高频交易、嵌入式网关、游戏引擎时,GC 暂停哪怕只有几毫秒,也可能让整个产品的体验崩盘。
Go 的哲学是"让开发者不用考虑内存",这本身很伟大。但系统级编程的很多场景恰恰需要你考虑内存:缓冲区复用、内存池、缓存的命中率。Go 提供了sync.Pool和unsafe指针,但这些手段要么有使用限制,要么需要绕过类型系统,写起来很不"Go"。当你想真正控制内存布局时,GC 的存在就像一个大管家,你没法完全绕开他去安排家具摆放。
3.2 泛型虽然来了,但迟到与不彻底
Go 在 1.18 加入泛型,解决了interface{}装箱和类型断言的痛点,也引入了constraints.Ordered等一堆新概念。但用过 Rust 或 Zig 的泛型再回来看 Go 的泛型,你会发现它仍然很"克制":没有 trait 概念,只能用接口约束方法集,无法做类型的关联常量或编译期计算。这导致一些需要高性能数据结构的场景,Go 泛型的表现还是不如 C++/Rust 那样接近零成本抽象。
Go 的极简风格让初学者上手极快,但深入下去,这种极简会变成一种抽象能力的上限。你无法用 Go 写出真正的泛型线性代数库,也不太可能在编译期进行数值计算来替换运行时常量。这些功能在系统级编程中经常被忽略,可一旦你需要,Go 就给不出来了。
3.3 底层控制力不足:内存布局、无 GC 的现实问题
在写代理的时候,我需要为每个连接维护一个可回收的读写缓冲区。在 Zig 里我可以直接做一个内存池,把空闲块放在链表里,严格掌控分配策略。在 Go 里也有sync.Pool,但它的回收时机是堆 GC 来决定的,不保证立刻释放。结果就是,Go 版本的高负载长长尾延迟比 Rust/Zig 明显,而且无法通过调优代码消除。
另一个痛点是与 C 库的交互。CGo 虽然能调用 C 代码,但每次跨越 Go/C 边界都有固定的上下文切换开销,而且 C 指针在 Go 里受 GC 扫描规则限制,不能方便地持有 Go 堆对象。这意味着很多现成的 C 生态库,在 Go 里并不能做到零成本封装。相比之下,Zig 和 Rust 都能更自然地与 C ABI 对接,Zig 甚至可以直接导入 C 头文件。
我并不是说 Go 不好。Go 在工程效率、团队协作、部署运维方面,依然是当前最好的选择之一。但如果你要"触碰底层",它确实提供了屏障,而不是帮助。
4. Zig 的破局方式:不是说"更简单",而是把复杂放对位置
4.1 comptime:用编译期执行替代宏与泛型
Zig 最大的创新,很多人会说是「没有隐式内存分配」,但我认为真正让 Zig 与众不同的是comptime。简单说,comptime让你在编译期执行 Zig 代码,生成类型、常量、代码分支,而这一切都有完整的类型检查和语法支持。
比如你写一个支持任意整数类型的Point:
fn Point(comptime T: type) type { return struct { x: T, y: T, fn distanceSquared(self: @This()) T { return self.x * self.x + self.y * self.y; } }; } pub fn main() void { const PointF64 = Point(f64); const p = PointF64{ .x = 3.0, .y = 4.0 }; std.debug.print("{d}\n", .{p.distanceSquared()}); }这段代码在编译期就会生成一个具体的结构体,没有运行时开销,也没有额外的类型装箱。比起 C 的宏,comptime的类型正确性有保证;比起 Rust 的泛型与 trait 组合,comptime更像"直接在语言里写编译期脚本",限制更少,表达更直观。写多了你会发现,它足以替代绝大多数宏和模板元编程的需求。
4.2 错误联合与 defer:错误处理该有的样子
Zig 的错误处理是我个人最喜欢的设计之一。它用一个!操作符表示"这个函数返回结果或错误":
fn readUserFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 { const file = try std.fs.cwd().openFile(path, .{}); defer file.close(); return try file.readToEndAlloc(allocator, 1024 * 1024); } pub fn main() !void { const contents = try readUserFile(std.heap.page_allocator, "user.txt"); defer std.heap.page_allocator.free(contents); std.debug.print("{s}", .{contents}); }关键点在于try和defer。try遇到错误就提前返回,defer保证无论返回值还是返回错误,都会执行清理动作。这比 Go 的if err != nil简洁,比 Rust 的?+ RAII 更容易读懂资源何时释放。Zig 没有 RAII,你不必为每个类型实现 Drop,而是显式地在作用域内安排defer释放,心智模型非常直观。
调试模式下 Zig 还会保留完整的错误追踪栈,线上遇到错误,打印出来的堆栈信息能精确到每个函数的返回值。这种"错误即返回路径"的设计,我实际用下来觉得比异常机制和 Go 的错误检查都更容易写出可靠的代码。
4.3 Allocator 显式传递:内存责任的回归
Zig 标准库里几乎所有需要分配内存的函数,第一个参数都必须传入一个Allocator。这个设计初看很繁琐,但它的好处是巨大的:谁分配、谁释放、用什么策略分配,全都在函数签名里暴露出来。于是你可以轻松做到:
- 用
std.heap.GeneralPurposeAllocator做全局分配器,开启安全检查 - 用
ArenaAllocator做一次性批量分配,结束时整块释放 - 用自己实现的
GPA或专用内存池来精确控制性能 - 在编译期就禁用某些库的分配功能,因为这取决于传入的 Allocator 实例
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); defer arena.deinit(); const allocator = arena.allocator(); const list = try std.ArrayList(u8).initCapacity(allocator, 16);这段代码里,ArrayList的所有内存都来自arena,函数结束时一次性释放,不会再有漏内存的隐患。对比 Go,你无法让某个 goroutine 用一块独立的堆内存运行;对比 Rust,Allocator需要做额外的抽象设计(目前 nightly 还在推进)。Zig 把内存策略的选择权和责任彻底交还给开发者,这种设计哲学显然更贴近系统级编程的本质。
5. 同一个代理项目三种写法:我在旧笔记本上的实测记录
5.1 错误处理路径对比
我截取代理中的一个核心函数:读取配置文件并解析键值对,如果文件不存在或内容格式错误,返回错误信息。三种语言的写法差异非常能说明问题。
Rust 版本(使用?运算符和thiserror的典型风格):
use std::fs::File; use std::io::Read; fn load_config(path: &str) -> Result<String, std::io::Error> { let mut file = File::open(path)?; let mut contents = String::new(); file.read_to_string(&mut contents)?; Ok(contents) }Go 版本:
func loadConfig(path string) (string, error) { contents, err := os.ReadFile(path) if err != nil { return "", err } return string(contents), nil }Zig 版本:
fn loadConfig(path: []const u8) ![]const u8 { const file = try std.fs.cwd().openFile(path, .{}); defer file.close(); return try file.readToEndAlloc(std.heap.page_allocator, 1024 * 1024); }三种代码都非常清晰,但注意几个细节:Rust 需要把错误类型写进签名(这里的std::io::Error),如果底层有不同的错误类型,还需要做转换;Go 手动返回error,会导致每个调用点多一行if err != nil;Zig 的try会自动传播错误,且错误类型是匿名的,调用方可以用catch或switch精细处理具体错误码。
提示:Zig 的错误联合类型和 Rust 的
Result其实非常相似,差别在于 Zig 不需要写错误类型的泛型参数。错误在 Zig 里本质上是一组全局错误表的索引,处理开销极小。
5.2 编译速度、二进制体积与内存表现
我在同样的硬件上,用三种语言编译了相同功能的最小代理程序,结果如下:
| 维度 | Go 1.22 | Rust 1.77 (tokio) | Zig 0.13 |
|---|---|---|---|
| 冷编译时间(含依赖拉取) | 约 15 秒 | 约 1 分 20 秒 | 约 40 秒 |
| 最终二进制体积 | 约 8 MB(strip 后) | 约 5 MB(strip 后) | 约 200 KB |
| 空载时 RSS 内存 | 约 2.5 MB | 约 1.2 MB | 约 0.4 MB |
| 高并发连接时内存峰值 | 较高,受 GC 影响 | 较低,整体可控 | 最低,完全手动 |
| 错误排查体验 | 日志 + 堆栈 | Debug 断言 + 可读错误链 | Debug 模式完整错误轨迹 |
这里要说明,Go 的编译速度和部署便利优势是真实的,这也是它被大量云原生项目选中的原因。Rust 的二进制虽然也很小,但因为依赖 tokio 等异步运行时,最终体积和编译时间都明显上升。Zig 没有自带运行时,二进制几乎可以和 C 程序比肩,这在嵌入式、无容器环境、追求冷启动速度的场景下非常实用。
当然,我不建议把上面这组数字当作严格基准。它只是我在旧笔记本上的个人实测,硬件、依赖版本、编译选项不同,结果会有波动。但"Zig 的二进制非常小、内存非常可控"这点,在各类公开对比中同样是普遍结论。
5.3 修改迭代时的体验差异
写代理过程中,我经历了几次结构改动,最能体现三者差异的是"添加一个超时字段"这种小事:
- 在 Go 里:加字段,初始化时赋值,读写逻辑里取值,编译运行,一气呵成,甚至不需要测试覆盖率特别高。代码确实"不用想",但我心里也清楚,任何问题都不会在编译期被拦截,全靠运行测试。
- 在 Rust 里:加字段,如果这个类型派生了很多 trait(
Debug、Clone、Serialize等),会触发大量引用点的类型错误。改完后你会获得极强的编译期保障,但过程中的红色错误消息和多次cargo check等待时间,确实会让迭代变慢。 - 在 Zig 里:加字段不会引发大量连锁错误,因为 Zig 默认没有自动派生的行为。编译器会告诉你该字段没有被初始化,但不会像 Rust 那样蔓延到所有构造点。代价是你需要自己写序列化/克隆代码(或者用第三方库),标准库没有提供与 Rust 同等的
derive机制。
这三者的体验没有绝对好坏,只有取舍。Go 让你快速跑起来;Rust 让你在编译期就建立完整性模型;Zig 则更像 C 的改良版,给你自由,但要你自己管理边界。
6. Zig 的现状与坑:它能进生产环境了吗
6.1 生态成熟度:还处在"值得关注、谨慎上车"阶段
实话说,Zig 的生态目前(0.13/0.14 时代)离成熟还有明显距离。标准库覆盖了常见数据结构(ArrayList、StringHashMap、PriorityQueue 等),网络库有std.http,但功能远不如 tokio 或 Go 的 net 库那么完整。第三方生态里值得关注的有:
zig-http、h11等 HTTP 底层实现,但 API 变化快zap:基于水平,封装了 http_parser 和 ev,但成熟度较低- 图形库、音频库、游戏引擎的 Zig 绑定大多处在概念验证阶段
- 通过
@cImport导入 C 库是主力方案,但要注意 C 库的构建依赖
如果你是做一个嵌入式固件、CLI、编译器、解释器、游戏引擎的底层模块,Zig 完全可以上手;如果你要快速做一个标准 Web 后端服务,那我目前的建议仍然是 Go 或 Rust。
6.2 版本迭代带来的 API 变动:最大的学习成本
我用 Zig 写的第一个程序,是参照一篇 0.11 时代的博客。结果 0.12 一发布,std.fs.cwd().openFile的签名变了,std.heap.GeneralPurposeAllocator的初始化方式也变了。最离谱的是 async 相关 API,Zig 在 0.11 里直接移除了 async/await,计划未来重新设计。这意味着任何一年前学习 Zig 的资料,都可能有过期风险。
这也引出一个实际问题:如果你希望语言生态稳定,Zig 目前不是最好的选择。Linus Torvalds 曾在邮件里评价过 Zig,说它"有点意思,但还在不断自我突破"。这句话很准确:Zig 的自我突破精神值得尊重,但作为生产依赖确实需要耐心等待 1.0 之后 API 冻结。
提示:如果你是初学者,建议直接锁定一个最近的稳定版本(如 0.13 或 0.14),并用
zig init生成的项目模板开始,不要盲目下载最新的 master 构建。官方文档和 release notes 是比网上教程更可信的信息源。
6.3 构建系统和交叉编译:真正让人眼前一亮的部分
Zig 的build.zig是一个用 Zig 语言写的构建脚本,而不是 C 的 Makefile 或 CMake 脚本。它把依赖管理、编译选项、目标平台、输出文件全部用代码表达,调试起来比 CMake 舒服得多。一个最小项目只需要:
zig init zig build run生成式的重复代码很少,所有配置都是强类型,错误信息也是 Zig 编译器输出的可读格式。
最让我惊喜的是交叉编译能力。我在旧笔记本上为树莓派和 Windows 交叉编译代理程序,以往用 Rust 时需要rustup target add,还要装对应平台的链接器;用 Go 时虽然简单,但 CGo 场景会麻烦。Zig 内置了针对几乎所有主流 target 的 libc 和链接器,我只需:
zig build -Dtarget=aarch64-linux-gnu zig build -Dtarget=x86_64-windows-gnu zig build -Dtarget=arm-linux-musleabihf整个过程没有额外安装任何工具链,这体验甚至比 Go 还要好,因为 Zig 连 musl libc 都自带了,不需要手动配置 CGO_ENABLED 之类的东西。
7. "最终答案"这个概念本身就是错的
回到标题的问题:Zig 会是系统级编程的最终答案吗?
我的观点是:把任何一门语言称为"最终答案",都是对现实复杂性的低估。Rust、Go、Zig 解决的问题域不同,优势与代价也不同,更适合以"工具-场景"的方式来看待:
| 场景 | 更推荐 | 原因 |
|---|---|---|
| 云服务、Web API、CLI 工具 | Go | 开发效率极高,部署简单,生态成熟 |
| 高性能基础软件、安全关键系统 | Rust | 编译期强保证,生态最丰富,适合大型项目 |
| 嵌入式、驱动程序、网络设备、零运行时环境 | Zig | 无 GC、无运行时、二进制极小、直接对接 C ABI |
| 与既有 C 代码库深度集成 | Zig | 可编译 C 代码,跨平台交叉编译体验最好 |
| 学习系统级编程底层原理 | Zig | 显式内存管理、无抽象混乱,比 C 更安全,比 Rust 更容易入门底层机制 |
我自己的体会是,这三门语言不是取代关系,而是在不同的层次上服务不同的人。Rust 教你把安全变成类型系统的一部分,Go 教你把并发变成一种日常思维,Zig 教你把内存和 C 生态的复杂性重新捡起来,并用现代语言设计把它们整理得井井有条。
如果非要给一句建议:如果你已经有 C 基础,却受够了 C 的脆弱;如果你欣赏 Rust 的严谨,又不喜欢它的约束;如果你喜欢 Go 的开发效率,又希望得到更底层的控制力,那 Zig 确实值得你花一个周末试试。它未必是终点,但它让我重新觉得,系统级编程还有新的可能性,这本身就是它最大的价值。