第1章 系统程序员也能享受美好事物
# 第1章 系统程序员也能享受美好事物
在某些特定场景下,比如Rust所针对的领域,比竞争对手快10倍甚至2倍,可能是决定成败的关键因素。这不仅关乎一个系统在市场上的命运,其重要性就如同在硬件市场中一样。
—— 格雷登·霍尔(Graydon Hoare)如今所有计算机都是并行的……
并行编程就是编程的未来。
—— 迈克尔·麦库尔(Michael McCool)等人,《结构化并行编程》
国家级攻击者利用TrueType字体解析漏洞进行监控,所有软件都存在安全隐患。
—— 安迪·温戈(Andy Wingo)
我们选择以上三段引言作为开篇是有原因的。不过,让我们先从一个谜题开始。下面这段C程序是做什么的呢?
int main(int argc, char **argv) {
unsigned long a[1];
a[3] = 0x7ffff7b36cebUL;
return 0;
}
2
3
4
5
今天早上在吉姆(Jim)的笔记本电脑上,这个程序输出了:
undef: Error: .netrc file is readable by others.
undef: Remove password or make file unreadable by others.
2
然后程序崩溃了。如果你在自己的机器上运行这个程序,可能会有不同的结果。这是怎么回事呢?
这个程序存在缺陷。数组a
的长度只有1,所以根据C编程语言标准,使用a[3]
属于未定义行为:
使用非标准或错误的程序结构,或者错误的数据时的行为,本国际标准对此不做任何要求。
未定义行为的结果不仅仅是不可预测的:标准明确允许程序做任何事情。在我们的例子中,将这个特定的值存储到这个特定数组的第四个元素中,恰好破坏了函数调用栈,导致从main
函数返回时,程序没有像预期的那样正常退出,而是跳入了标准C库中从用户主目录的文件中检索密码的代码中间部分。结果自然不太妙。
C和C++ 有数百条规则来避免未定义行为。它们大多是常识性的:不要访问不该访问的内存,不要让算术运算溢出,不要除以零等等。但是编译器并不会强制执行这些规则,它甚至没有义务去检测那些明显的违规行为。实际上,前面那段程序编译时不会报错也不会给出警告。避免未定义行为的责任完全落在程序员你身上。
从实际经验来看,在这方面我们程序员的表现并不理想。犹他大学的研究员彭李(Peng Li)在学生时代修改了C和C++ 编译器,使它们编译的程序能够报告是否执行了某些形式的未定义行为。他发现几乎所有程序都会出现这种情况,包括那些来自备受赞誉、代码质量要求很高的项目。就像你不能仅仅因为知道规则就认为自己能赢得一盘国际象棋一样,认为在C和C++ 中可以避免未定义行为也是不现实的。
偶尔出现的奇怪消息或程序崩溃可能只是质量问题,但自1988年莫里斯蠕虫(Morris Worm)利用前面类似的技术在早期互联网上从一台计算机传播到另一台计算机以来,无意中产生的未定义行为也一直是安全漏洞的主要原因。
所以C和C++ 让程序员陷入了尴尬的境地:这些语言是系统编程的行业标准,但它们对程序员的要求几乎注定会导致程序频繁崩溃和出现安全问题。解开这个谜题只会引出一个更大的问题:难道我们就不能做得更好吗?
# Rust为你承担重任
我们的答案与开篇的三段引言有关。第三段引言提到,2010年发现的计算机蠕虫 “震网”(Stuxnet)在入侵工业控制设备时,除了使用许多其他技术外,还利用了在解析文字处理文档中嵌入的TrueType字体的代码中的未定义行为来控制受害者的计算机。可以肯定的是,那段代码的作者并没有预料到它会被这样利用,这表明需要担心安全问题的不仅仅是操作系统和服务器:任何可能处理来自不可信来源数据的软件都可能成为攻击目标。
Rust语言向你做出一个简单的承诺:如果你的程序通过了编译器的检查,它就不会有未定义行为。悬空指针、双重释放和空指针解引用等问题都会在编译时被捕获。数组引用通过编译时和运行时检查的组合来确保安全,所以不会有缓冲区溢出的问题:与我们之前那个有问题的C程序对应的Rust程序会安全地退出并给出错误信息。
此外,Rust旨在既保证安全又易于使用。为了对你的程序行为做出更有力的保证,Rust比C和C++ 对代码施加了更多的限制,要习惯这些限制需要练习和经验。但总体而言,这门语言既灵活又富有表现力。用Rust编写的代码的广泛应用领域就是证明。
以我们的经验来看,能够相信这门语言能捕获更多错误,会鼓励我们尝试更具挑战性的项目。当你知道内存管理和指针有效性等问题都能得到妥善处理时,修改大型复杂程序的风险就会降低。而且当一个错误的潜在后果不包括破坏程序的其他无关部分时,调试工作也会简单得多。
当然,Rust仍然无法检测出许多错误。但在实践中,排除未定义行为会极大地改善开发的特性。
# 并行编程不再棘手
在C和C++ 中,正确使用并发是出了名的困难。开发人员通常只有在单线程代码无法达到所需性能时才会转向并发编程。但开篇的第二段引言指出,并行性对于现代计算机来说太重要了,不能把它当作最后的手段。
事实证明,Rust中确保内存安全的那些限制,同样也能确保Rust程序不会出现数据竞争问题。只要数据不发生变化,你就可以在不同线程之间自由共享数据。对于会发生变化的数据,只能使用同步原语来访问。所有传统的并发工具都可以使用:互斥锁、条件变量、通道、原子操作等等。Rust只是会检查你是否正确使用了它们。
这使得Rust成为利用现代多核计算机能力的优秀语言。Rust生态系统提供的库不仅包含常见的并发原语,还能帮助你将复杂的负载均匀地分配到处理器池中,使用像读 - 复制 - 更新(Read-Copy-Update)这样的无锁同步机制等等。
# Rust依然快速
这就引出了我们开篇的第一段引言。Rust和比雅尼·斯特劳斯特鲁普(Bjarne Stroustrup)在他的论文《抽象与C++ 机器模型》中为C++ 所阐述的目标一致:
一般来说,C++ 实现遵循零开销原则:你不用的功能,无需为此付费。更进一步:你使用的功能,手工编写也不会比它更好。
系统编程常常需要将计算机的性能发挥到极致。对于视频游戏来说,整个计算机资源都应该用于为玩家创造最佳体验。对于网络浏览器而言,浏览器的效率决定了内容创作者能实现的上限。在计算机固有的限制内,必须尽可能地将内存和处理器资源留给内容本身。同样的原则也适用于操作系统:内核应该将计算机资源提供给用户程序,而不是自己占用。
但是当我们说Rust “快速” 时,这到底意味着什么呢?在任何通用语言中都可能编写出运行缓慢的代码。更准确的说法是,如果你愿意投入精力设计程序,以充分利用底层计算机的性能,Rust会支持你这样做。这门语言在设计上采用了高效的默认设置,并且让你能够控制内存的使用方式和处理器资源的分配。
# Rust让协作更轻松
我们在本章标题中还隐藏了第四段引言:“系统程序员也能享受美好”。这指的是Rust对代码共享和复用的支持。
Rust的包管理器和构建工具Cargo,让你可以轻松使用在Rust公共包仓库crates.io网站上发布的库。你只需将库的名称和所需版本号添加到一个文件中,Cargo就会负责下载该库,以及它所依赖的其他库,并将它们全部链接在一起。你可以把Cargo看作是Rust版本的NPM或RubyGems,它特别注重合理的版本管理和可复现的构建。有许多流行的Rust库,提供从现成的序列化功能到HTTP客户端和服务器,再到现代图形API等各种功能。
更进一步,Rust语言本身的设计也支持协作:Rust的特性(traits)和泛型让你能够创建具有灵活接口的库,使其可以在多种不同的场景中使用。而且Rust的标准库提供了一组核心的基本类型,为常见情况建立了共享约定,让不同的库更容易协同使用。
下一章将通过展示几个小型Rust程序来具体说明我们在本章中提出的这些宽泛的观点,展现这门语言的优势。