那个让我揉了三遍眼睛的数字

前几天我把一个Node.js微服务重写成了Rust。

同样的功能,同样的接口,什么都没变。跑起来一看,内存占用从1GB掉到了40MB。

我盯着终端数字看了好几秒,以为见鬼了。

你可能会说,1GB肯定是我代码写得烂。话糙理不糙,但我那个Node.js服务写得也算中规中矩,该用流的地方用流,该释放的对象都释放了。问题不在我,在于语言本身。

GC不是免费的午餐

JavaScript、Python、Java这些语言都用垃圾回收(GC)。你只管创建对象,某个后台进程会帮你收拾烂摊子。

听起来很美好?但便利是有代价的。

GC本身要吃内存。它得给自己留一片"工作区"才能运转,就像保洁阿姨进你家干活,至少得有个地方放清洁工具。

GC运行的时候,会随机暂停你的应用。想象一下,你正打着游戏,突然画面卡住0.5秒——后台正在跑垃圾回收呢。

你的垃圾不会立即消失。GC没跑之前,那些已经不需要的数据还占着内存不放。就像你家垃圾桶满了,但保洁阿姨还没来,你也只能先堆着。

我不是在说GC的坏话。对于大多数应用来说,有GC完全没问题,写的舒服最重要。但如果你:

  • 在云上跑服务,每GB内存都要花钱
  • 在树莓派上运行,硬件资源捉襟见肘
  • 做高性能网络服务,不能容忍随机卡顿

那这个"免费午餐"的代价就开始肉疼了。

Rust:不走寻常路

Rust没有GC。这点你已经知道了。

但Rust也不让你手动管理内存——不用像C那样写free(),不用担心use-after-free漏洞。

那它是怎么做到的?

答案就两个字:所有权

所有权:每个值都有一个"房东"

在Rust里,每个值都有且仅有一个所有者。就像每间房子只有一个房东一样。

当这个所有者"消失"(比如函数结束、变量作用域结束),这个值立即被清理掉。不用等GC,不用你动手,立刻马上清理。

fn main() {
    let message = String::from("hello");
    print_message(message);
    // message 在这里已经被清理了,不能再用了
}

fn print_message(text: String) {
    println!("{}", text);
} // text 在这里被清理

函数print_message执行完,text立即消失,内存立即释放。没有等待,没有延迟。

这就是Rust的核心魔法:所有权机制在编译期就规划好了一切,运行时根本不需要一个"保洁阿姨"来收拾。

借用:不买房也能住

但有时候你就是想用一下数据,并不想把它"买下来"怎么办?

借呗。

fn main() {
    let message = String::from("hello");
    let length = get_length(&message);  // 借用一下
    println!("Message: {} (length: {})", message, length);
    // message 仍然可用,因为只是借出去看了看
}

fn get_length(text: &String) -> usize {
    text.len()
} // 只借看一下,不影响原数据

&符号,你就可以"借用"数据,而不需要取得所有权。用完回来,原数据该在哪还在哪。

这就是Rust的借用检查器(Borrow Checker)在编译期检查的东西:有没有人借了东西然后不还?有没有借出去的同时还想改?这些都逃不过编译器的火眼金睛。

栈vs堆:Rust的默认选择

说完所有权,再聊一个关键区别:栈分配和堆分配

你可以把栈想象成书桌上的便签——用完就扔,随手拿。堆则是仓库里的箱子——需要申请、登记、找位置,放进去和拿出来都慢一些。

GC语言(比如JavaScript)几乎默认都用堆。 Rust则相反,能放栈上的就放栈上。

fn example() {
    let x = 42;           // 栈上:固定大小,放在栈里
    let y = Box::new(42); // 堆上:需要动态大小,用Box包装
}

这个区别带来的性能差异是巨大的。栈分配几乎没有开销,堆分配则需要:

  • 找一块足够大的空闲内存
  • 记录这块内存的位置
  • 用完了还要释放

Rust把"能栈则栈"作为默认策略,这一招就赢了大多数GC语言。

来看个对比

这是Node.js的写法:

const express = require('express');
const app = express();

app.get('/process', (req, res) => {
    const data = new Array(1000000).fill(0);
    const result = data[0];
    res.json({ result });
    // 这块内存在GC跑之前会一直占着
});

这是Rust的写法:

use actix_web::{web, App, HttpServer};

async fn process() -> String {
    let data: Vec<i32> = vec![0; 1000000];
    let result = data[0];
    format!("{}", result)
    // 函数结束,这块内存立即释放,不等任何人
}

Rust版本在函数返回的那一刻,那100万个i32占用的内存立即消失。Node.js版本则要等GC决定什么时候跑,可能几毫秒,也可能几秒。

一个请求这么点区别不明显。1000个并发请求堆在一起,内存差距就出来了。

代价与收获

说了这么多,该谈谈代价了。

Rust的所有权系统学习曲线确实陡。 编译器会频繁拒绝你的代码,每次都说"你不能这样"。你会郁闷、会烦躁,我也不例外。

借用检查器的规则刚开始特别不习惯:

  • 不能同时借用又修改
  • 借用的数据不能比原数据活得久
  • 生命周期标注有时候能把人逼疯

但当你跨过这个坎之后呢?

你会发现,你写出来的代码天然就是内存安全的。不会有use-after-free,不会有数据竞争,所有权的规则已经帮你把坑都填平了。

到底值不值?

一个普通CRUD应用?可能有点杀鸡用牛刀。

但对于:

  • 高并发服务 — 每次请求的内存峰值都很关键
  • 嵌入式系统 — 硬件资源极度有限
  • 延迟敏感场景 — 不能容忍GC暂停
  • 成本敏感业务 — 内存省一点都是钱

Rust绝对值得投入。

我见过不少团队把热路径用Rust重写后,服务器成本直接砍半。那个1GB到40MB的故事,不是个例。

说到底,Rust用所有权系统在编译期就把内存安排得明明白白。值不用了就立即消失,不用等GC来擦屁股。借用机制让你不取得所有权也能用数据,栈分配是默认选择比GC语言的堆分配高效得多。前期学习曲线确实费劲,但跨过去之后,写出来的代码天然内存安全,后期省心省钱。

如果你的项目对性能和内存有要求,不妨给Rust一个机会。


常见问题

Q: Rust的所有权和借用会不会让代码很难写? A: 刚开始确实会。编译器像严格的质检员,动不动就报错。但这些报错都是在帮你避开真正的运行时bug。等你适应之后,写代码反而更快——因为不用再纠结"这个对象什么时候释放"这种问题了。

Q: Rust真的比GC语言省那么多内存吗? A: 实际效果取决于场景。高并发、低延迟、嵌入式这些对内存敏感的场景,差异非常明显。上万个并发请求同时来的时候,GC语言可能需要几GB内存,Rust几十MB就能搞定。

Q: 存量Node.js项目值得迁移到Rust吗? A: 不建议为了迁移而迁移。如果当前架构在性能、内存、延迟上已经满足需求,没必要大动干戈。但如果遇到性能瓶颈、内存涨不停、GC暂停影响用户体验这些问题,迁移热路径到Rust是值得考虑的方案。


你在项目中有遇到过GC带来的问题吗?或者对Rust的所有权系统有什么疑问?欢迎在评论区聊聊。