主题
字号
CHAPTER 01 ≈ 20 MIN READ

为什么会有 Rust——从 C 的原罪说起

1.1 C统治世界六十年靠的是什么

1.1.1 一门语言的长寿秘诀

C语言诞生于1972年。那是阿波罗11号登月后三年,Unix刚刚被Dennis Ritchie和Ken Thompson在贝尔实验室里折腾出来的年代。那时候的程序员写代码,基本上是在和汇编语言搏斗——每换一台机器,代码就得重写一遍,因为不同处理器的指令集完全不同。

C的出现解决了这个问题。它提供了一层薄薄的抽象:你写的是C,编译器帮你翻译成对应机器的汇编。这听起来简单,但在当时是革命性的。你终于可以写一份代码,然后在不同平台上编译运行——这个特性被称为"可移植性"(portability),它是C统治半个世纪的第一块基石。

第二块基石是性能。C的抽象极其"薄"——薄到几乎透明。你写下的每一行C代码,基本上都能预测它会被编译成什么样的机器指令。没有隐藏的垃圾回收,没有虚拟机,没有运行时解释器,就是原始的机器码。这个特性让C成了性能敏感场景的首选:操作系统内核、数据库、网络协议栈、嵌入式系统。

第三块基石是时间积累出来的生态。六十年来,几乎所有重要的系统软件都是用C写的或者暴露C接口的。Linux内核是C,Windows内核是C,macOS内核的核心部分是C,MySQL、PostgreSQL是C,Python的解释器CPython是C,Ruby的MRI是C,甚至V8——那个驱动Chrome和Node.js的JavaScript引擎——它的底层也大量使用了C和C++。你想和任何操作系统、任何硬件、任何已有的库打交道,你迟早要和C的ABI(应用二进制接口)握手。

所以C的长寿不是偶然。它的成功是性能、可移植性和生态三者共同构成的护城河。

1.1.2 C的设计哲学:相信程序员

但C有一个非常鲜明的设计哲学,几乎是它所有问题的根源:相信程序员

C假设程序员知道自己在做什么。你说你要访问数组的第100个元素?好,直接给你。你说要释放这块内存?好,释放了。你说要把这个指针当成另一个类型用?好,强转。

这个哲学在1970年代是合理的。那时候写C的人大多是系统程序员,他们理解底层,知道内存怎么回事。而且程序规模小,团队也小,一个人可以把整个代码库装进脑子里。

但软件工程发展到今天,代码库动辄几百万行,团队几十上百人,没有任何人能完整理解整个系统。在这种规模下,"相信程序员"变成了一场赌注——你在赌每个人每次都不会犯错。

结果怎么样?我们来看数据。

1.1.3 六十年积累的技术债

微软在2019年公开了一组数据:他们过去十年里修复的CVE(公开漏洞)中,70%是内存安全问题。谷歌分析了Chromium项目的漏洞,得出了相同的结论:70%的严重安全漏洞是内存安全问题。NSA(美国国家安全局)在2022年发布的报告里直接建议组织机构迁移到内存安全语言,点名批评了C和C++。

这70%不是一个偶然的数字。它是C语言设计哲学在工业规模下的必然代价。

内存安全问题本质上是:程序访问了它不应该访问的内存,或者用错了它应该管理的内存。这些问题有几个经典形式:

缓冲区溢出(Buffer Overflow):你有一个长度为10的数组,但你往第15个位置写了数据。C不会阻止你,它会乖乖地把数据写进去,但那块内存可能属于别的变量,甚至是函数的返回地址。经典的栈溢出攻击就是利用这个——覆盖返回地址,让程序跳转到攻击者控制的代码。

// 这段C代码会编译,会运行,但它是个定时炸弹
char buf[10];
strcpy(buf, "这个字符串比10个字节长多了");
// buf溢出了,覆盖了栈上的其他数据

Use-After-Free(释放后使用):你释放了一块内存,但还保留着指向它的指针,之后又用这个指针去读写数据。那块内存可能已经被分配给别的用途了,你在读的可能是完全不相关的数据,更糟糕的情况是你在写,覆盖了别人的数据。

int *p = malloc(sizeof(int));
*p = 42;
free(p);
// p现在是"悬垂指针"(dangling pointer)
// 下面这行可能读到垃圾值,也可能导致崩溃,也可能看起来正常——不确定性是最可怕的
printf("%d\n", *p);

Double Free(重复释放):同一块内存释放两次。malloc/free的内部数据结构会被破坏,导致堆腐败(heap corruption),这是一类极难调试的问题。

空指针解引用:访问NULL指针。这个比前面几个"温柔"——通常直接崩溃,但总比悄悄破坏数据要好一点。

