作者 | Kornel
译者 | Sambodhi
策划 | 赵钰莹
本文最初发表于原作者个人博客,经原作者 Kornel 授权,InfoQ 中文站翻译并分享。
使用 Rust 语言编写的程序,其运行时速度和内存使用情况应该和用 C 语言编写的程序相差不大,但是,由于这些语言的整体编程风格不同,所以它们的速度很难一概而论。本文总结了 Rust 和 C 有何相同之处,以及什么情况 C 更快,什么情况 Rust 更快。
声明:本文并非一个客观的基准,只是揭示了这些语言无可争辩的事实。这两种语言理论上能够实现什么,以及在实践中如何使用,存在显著的差异。这种特别的比较是基于我个人的主观经验,包括有交付截止日期、有 Bug,还有懒惰。Rust 语言作为我的主要编程语言已经超过 4 年了,而之前我使用 C 语言也有 10 年之久。在本文中,我专门将 Rust 与 C 进行比较,因为与 C++ 相比,将会有更多的“如果”和“但是”,而我并不想深入讨论。
简而言之:
我的总体感觉是,如果可以花费无穷无尽的时间和精力,我的 C 程序将和 Rust 一样快,甚至比 Rust 还快,因为在理论上,没有什么是 C 做不到而 Rust 可以做到的。但实际上,C 的抽象较少,标准库很原始,依赖情况也很糟糕,我真的没有时间每次都重新“发明轮子”。
Rust 和 C 的相似与不同
两者都是“可移植汇编器”
Rust 和 C 都给出了对数据结构布局、整数大小、堆与堆内存分配、指针间接寻址控制,一般来说,只要编译器插入一点“魔法”,就可以翻译成可理解的机器代码。Rust 甚至承认,字节有 8 位,带符号的整数可能会溢出!
虽然 Rust 具有更高级别的结构,比如迭代器、特性(traits)和智能指针,但是这些结构被设计成可以预测的优化直接机器代码(也就是“零成本抽象”)。Rust 的类型的内存布局很简单,例如,可增长的字符串和向量正是 {byte,capacity,length}。Rust 没有任何像 move 或 copy 构造函数这样的概念,因此保证对象的传递并不比传递指针或 memcpy 复杂。
借用检查只是编译时的一种静态分析。在生成代码之前,它什么也不做,生命周期信息就被完全剥离了。不存在自动装箱(autoboxing)之类的聪明做法。
Rust 不是“愚蠢”的代码生成器的一个例子是展开(unwinding)。尽管 Rust 不是用异常来处理正常的错误,但是 panic(未处理的致命错误)可以有选择地以 C++ 异常的形式出现。可能会在编译时禁用(panic = abort),但即便如此,Rust 也不喜欢与 C++ 异常或 longjmp 混在一起。
老样子的 LLVM 后端
由于 Rust 与 LLVM 集成非常好,因此它支持链接时优化(Link-Time Optimization,LTO),包括 ThinLTO,甚至支持跨 C/C++/Rust 语言边界的内联,还有配置文件引导的优化。虽然 rustc 生成的 LLVM IR 比 clang 冗长得多,但是优化器能够很好地处理。
在使用 GCC 编译时,我的一些 C 代码会比 LLVM 更快一些,而且 GCC 没有 Rust 前端,而 Rust 没有做到这一点。
从理论上讲,Rust 允许比 C 更好的优化,因为它具有更严格的不可变性和别名规则,但是实际上这还没有发生。对于 LLVM,除 C 外的优化工作正在进行,所以 Rust 还没有充分发挥出它的潜力。
除少数例外,这两者都允许手动调优
Rust 代码是低级的,而且很容易预测,我可以手动调优它所优化的汇编。Rust 支持 SIMD,能够很好地控制对内联、调用约定等。Rust 语言与 C 语言很相似,以至于 C 语言的 profiler 分析器通常可以与 Rust 语言一起使用(例如,我可以在一个 Rust-C-Swift 三明治式程序上使用 Xcode 的工具)。
一般来说,在性能绝对关键且需要手工优化到最后一点时,优化 Rust 语言与优化 C 语言之间并无太大差别。
有些低级的功能,Rust 并没有合适的替代:
Rust 少量开销
但是,如果 Rust 没有进行手动调优,则会出现一些低效问题:
Rust 的借用检查器以讨厌双向链表而臭名昭著,但幸运的是,链表在目前的硬件上的运行非常缓慢(缓存局部性差,而且没有向量化)。Rust 的标准库提供了链表,以及更快、更适合于借用检查器的容器可供选择。
有两种借用检查器无法忍受的情况:内存映射文件(来自进程外的神奇变化与引用的不可变性 ^ 排他性语义相冲突)和自引用结构(通过值传递结构将内部指针悬空)。这种情况可以通过原始指针解决,就像 C 语言中的每个指针一样安全,也可以通过心理体操来抽象出这些指针的安全。
在 Rust 中,单线程程序只是不作为一个概念存在而已。为了提高性能,Rust 允许使用单个数据结构而忽视线程安全,但是任何允许在线程之间共享的东西(包括全局变量)必须同步,或者标记为不安全。
Rust 的字符串支持一些廉价的就地操作,例如 make_ascii_lowercase()(直接与 C 语言中的操作等同),而 .to_lowercase() 的复制不需要使用 Unicode-aware 的方式。说到字符串,UTF-8 编码并不像看上去那么麻烦,因为字符串具有 .as_bytes() 视图,所以如果需要的话,可以使用 Unicode-ignorant 的方式来处理。
libc 会尽其所能让 stdout 和 putc 变得相当快。Rust 的 libstd 没有这么神奇,因此除非用 BufWriter 进行包装,否则不会缓冲 I/O。有些人抱怨说 Rust 比 Python 慢,这是因为 Rust 花了 99% 的时间逐字节刷新结果,这与我们所说的完全相同。
可执行文件的大小
每一种操作系统都会内置一些标准的 C 库,这些 C 库是 C 可执行文件“免费”得到的约 30MB 的代码,比如一个小小的“Hello World” C 可执行文件实际上无法输出任何内容,它只是调用操作系统附带的 printf。Rust 不能指望操作系统会内置 Rust 的标准库,因此 Rust 可执行文件捆绑了自己的标准库(300KB 以上)。幸好,这是可以减少的一次性开销。在嵌入式开发中,标准库可以关闭,Rust 将生成“裸”代码。
Rust 代码的大小与 C 语言中每个函数的大小相差不多,但存在“泛型膨胀”(generics bloat)的问题。对于每一种类型,都会有泛型函数经过优化的版本,因此有可能同一个函数最终有 8 个版本,cargo-bloat 工具可以帮助查找它们。
在 Rust 中使用依赖关系非常简单。类似于 JS/npm ,也有一种制作小型单用途库的文化,但它们确实是合二为一。最终,我所有的可执行文件都包含了 Unicode 规范化表、7 个不同的随机数生成器,以及一个支持 Brotli 的 HTTP/2 客户端。在重复数据删除(deduping)和删除数据时,cargo-tree 非常有用。
Rust 取得小胜之处
在讨论开销时,我已经讨论了许多,但是 Rust 还存在一些地方,它最终更加高效和快速:
Rust 取得大胜之处
即使是在第三方库中,Rust 也会强制实现所有代码和数据的线程安全,哪怕那些代码的作者没有注意线程安全。一切都遵循一个特定的线程安全保证,或者不允许跨线程使用。当我编写的代码不符合线程安全时,编译器会准确地指出不安全之处。
它和 C 语言中的情况完全不同。一般来说,除非库函数具有明确的文档说明,否则不能相信它们线程安全。程序员需要确保所有代码都是正确的,而编译器对此通常无能为力。多线程化的 C 代码有更多的责任和风险,因此假装多核 CPU 是一种时尚,并且想象用户有更好的事情可以用剩下的 7 到 15 个核来做,这非常吸引人。
Rust 保证了不受数据争用和内存不安全的影响(例如,释放后使用(use-after-free)bug,甚至跨线程)。并非只有一些争用可以通过启发式方法或者工具构建在运行时被发现,而是所有的数据争用都可以被发现。它是救命稻草,因为数据争用是并行错误中最糟糕的。它们会发生在我用户的机器上,而不会发生在我的调试器中。也有其他类型的并发错误,比如锁基元使用不当导致更高级别的逻辑争用条件或死锁,Rust 无法消除这些错误,但它们通常更容易重现和修复。
我不敢用 C 语言在简单的 for 循环上使用更多的 OpenMP 实用程序。我曾试图更多地在任务和线程上冒险,但是结果总是令人遗憾。
Rust 已经有了很多库,如数据并行、线程池、队列、任务、无锁数据结构等。有了这类构件的帮助,再加上类型系统强大的安全网,我就可以很轻松地并行化 Rust 程序了。有些情况下,用 par_iter() 代替 iter() 是可以的,只要能够进行编译,就可以正常工作!这并不总是线性加速( 阿姆达尔定律(Amdahl's law)很残酷),但往往是相对较少的工作就能加速 2~3 倍。
延伸:阿姆达尔定律,一个计算机科学界的经验法则,因 Gene Amdahl 而得名。它代表了处理器并行计算之后效率提升的能力。
在记录线程安全方面,Rust 和 C 有一个有趣的不同。Rust 有一个词汇表用于描述线程安全的特定方面,如 Send 和 Sync、guards 和 cell。对于 C 库,没有这样的说法:“可以在一个线程上分配它,在另一个线程上释放它,但不能同时从两个线程中使用它”。根据数据类型,Rust 描述了线程安全性,它可以泛化到所有使用它们的函数。对于 C 语言来说,线程安全只涉及单个函数和配置标志。Rust 的保证通常是在编译时提供的,至少是无条件的。对于 C 语言,常见的是“仅当 turboblub 选项设置为 7 时,这才是线程安全的”。
总 结
Rust 足够低级,如果有必要,它可以像 C 一样进行优化,以实现最高性能。抽象层次越高,内存管理越方便,可用库越丰富,Rust 程序代码就越多,做的事情越多,但如果不进行控制,可能导致程序膨胀。然而,Rust 程序的优化也很不错,有时候比 C 语言更好,C 语言适合在逐个字节逐个指针的级别上编写最小的代码,而 Rust 具有强大的功能,能够有效地将多个函数甚至整个库组合在一起。
但是,最大的潜力是可以无畏地并行化大多数 Rust 代码,即使等价的 C 代码并行化的风险非常高。在这方面,Rust 语言是比 C 语言更为成熟的语言。
作者介绍:
Kornel,程序员,专长图像压缩领域。喜欢闲聊。博客写手。
原文链接:
https://kornel.ski/rust-c-speed
点击文末【阅读原文】移步InfoQ官网,内容更多更精彩!
今日好文推荐
每周精要上线移动端,立刻订阅,你将获得
InfoQ 用户每周必看的精华内容集合:
资深技术编辑撰写或编译的全球 IT 要闻;
一线技术专家撰写的实操技术案例;
InfoQ 出品的课程和技术活动报名通道;
“码”上关注,订阅每周新鲜资讯
点个在看少个 bug👇