跳转到内容

1.1 困难三角:自动释放要付出什么代价

学一门新语言之前,先问一个根本问题:世界上已经有 C、C++,也有 Java、Python、Go,为什么还要发明 Rust?

答案落在它的首要设计目标上:同时拿到贴近硬件的运行性能与内存安全。在此之前,这两件事长期处于二选一的拉扯中。你在 Python、Java、JavaScript 里写下一行对象赋值 b = a,两个变量就同时指向了内存里的同一个对象,不需要写释放代码,程序也能安全跑下去——这份顺畅有它的来路,语言把内存管理成本转给了运行时;而在 C、C++ 里,代码直接贴着硬件运行,却要由人手写释放、自己防范内存错误。

为什么前人没有直接把高性能和高安全合在一起?要看懂 Rust 的设计目标,以及它为此付出了什么代价,我们需要先回到编程语言设计的底层逻辑,从三个相互制约的指标看起:

  • 性能(Performance):代码以多低的开销在硬件上运行;
  • 安全(Safety):程序能否避免访问已经失效的内存、读写超出有效范围等错误;
  • 表达力(Expressiveness):既指高层抽象能力,也指写代码时受多少限制、有多大自由度。

这三者之间的取舍,我们称为编程语言的困难三角。它不是非此即彼,而是程度问题——侧重其中两项,第三项就要多付代价。本节先看 Rust 最先要攻克的安全与性能这一条边:内存由谁释放、在何时释放。

性能、安全、表达力构成的困难三角三角形三个顶点分别是性能、安全、表达力。左边连接安全与性能,标注「谁在什么时候释放」;右边连接性能与表达力,标注「抽象的代价」;底边连接安全与表达力,标注「给写代码的人多大权力」。三角形中心写着「难以同时拿满」。难以同时拿满安全 ↔ 性能谁在什么时候释放性能 ↔ 表达力抽象的代价安全 ↔ 表达力给写代码的人多大权力性能performance安全safety表达力expressiveness

这条边的分歧是:内存在什么时刻释放,这个时刻由谁决定。现成的做法分两类:

  • 交给写代码的人(C / C++ 手动管理):free 或 delete 写在哪一行,内存就在哪一行释放,不需要扫描或计数来判断是否释放。代价落在安全上——释放后继续使用,或者对同一块内存释放两次,都可能造成内存错误。C++ 的 RAII 和智能指针也能自动释放,但编译器通常不检查代码是否继续访问已释放的数据。
  • 交给运行时(Java / JavaScript / Python):由运行时的回收机制找出不再使用的内存并释放。Java、JavaScript、Go 用追踪式垃圾回收(tracing GC),在后台扫描,释放时刻不确定,高并发下会有停顿或延迟抖动;CPython(常用的 Python 实现)以引用计数为主,多数对象在计数归零时即时释放,循环引用仍交给分代 GC。

两类做法放在一起对照:

维度 运行时回收(Java / JavaScript / Python / Go) 手写释放(C / C++ 手动管理)
释放时刻 追踪式垃圾回收通常不确定;CPython 的引用计数多数即时 写下 free / delete 的那一行
侧重 安全与共享的自由度:共享对象时,不必手动安排释放 性能
代价 性能:扫描或计数的 CPU 与内存、停顿、延迟抖动 需要人保证不在释放后继续使用、不释放两次,也不遗漏释放
生产环境中的典型问题 吞吐量下降、停顿、内存占用居高不下 可能崩溃或破坏数据;遗漏释放还会让内存持续被占用

性能与表达力:抽象是否增加运行时步骤。Python 里的 a + b,运行时要根据对象类型决定执行哪段加法;C 里两个 int 相加,通常可以直接编译为机器加法。高层抽象不一定有额外开销,要看哪些工作能在编译期完成。

安全与表达力:给写代码的人多大权力。能直接操作的内存地址越多,编译器能检查的就越少。C 允许把任意整数转成指针再写入,编译器无从判断那个地址是否有效;Java 不提供指针运算,数组越界在运行时被检查出来,抛出异常。

这两条边本节不展开。


Rust 的困难三角切入点:自动回收,但非 GC

Section titled “Rust 的困难三角切入点:自动回收,但非 GC”

前面的两类做法,是在做一道二选一:要安全,就接受 GC 的运行时开销;要性能,就让人自己写 free、自己担风险。

把「由谁决定释放」和「靠不靠运行时回收」交叉成四个象限,只有左下角这一格是自动回收、又没有运行时回收器——Rust 要选的就是这一格。

谁决定释放、是否依赖运行时回收:四个象限两行两列共四个象限。上行是由人决定何时释放,下行是自动回收;左列没有运行时回收器(非 GC),右列靠运行时回收(GC)。左上角是 C 的 free 和 C++ 手写的 delete;右上角不成立;左下角写着「Rust 要走的路:自动回收、非 GC」;右下角是追踪式垃圾回收与引用计数。非 GC无运行时回收器GC运行时回收人决定何时释放自动回收C 的 freeC++ 手写的 delete—不成立Rust 要走的路自动回收 · 非 GC追踪式垃圾回收引用计数

要同时兼顾安全与性能,就要走左下这一格:既要自动回收(非手动),又不在运行时靠回收器扫描或计数(非 GC)。

但顺着想一步,这看起来像个矛盾:写代码的人不写 free,程序跑起来又没有回收器在后台盯着,那程序怎么知道一块内存什么时候不再被使用、该在哪一行释放?

正因为这件事在直觉上很难兼顾,主流语言往往把「自动回收」等同于「引入 GC」。

Rust 选择走通左下这一格。当然,困难三角的制约依然存在:当安全与性能都不让步时,代价就落在了写法的约束上。

怎么在既不靠人、也不靠运行时的前提下,算出一块内存的释放时机?下一节,我们先看程序里的内存分哪两块,释放的难题出在哪一块上。