这些问题在一个几千行的学生项目里可能永远不会暴露,但在一个运行几年的生产系统里,它们是定时炸弹。最危险的不是崩溃,而是那种"程序还在跑,但数据已经悄悄错了"的情况。

1.2 内存安全:那70%的漏洞从哪来

1.2.1 内存管理的两种思路

人类在几十年里其实想到了两种主要方式来缓解内存安全问题:

第一种:垃圾回收(Garbage Collection,GC)。

让语言运行时自动追踪哪些内存不再被使用,然后定期回收。Java、Python、Go、JavaScript、C#都采用了这个方案。从根本上消灭了Use-After-Free和Double Free——因为你根本没有机会手动释放内存,运行时会帮你搞定。

代价是什么?GC需要在运行时追踪所有对象的引用,这有额外的CPU开销和内存开销,更关键的是GC的"停顿"问题——GC运行的时候程序可能暂停几毫秒甚至几百毫秒,这对延迟敏感的应用是灾难。此外,GC语言很难精确控制内存的分配和释放时机,写操作系统内核这种需要精确控制资源的场景,GC基本没有用武之地。

第二种:程序员自律。

继续用手动内存管理,但依靠代码审查、sanitizer工具(比如AddressSanitizer、Valgrind)、fuzzing测试来发现问题。这是C/C++的现实路径。

代价也很明显:问题往往在生产环境才暴露,调试成本极高,而且总有漏网之鱼。那70%的数字就是这条路的成绩单。

Rust的思路是第三条路:在编译时通过类型系统和所有权规则保证内存安全,不依赖GC,也不依赖程序员自律。

这听起来很玄,但这正是Rust最核心的创新,我们在第二章会深入讲。这里先继续看C++的故事。

1.2.2 为什么sanitizer不够用

有人可能会说,现在有AddressSanitizer(ASan)这样的工具,能在运行时检测大部分内存错误,为什么还需要新语言?

ASan确实很强大,它在程序运行时插入检测代码,能发现大部分Use-After-Free和缓冲区溢出。Google内部大规模使用了它,发现了大量真实的漏洞。

但ASan是运行时工具,有几个根本性的限制:

首先,它有性能开销,通常让程序慢2到10倍,所以不能在生产环境开启,只能在测试环境用。问题是测试环境覆盖的代码路径有限,那些只在特殊输入或极端负载下才触发的bug,测试可能永远跑不到。

其次,它检测不了所有问题。逻辑上的错误(比如把错误的值写进了正确的地址)、数据竞争(多线程情况下的内存安全问题)这些情况需要其他工具,而这些工具加在一起也没法做到100%覆盖。

最后,这套工具链的运营本身就是一个负担。你需要有专门的基础设施来跑这些工具,需要有人来分析报告,需要有流程来保证每次提交都经过这些检查。这在小团队或者资金不足的项目里根本玩不起来。

Rust的承诺是:你不用跑任何额外的工具,编译通过的代码在内存安全方面就是有保证的(注意,这里说的是内存安全,不是逻辑正确——Rust不能保证你的算法是对的,只能保证你不会越界访问内存)。

1.3 C++试图修补,但越补越复杂

1.3.1 C++的历史使命

C++出现于1980年代,Bjarne Stroustrup在C的基础上加入了面向对象编程(类、继承、多态)和泛型编程(模板)。它的核心定位是:保留C的性能和底层控制能力,同时提供更高级的抽象机制,让大规模软件工程成为可能。

在相当长的时间里,C++完成了这个使命。游戏引擎(Unreal Engine)、浏览器(Chrome、Firefox的核心)、数据库(MySQL、MongoDB)、交易系统,C++几乎是性能敏感领域的标配。

但C++也继承了C的内存安全问题,而且因为语言本身越来越复杂,问题变得更难处理。

1.3.2 智能指针:一个局部解

C++11(2011年发布的C++标准,是一次重大更新)引入了智能指针:unique_ptrshared_ptrweak_ptr

智能指针的思路是RAII(Resource Acquisition Is Initialization,资源获取即初始化):把资源(内存、文件句柄等)绑定到对象的生命周期,对象销毁时自动释放资源。这样就不用手动调用delete,消除了大部分的手动释放错误。

// 现代C++的做法
auto p = std::make_unique<int>(42);
// 不需要手动delete,p离开作用域时自动释放

// shared_ptr用于多个所有者共享
auto s1 = std::make_shared<int>(100);
auto s2 = s1; // s1和s2共享同一块内存
// 两者都离开作用域时,内存才会被释放

这比裸指针好多了,但不是没有代价:

shared_ptr用引用计数来追踪有多少个指针共享同一块内存,引用计数的增减是有性能开销的,而且在多线程情况下,原子操作的开销更大。更糟糕的是,shared_ptr无法解决循环引用问题——A持有B的shared_ptr,B也持有A的shared_ptr,两者都不会被释放,导致内存泄漏。weak_ptr是为了打破这种循环而存在的,但这意味着你必须在设计时就想清楚哪些引用是"强"的哪些是"弱"的,这本身就是一种认知负担。

更根本的问题是:C++里这些都是"可选的"。没有人强制你用智能指针,你完全可以继续写newdelete。旧的代码库用的是旧的写法,新人可能不知道最佳实践,两种风格的代码可能共存于同一个项目里。这种"你可以这样做,但也可以不这样做"的设计,在大型团队里几乎必然导致问题。

1.3.3 C++委员会:百年老店的治理困境

C++标准每三年更新一次(C++11、C++14、C++17、C++20、C++23……)。每次更新都会引入一些新特性来解决现有的痛点,但也引入了新的复杂性。

C++现在是一门极其复杂的语言。"核心C++"、"现代C++"、"惯用C++"……不同年代、不同流派的C++代码放在一起,像是考古现场。你在一个项目里可能同时看到1990年代的C风格代码、2000年代的STL惯用法、2011年以后的lambda和智能指针。

一个残酷的现实:大量的真实C++代码库根本没有全面迁移到"现代C++"的激励——因为重构风险太大,因为旧代码"能用",因为工程师的时间更值钱

C++核心指南(C++ Core Guidelines)是Bjarne Stroustrup本人和Herb Sutter推动的一份最佳实践文档,试图用非正式规范告诉人们怎么安全地写C++。这份文档很好,但它是文档,不是编译器。编译器不会拒绝违反Core Guidelines的代码。

Rust选择了完全不同的路:把安全规则编进语言里,让编译器执行,没有商量余地。

1.3.4 C++的数据竞争问题

内存安全还有一个维度C++没有很好地解决:多线程下的数据竞争(data race)。

当两个线程同时访问同一块数据,至少一个在写,而又没有适当的同步机制,就发生了数据竞争。数据竞争是未定义行为(undefined behavior),程序的结果完全不可预期——可能崩溃,可能输出错误结果,可能在99%的情况下运行正常但在某个特定的时序下出问题。

多线程的数据竞争bug是最难重现、最难调试的问题之一,因为它们往往和时序有关,在调试模式下可能永远不出现。C++提供了mutexatomic等工具,但用不用、用对没有,编译器不管。ThreadSanitizer(TSan)可以在运行时检测,但有性能开销,且不能在生产环境持续运行。

Rust的所有权系统在设计之初就考虑了并发安全,可以在编译时排除大量数据竞争。这是Rust的另一个重要卖点,我们也留到第二章细讲。

1.4 Mozilla的工程师为什么要重新造轮子

1.4.1 一个具体的工程问题

2009年前后,Mozilla的一位工程师Graydon Hoare开始在业余时间开发Rust。他在Mozilla工作,参与了Firefox浏览器的开发,深知C++大型项目的痛苦。

浏览器是一个极其复杂的软件。它需要解析HTML、CSS、JavaScript,执行脚本,渲染页面,处理网络请求,管理内存……而且现代网页里的JavaScript是不可信任的——来自任何网站的JS代码都在你的浏览器里运行,如果浏览器有内存安全漏洞,恶意JS就可以利用它来逃出沙箱,危害整个系统。

这就是为什么浏览器的安全性如此重要,也是为什么浏览器的漏洞赏金计划往往出手阔绰——发现一个可靠的远程代码执行漏洞,赏金可以高达数十万美元。

Firefox当时面临的问题是:Gecko(Firefox的渲染引擎)是一个庞大的C++代码库,维护困难,安全漏洞不断,而且单线程的架构在多核时代越来越显得落伍。Chrome的出现(2008年)证明了多进程/多线程架构在性能和安全性上的优势,Mozilla需要跟上。

在这个背景下,Mozilla开始认真对待Rust项目,在2010年将其变成官方项目。核心目标很清晰:开发一门可以安全地写并发系统软件的语言,用来重写Firefox的渲染引擎

1.4.2 Servo:那个"实验引擎"

Servo项目是Mozilla用Rust开发的实验性浏览器引擎,可以理解为Rust语言的第一个"杀手级应用"。

Servo的目标是展示一个从零开始用Rust构建的浏览器引擎可以做到什么:更好的内存安全性、天然的并行化(CSS样式计算、布局、渲染都可以并行)。它不是Firefox的替代品,而是一个研究项目,用来探索下一代浏览器引擎的设计。

Servo对Rust的重要意义在于:它是一个真实的、大规模的、面临真实挑战的工程项目,用来检验Rust语言设计的实际效果。很多Rust的特性和优化都是在Servo的开发过程中被发现需要、然后加入语言的。语言和工程项目同步演化,这让Rust避免了很多"学术语言"的问题——理论上很美,但实际工程中处处掣肘。

Servo最终并没有成为Firefox的核心,Mozilla在2020年因为裁员停止了对Servo的大规模投入,Servo后来转移到Linux基金会继续发展。但Servo项目中开发的几个关键组件,包括CSS引擎Stylo,确实被集成进了Firefox,并且带来了显著的性能提升。

Servo的故事告诉我们:Rust不是在象牙塔里设计的,它从诞生之初就是在真实的工程压力下锤炼的。

1.4.3 为什么不直接用Go?

Go语言在2009年正式开源,2012年发布1.0版本,和Rust几乎是同时代的产物。既然Go也是一门新的系统级语言,Mozilla为什么不用Go?

这是个好问题,答案揭示了Rust和Go的本质差异。

Go选择了垃圾回收。Go的GC已经相当不错,延迟很低,但它仍然存在停顿,仍然有运行时开销,仍然不能精确控制内存分配时机。对于写一个Web服务、一个CLI工具、一个DevOps工具,Go是极好的选择——上手快,并发模型简洁(goroutine),生态好。

但写浏览器引擎?写操作系统组件?不行。你不能接受GC停顿带来的渲染卡顿,你不能放弃对内存布局的精确控制,你不能引入一个运行时——因为在嵌入式或内核场景里,根本没有运行时的立锥之地。

Mozilla需要的是:没有GC、没有运行时、性能可以媲美C++,但内存安全有保障的语言。这个需求精确地描述了Rust,而不是Go。

1.5 Rust的诞生:一个浏览器引擎的副产品

1.5.1 从个人项目到正式语言

Rust的历史大概可以这样概括:

从2006年到2015年,Rust用了将近十年时间才发布1.0。这是一个漫长的孕育期,但正是这个过程保证了Rust核心设计的成熟——特别是所有权系统,它在发布前经历了无数次修改和验证。

1.5.2 Rust团队的核心决策

Rust的设计过程中做了几个关键决策,值得单独说说,因为它们解释了Rust为什么是今天这个样子:

决策一:去掉GC,但不依赖程序员自律。

这是Rust最根本的赌注。去掉GC意味着没有运行时开销,可以做到真正的零成本抽象,可以用于内核和嵌入式。但去掉GC又不能依赖程序员手动管理——因为那条路已经被C/C++的几十年历史证明是不可靠的。答案是:所有权系统,把内存安全规则编进类型系统和编译器里。

决策二:错误必须在编译时暴露。

Rust的编译器(rustc)以严格著称,甚至有点出了名。很多第一次接触Rust的人会被rustc的错误信息数量震惊。但这是设计取舍:宁可让你在编译时多折腾几下,也不要让你在生产环境遇到悬垂指针。"如果能编译,基本上就是对的"——这句话在Rust社区流传,虽然有点夸张,但反映了这个设计哲学。

决策三:不做语法糖,做真正的零成本抽象。

Rust提供了很多高级特性——迭代器、闭包、trait(类似接口)——但这些特性在编译后会被优化成和手写的底层代码等价的机器码,没有额外的运行时开销。这和Python的高级特性不同,Python的列表推导式背后有解释器开销;也和Java不同,Java的泛型有类型擦除。Rust的抽象是真的"零成本"。

决策四:并发安全和内存安全用同一套机制保证。

这个决策的优雅之处在于,所有权系统在保证内存安全的同时,几乎自然地也保证了线程安全。Rust编译器可以在编译时检测大量的数据竞争,而不需要额外的并发分析工具。

1.5.3 Rust今天是什么

用一句话概括:Rust是一门系统编程语言,它的目标是在不引入垃圾回收器的前提下,通过编译时检查保证内存安全和线程安全,同时提供现代语言的高级抽象能力。

它的定位填补了一个真实存在的空缺:有GC、有安全保证的语言(Python、Java、Go)不能用于性能极致敏感或无运行时的场景;没有GC、性能极致的语言(C、C++)无法在大型项目里可靠地保证安全。

Rust试图两者兼得,代价是:学习曲线陡峭。所有权系统是新的概念,借用检查器会拒绝很多在其他语言里完全正常的写法。这是Rust最大的门槛,也是它存在的理由——这套规则不容易驾驭,但一旦驾驭,带来的保证是真实的。