Rust-高性能指南-全-

Rust 高性能指南(全)

原文:annas-archive.org/md5/bf97e121e3b52a9455e1e051f8cd4ce9

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎来到《Rust 高性能编程》。在这本书中,你将通过学习如何提高你的 Rust 代码性能来获得对高性能编程的温和介绍。它将向你展示如何通过避免常见瓶颈正确地将代码从其他语言转换过来,以及如何使用一些惯用的 Rust API 轻松提高你应用程序的性能。

通过找到能提高开发效率并改善你应用程序性能的优秀 crate,你将了解伟大的 Rust 社区。你将编写示例来使用你所有的知识。你将编写自己的宏和自定义 derives,并学习关于异步和多线程编程。

本书面向的对象

在这本书中,你将找到提高你的 Rust 代码性能所需的一切;你将学会许多技巧,并使用有用的 crate 和工具。因此,本书的编写基于你已经具备一些 Rust 编程知识的基础。

本书不会涵盖高性能编程的整个世界,因为这是一个极其广泛的话题。你将找到对大多数通用高性能编程概念的温和介绍,并学习如何在 Rust 编程语言中使用特定的模式。

本书涵盖的内容

第一章,常见的性能陷阱,帮助你了解从 C/C++等语言转换过来可能导致性能大幅下降的原因,如何使用不同的 Copy/Clone 类型和引用来改进你的算法,以及了解循环复杂度如何使编译器优化变得不那么有效。

第二章,额外的性能提升,进一步探讨了 Rust 为我们提供的提高应用程序性能的一些技巧和窍门。在了解了前几章中常见的错误之后,你将学习如何利用 Rust 类型系统为你带来优势,创建复杂的编译时检查和评估。你还将了解常见标准库集合之间的区别,以便为你的算法选择正确的集合。

第三章,Rust 中的内存管理,展示了如何通过利用借用检查器来提高你应用程序的内存占用。你将了解生命周期以及如何正确使用它们,理解有助于你的数据在内存中正确结构化的不同表示属性,最后,学习如何使用标准库类型为你的应用程序创建高效的共享指针结构。

第四章,代码检查与 Clippy,教你了解代码检查的强大功能以及如何配置它们以提供适当的建议。你将学习如何配置 Clippy,这是一个功能强大的工具,能够指出常见的错误和潜在的性能改进。在本章中,你将了解最重要的 Clippy 代码检查,并在你的开发工作流程中使用它们。

第五章,分析你的 Rust 应用程序,介绍了如何使用分析软件,以便你可以轻松地找到应用程序中的性能瓶颈。你将了解缓存未命中如何影响你的代码,以及如何在代码中找到应用程序花费更多时间的地方。你将学会修复这些瓶颈,从而提高应用程序的整体性能。

第六章,基准测试,讨论了如何检测性能关键代码以及如何在 Rust 稳定版和夜间版中对其进行基准测试。你还将学习如何设置你的持续集成环境以获取性能报告,并在你的项目开发过程中跟踪它们。

第七章,内置宏和配置项,带你进入可以个性化你的代码的属性的世界,这样你可以使用代码的每一部分针对特定的平台,利用每个平台的所有潜力。你将了解如何划分你的库,以便不是所有的代码都必须为每个用途编译,你最终将学习如何使用夜间功能来提高你代码的效率和需要编写的代码量。

第八章,必备宏库,介绍了多个元编程库——创建可序列化的结构,从 JSON 或 TOML 等语言中反序列化数据,解析日志文件,或者为你的数据结构创建大量的代码模板。在这里,你可以了解如何初始化复杂的静态结构,并使用适当的错误处理。最后,得益于夜间 Rust 和插件,你将能够创建一个带有数据库的小型 Web 服务器,甚至可以连接到它存在的最快的模板系统。

第九章,创建自己的宏,介绍了如何编写自己的宏以避免代码冗余。你将了解新的宏 1.1 版本的工作原理,并创建你的第一个自定义派生。最后,你将学习编译器插件是如何在内部工作的,并将创建你自己的编译器插件。

第十章,多线程,概述了如何创建多个线程以平衡应用程序的工作。您将了解 Rust 线程的全部功能和标准库中的同步原语。此外,您还将学习如何在线程之间发送信息。最后,您将了解一些有用的 crate,这些 crate 可以帮助您实现工作窃取算法、并行迭代器等。

第十一章,异步编程,帮助您了解异步编程是如何工作的。在这里,您可以通过 mio 和 futures 学习如何在 Rust 中开发异步算法,并学习新的 async/await 语法。您还可以使用 tokio 和 WebSockets 创建异步应用程序。

要充分利用本书

本书假设您对 Rust 编程语言有一些基本了解。如果您是 Rust 的新手,官方 Rust 书籍的前几章是一个很好的序言。尽管如此,您应该至少对一种编程语言有适度的或深入的了解;还需要具备终端使用的基本知识。

具备计算机架构的基本知识以及 C/C++高性能编程的基本知识将是一个加分项,尽管它们不是必需的,因为在这本书中,我们将涵盖所有基础理论,以了解幕后性能改进的工作原理。

您需要代码编辑器或 IDE 来遵循本书。Rust 已经在 Microsoft 的 Visual Studio Code、GitHub 的 Atom 和 IntelliJ 的 IDEA IDE 中进行了大量测试。我个人使用 Atom 编写代码示例,但请随意使用您喜欢的文本编辑器或 IDE。您可能会在您的编辑器中找到插件或扩展。

在 VS Code、Atom 和 IntelliJ IDEA 的情况下,您将找到官方的 Rust 包以及非官方的扩展。我个人一直在使用 Atom 的 Tokamak 包。

下载示例代码文件

您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

您可以通过以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择支持选项卡。

  3. 点击代码下载与勘误。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

文件下载完成后,请确保使用最新版本的以下软件解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

书籍的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Rust-High-Performance。如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的书籍和视频目录的代码包,可在 github.com/PacktPublishing/ 上找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“迭代器将在您调用collect()方法或将其用于循环之前不会运行。这些就是在next()方法执行的时刻。”

代码块设置如下:

    for row in arr1.iter().cartesian_product(arr2.iter()) {
      print!("{:?}, ", row);
    }

任何命令行输入或输出都应如下所示:

cargo install --no-default-features --features sqlite diesel_cli

粗体:表示新术语、重要单词或您在屏幕上看到的单词。

警告或重要说明看起来是这样的。

小技巧和技巧看起来是这样的。

联系我们

我们欢迎读者的反馈。

一般反馈:请通过 feedback@packtpub.com 发送电子邮件,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送电子邮件给我们。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 联系我们,并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

评论

请留下评论。一旦您阅读并使用了这本书,为什么不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

想了解更多关于 Packt 的信息,请访问 packtpub.com

第一章:常见性能陷阱

如果你正在阅读这本书,你很可能关心你的 Rust 代码的性能。众所周知,Rust 可以提供接近 C/C++ 程序的性能,在某些情况下,Rust 甚至可以在基准测试中胜出。然而,主要问题是,有时很难获得这种效率,尤其是如果你来自 C/C++。有些概念不适用,而且那些语言中的一些简单高效的方法在 Rust 中明显更差。

在这本书中,你将学习如何真正利用 Rust,使其在保持其带来的所有好处——安全性、零成本抽象和出色的并发性的同时,发挥最佳性能。你可以从头到尾阅读这本书,你可能会在每一章中学习到新的概念。不过,你也可以直接阅读你感兴趣的章节,因为每一章都包含了完成其内容所需的所有信息,因此它可以作为参考。

在本书的第一部分,我们将从介绍如何提高你的顺序代码性能开始。你将学习如何避免常见的性能陷阱,以及如何修复来自其他语言的直接翻译。然后,你将学习如何从你的代码中获得更好的性能,并最终理解 Rust 中的内存管理。

在本章中,我们将探讨以下内容:

  • 使用配置文件配置编译过程

  • 翻译陷阱——学习如何通过数组/切片索引和掌握迭代器避免性能陷阱

  • 标准库和外部 crate 中的新迭代器适配器,以及以零成本编码任何复杂行为

  • 如何利用借用检查器

大多数开始学习 Rust 的人,包括我自己,往往会将其他语言中学到的经验带到 Rust 中。这通常是一件好事,因为它将使你更快地学习这门语言。这种方法的 主要问题是,在其他语言中使用的一些模式在 Rust 中实际上可能是一个权衡。我们将学习最常见的和不太常见的一些模式,以便任何试图在 Rust 中获得更好性能的人都能学到如何做到这一点。

向 Rust 编译器询问性能

Rust 有时有一些有趣且不太为人所知的特性,在谈论性能提升时,这些特性确实能带来很大的差异。当涉及到通过小改动实现大改进时,你应该首先理解的是发布模式。Rust 默认以开发模式编译你的软件,这对于快速检查编译错误来说相当不错,但如果你想运行它,它会运行得很慢。这是因为开发模式不会进行任何优化。它将创建与 Rust 代码直接相关的对象(机器)代码,而不会对其进行优化。

Rust 使用了 LLVM 后端,这使得它能够利用其性能优化,而无需自己开发所有这些。它们只需要使用 LLVM 中间表示。这是 Rust 和汇编代码之间的中间语言,LLVM 编译器能够理解。在开发模式下,Rust 或 LLVM 不会执行任何优化;启用它们就像在 cargo 编译时添加 --release 标志一样简单。例如,如果你通过在控制台输入 cargo run 来运行你的软件,只需使用 cargo run --release,它就会进行优化编译并运行得快得多。通常,这种提升是超过一个数量级的。

优化

默认情况下,Rust 会在代码中执行第 3 级优化。优化根据其复杂度被分为不同的级别。理论上,高级别的优化可以极大地提高代码的性能,但它们可能存在可能导致程序行为改变的错误。通常,第 1 级优化是完全安全的,而第 2 级优化在 C/C++ 生态系统中是最常用的。第 3 级优化尚未被证明会引起任何问题,但在某些关键情况下,可能最好避免它们。这可以进行配置,但我们应该首先了解 Rust 编译器如何将代码编译成机器指令,以便我们知道不同的选项能完成什么。

Rust 首先会解析你的代码文件。它会获取关键词和不同的符号来在内存中创建代码的表示。这种解析会找到常见的错误,例如缺少分号或无效的关键词。这种代码的内存表示称为 高中间表示HIR)。这种代码的表示将会大大简化,移除复杂的流程结构,并将其转换为 中间中间表示MIR)。

然后,MIR 表示被用来检查软件的更复杂的流程,并允许进行复杂的变量生命周期检查,以及其他一些改进。然后,它被转换为 LLVM 中间表示,并传递给 LLVM 编译器。当将此代码传递给 LLVM 时,Rust 会添加一些标志,这些标志将修改 LLVM 优化代码的方式。我们已经看到,默认情况下,它传递的其中一个标志是 -O0 标志,或 不优化 标志,因此它简单地转换为机器代码。然而,在发布模式下编译时,会传递 -O3,以便执行第 3 级优化。

这种行为可以在项目的 Cargo.toml 文件中配置,并且可以为每个配置文件进行配置。你可以配置如何为测试、开发、文档、基准测试和发布进行编译。你可能希望将开发和文档优化保持在最低限度,因为在这些配置文件中,主要目的是快速编译。在开发配置文件的情况下,你将想要检查一切是否都能正确编译,甚至测试程序的行为,但你可能不会关心性能。在生成文档时,应用程序的性能根本不重要,所以最好的办法就是不要进行优化。

在测试时,所需的优化级别将取决于你想要运行多少次测试以及它们的计算成本有多高。如果运行测试需要非常长的时间,那么编译它们以优化可能是有意义的。此外,在某些可能无法完全确保优化以完全安全方式执行的关键情况下,你可能希望以与优化发布相同的方式优化测试,这样你就可以检查所有单元和集成测试在优化后是否都能正确通过。如果它们没有通过,这可能是编译器故障,你应该向 Rust 编译器团队报告。他们将很高兴提供帮助。

当然,基准测试和发布配置文件应该是优化程度最高的。在基准测试中,你将想要知道代码的真实优化性能,而在发布中,你将希望用户从他们的硬件中获得最佳性能,并且你的软件能够尽可能高效地运行。在这些情况下,你将想要至少优化到 2 级,如果你不是在发送卫星到太空或编程起搏器,你可能会想要将优化进行到 3 级。

构建配置

Cargo.toml 文件中有一个部分可以启用这些配置:配置文件部分。在这个部分中,你会为每个配置文件找到一个子部分。每个子部分都使用 [profile.{profile}] 格式声明。例如,对于开发配置文件,它将是 [profile.dev]。不同的配置文件配置关键字如下:

  • dev 用于开发配置文件,在 cargo buildcargo run 中使用

  • release 用于发布配置文件,在 cargo build --releasecargo run --release 中使用

  • test 用于测试配置文件,在 cargo test 中使用

  • bench 用于基准测试配置文件,在 cargo bench 中使用

  • doc 用于文档配置文件,在 cargo doc 中使用

当配置每个配置文件时,你将有很多选项,我们将在这里检查所有这些选项。

优化级别

第一个选项是之前提到的优化级别。此配置选项可以通过在相关配置文件部分使用opt-level键来设置。默认情况下,优化级别为 3 用于基准测试和发布,其余为 0。例如,要仅在发布配置文件中执行级别2的优化,您可以将以下代码添加到您的Cargo.toml文件中:

    [profile.release]
    opt-level = 2

调试信息

下一个选项是调试信息。这不会直接影响性能,但它是一个有趣的配置项。在这种情况下,您可以决定是否将调试符号信息添加到最终的可执行文件中。如果您正在开发,尤其是如果您正在使用 GDB 之类的调试器,这将非常有用。将调试信息添加到可执行文件将使您能够获取处理器中每个指令的函数名甚至行号。这将为您提供关于代码中发生情况的深入了解。

在任何情况下,调试信息在最终发布的二进制文件中并不那么有用,因为最终发布的二进制文件并不是为了调试而设计的。而且,调试信息通常会增加最终二进制文件的大小。这曾多次成为开发者的担忧,因为 Rust 的二进制文件通常比用 C/C++编写的二进制文件要大得多。这在很大程度上是由于这种配置,以及在大多数情况下是由于 panic 行为,我们稍后会检查。调试符号还会显示有关原始代码的信息,因此在封闭源代码项目中隐藏它可能是有意义的。

为了避免在最终二进制文件中包含额外的调试符号,必须将debug选项设置为false。这可以为每个配置文件进行设置,默认情况下,只有开发配置文件为true。如果您还希望将其用于测试,例如,您可以在Cargo.toml文件中添加以下内容:

    [profile.test]
    debug = true

当然,您可以将此与任何其他配置文件选项结合使用:

    [profile.test]
    debug = true
    opt-level = 1

链接时间优化

下一个配置选项,对于提高应用程序的性能很有用,是链接时间优化。通常,当程序构建时,一旦所有代码都已优化,它就会被链接到其他库和函数,以提供所需的功能。然而,这并不总是以最有效的方式进行。有时,一个函数会被链接两次,或者一段代码会在许多地方使用,在这种情况下,编译器可能会决定复制一些代码。

该程序将完美运行,但有两个主要缺点——首先,复制代码和链接会使二进制文件更大,这可能是你不想看到的事情,其次,它将降低性能。你可能会问为什么。好吧,由于相同的代码在程序的不同地方被访问,如果它只执行一次,它可能会被添加到处理器的 L1/L2/L3 缓存中。这将使得未来可以重用这些指令,而无需处理器从 RAM 内存(较慢)或甚至从磁盘/SSD(极慢)中获取它们(如果内存已经被交换的话)。

当执行链接时间优化,或简称为LTOs时,主要优势在于,虽然 Rust 是按文件编译代码的,但 LTOs 将整个几乎最终的表示形式放入一个大的编译单元中,可以对其进行整体优化,从而实现更好的执行路径。

当然,这可以完成,但代价是编译时间会非常长。这些优化非常昂贵,因为它们有时需要改变最终的表现形式,即准备写入二进制的那个形式。不仅如此,这还需要检查大量的执行路径和代码样本以找到相似的块。记住,这是在对象代码上完成的,而不是 Rust 代码,所以编译器不知道库或模块;它只看到指令。

这种代价高昂的优化将提高你软件的性能和二进制文件的大小,但由于代价如此之高(通常需要与编译的其他部分一样多的时间,甚至更多),它默认情况下在所有配置文件中都没有启用。你不应该在除了发布和可能基准测试配置文件之外的地方启用它(你不想每次在函数中做小改动并想要测试它时都等待 LTO)。要更改的配置项是lto配置项:

    [profile.release]
    lto = true

有一个相关的配置项,如果为给定配置文件开启了 LTO,则会被忽略。我指的是codegen单元。这把代码分成多个更小的代码单元,并分别编译每个单元,从而实现并行编译,这提高了程序编译的速度。这在 LTO 的情况下是如此,但也可以对其他情况进行修改。当然,使用单独的编译单元可以避免一些可以提高代码性能的优化,因此,在开发模式下启用更快的编译可能是有意义的。默认情况下,它将是 16。

这就像更改开发配置文件中的codegen-units配置选项一样简单:

    [profile.dev]
    codegen-units = 32

一个示例值可以是你的计算机中的处理器/线程数。但请记住,这将使编译的软件变慢,所以不要在发布配置文件中使用它。无论如何,如果你激活了链接时间优化,它将始终被忽略。

调试断言

下一个有趣的配置项是允许移除调试断言的选项。调试断言类似于正常断言,但默认情况下它们仅在开发配置文件中执行。它们通过在assert!宏前加上debug_前缀来写入代码,例如使用debug_assert!debug_assert_eq!。这使您能够在整个代码中填充必须为真且需要处理周期来测试的断言,而不会降低发布应用程序的性能。当然,这意味着这些断言在发布模式下不会运行。这对于测试内部方法很有用,但对于 API 来说可能不是最好的选择,而且在不安全的代码包装器中肯定不是一个好主意。

例如,标准库Vec对象中的索引函数有一个断言,每次您通过索引获取向量的元素时都会检查索引是否超出范围。这可以很好地避免缓冲区溢出,但会使获取向量元素的运算变慢,如果索引超出范围,程序将崩溃。我们稍后会讨论这个特定的例子,但总的来说,它显示了这些断言是多么有用——在这种情况下,对于发布模式也是如此。

另一方面,如果您计划创建一个小的内部 API,该 API 将输入介于0100之间的数字并对其进行一些计算,但不对公众公开,您只需添加一个debug_assert!(num <= 100 && num >= 0),在测试和调试模式下,如果函数接收到该范围之外的数字,程序将恐慌,但在发布模式下不会运行这个断言。这可能会成为一个潜在的错误向量,但通过彻底的单元测试,测试/开发模式下未获得错误以及在发布模式下接收到错误数字的概率要低得多。当然,再次强调,这不应该用于安全重点区域或可能导致不安全或未定义行为的输入。

默认情况下,如解释所述,这些断言在开发、测试和文档模式下运行。最后一个模式在您有带有调试断言的文档测试时很有用。在任何情况下,都可以通过更改debug-assertions配置选项轻松地进行配置。例如:

    [profile.doc]
    debug-assertions = false

潘克行为

下一个要检查的配置变量是恐慌行为。默认情况下,Rust 会在恐慌时进行堆栈展开。这意味着如果发生严重错误并且应用程序恐慌,它将调用堆栈中每个变量的每个析构函数。还有一个选项:不调用任何东西,只是简单地终止程序(这是标准 C/C++的行为)。

unwind 的主要优势是,你将能够调用析构函数,因此程序堆栈中变量的任何清理工作都将得到妥善处理。abort 行为的主要优势是,它将需要编译更少的代码,因为对于每个潜在的 panic 位置,代码中都会添加一个新的分支,其中运行所有析构函数。它还使代码具有更少的分支,这使得优化更容易,但主要优势是更小的二进制文件。当然,你失去了运行析构函数的能力,因此某些复杂的行为可能无法得到适当的清理,例如,如果你需要在关闭时向日志写入某些内容。

如果你仍然认为在你的用例中,使用 abort 行为是一个好主意,你可以通过使用 panic 关键字来启用它:

    [profile.doc]
    panic = 'abort'

运行时库路径

最后一个配置选项是 rpath。此配置项接受一个布尔值,允许你要求 Rust 编译器在可执行文件在运行时查找库时设置加载器路径。尽管如此,大多数时候 Rust 会将 crate 和库静态链接,但你可以要求特定的库以动态方式链接。在这种情况下,该库将在运行时而不是编译时搜索,因此它将使用程序运行所在位置的系统库。

此配置选项要求 cargo 在 rustc 编译器调用时添加 -C rpath。这将添加到动态库搜索路径中。尽管如此,在大多数情况下,这不应该被需要,如果你不必要的话,应该通过使用 false 作为选项值来避免它。如果你在使你的应用程序在多个操作系统上运行时遇到问题,你可能可以尝试它,因为它可能会使可执行文件在新位置查找动态库。

翻译问题

当你将 C/C++/Java 思维方式翻译过来,或者直接将项目移植到 Rust 时,你可能会发现自己写的代码与你用母语写的代码相似,但如果你尝试过,你可能已经注意到它的性能不佳,或者至少比旧代码差得多。这种情况在 C/C++ 中尤其如此,因为与 Java 应用程序相比,Java 的性能问题要低得多,Java 应用程序具有 Java 虚拟机和垃圾回收器,内存和计算占用都很大。

但为什么直接翻译会损害性能?在本节中,我们将看到 Rust 的保证有时会创建不必要的样板指令,我们将学习如何通过使用安全和高效的代码来绕过它们。当然,在某些性能关键的情况下,可能需要使用不安全的作用域,但通常情况下并非如此。

索引退化

让我们从简单的例子开始。以下 Rust 代码将表现不佳:

    let arr = ['a', 'b', 'c', 'd', 'e', 'f'];

    for i in 0..arr.len() {
      println!("{}", arr[i]);
    }

当然,这会起作用,并且是完全安全的。我们创建一个从0到数组长度(在这个例子中是6)的索引,但不包括最后一个,所以i绑定将取值012345。对于每一个,它将获取数组中该索引处的元素并在新的一行中打印它。然而,这种方法有一个问题。在 C/C++中,等效的代码将简单地向数组中的指针添加元素的大小,以获取下一个元素,但有时这会导致问题。看看这段代码:

    let arr = ['a', 'b', 'c', 'd', 'e', 'f'];

    for i in 0..arr.len() + 1 {
      println!("{}", arr[i]);
    }

在这种情况下,我们迭代到数组长度加一,由于范围是排他的,最后一个索引将是 6。这意味着它将尝试获取数组中的第七个元素,但数组中没有第七个元素。在 C/C++中,这将创建一个缓冲区溢出,并获取内存中的下一个内容。如果这个内存超出了程序的范围,你将得到一个段错误,但如果它是程序的一部分,它将打印出那个位置的内容,导致泄漏。当然,在 Rust 中这是不可能的,因为 Rust 是一种内存安全的语言,所以会发生什么呢?

好吧,答案令人惊讶——它会惊慌程序,撤销栈(调用栈中所有变量的析构函数),并安全地退出程序,而不尝试访问无效内存。根据你的观点,你可能认为“太好了,我将不再有缓冲区溢出”,或者你可能认为“哦,我的天哪,整个服务器都会崩溃以防止缓冲区溢出”。然而,第二种情况可以通过停止惊慌并恢复适当的服务器状态来缓解,这已经在大多数框架中实现,所以这基本上是一个双赢的局面。

但这是真的吗?Rust 是如何知道索引是否越界的呢?在这个简单的例子中,编译器可以知道arr变量只有六个元素,所以尝试访问第七个将违反内存约束。但关于这个更复杂的程序:

    fn print_request(req: Request) {
      for i in 0..req.content_length {
        println!("{}", req.data[i]);
      }
    }

在这里,我接收到一个至少包含一个content_length属性和一个data属性的 HTTP 请求(非常天真地表示)。第一个应该包含数据字段长度的字节数,而第二个将是一个字节数组。假设我们没有那个数据字段中的len()函数,并且我们信任content_length属性。如果有人发送一个包含比内容实际长度更大的content_length的无效请求怎么办?编译器在请求运行时从 TCP 连接中产生之前不会知道这一点,但同样,Rust 必须始终是内存安全的(除非在不可安全的作用域中工作,这在本例中不是情况)。

嗯,发生的事情是索引操作有两个部分。首先,它检查切片的界限,如果索引是正确的,它将返回元素;如果不正确,它将引发恐慌。是的,它对每个索引操作都这样做。所以在这种情况下,如果请求是一个有效的请求,假设有 100 万个字节(1 MB),它将 1 百万次比较索引与向量的长度。这至少是 200 万个额外的指令(每个比较和分支至少)。这比等效的 C/C++代码效率低得多。

使用迭代器

然而,有一种方法可以绕过这个问题,达到与 C/C++代码相同的效果:使用迭代器。之前的代码可以转换为以下:

    let arr = ['a', 'b', 'c', 'd', 'e', 'f'];

    for c in &arr {
      println!("{}", c);
    }

这将大致编译成与 C/C++变体相同的机器代码,因为它不会多次检查切片的界限,然后使用相同的指针算术。这在迭代切片时很好,但在直接查找的情况下可能会出现问题。假设我们将收到成千上万的 100 元素切片,我们需要获取每个切片的最后一个元素并打印它。在这种情况下,为了只获取最后一个元素,遍历每个数组中的所有 100 个元素是不明智的,因为这会更有效率,只需检查最后一个元素的界限。有几种方法可以做到这一点。

第一个是最直接的:

    for arr in array_of_arrays {
      let last_index = arr.len() - 1;
      println!("{}", arr[last_index]);
    }

在这个具体案例中,我们想要获取最后一个元素,我们可以这样做:

    for arr in array_of_arrays {
      if let Some(elt) = arr.iter().rev().next() {
        println!("{}", elt);
      }
    }

这将通过调用rev()来反转迭代器,然后获取下一个元素(最后一个元素)。如果存在,它将打印它。但是,如果我们需要获取一个不接近切片末尾或开头的数字,最好的方法就是使用get()方法:

    for arr in array_of_arrays {
      if let Some(elt) = arr.get(125) {
        println!("{}", elt);
      }
    }

然而,最后一个有双重界限检查。它将首先检查索引是否正确以返回Some(elt)None,然后最后一个检查将查看返回的元素是Some还是None。如果我们确实知道,我是指 100%确定,索引始终在切片内,我们可以使用get_unchecked()来获取元素。这是 C/C++索引操作的精确等效,因此它不会进行界限检查,允许更好的性能,但使用它是不安全的。所以,在之前的 HTTP 示例中,攻击者能够获取存储在该索引中的内容,即使它是一个切片之外的内存地址。当然,您需要使用一个不安全的作用域:

    for arr in array_of_arrays {
      println!("{}", unsafe { arr.get_unchecked(125) });
    }

get_unchecked()函数将始终返回某些内容或段错误,因此不需要检查它是Some还是None。记住,在段错误发生时,这不会引发恐慌,并且不会调用析构函数。它只应在没有安全替代方案且切片界限已知的情况下使用。

在大多数情况下,你将想要使用一个迭代器。迭代器允许精确地迭代元素,甚至过滤它们,跳过一些,取最大数量的它们,最后将它们收集到一个集合中。它们甚至可以被扩展或与其他迭代器连接起来,以允许任何类型的解决方案。所有这些都由std::iter::Iterator特质管理。你现在已经理解了特质的常用方法,其余的留给你在标准库文档中研究。

正确使用和理解迭代器非常重要,因为它们对于执行真正快速的循环非常有用。迭代器是无成本的抽象,它们的工作方式与索引相同,但不需要边界检查,这使得它们非常适合效率提升。

迭代器适配器

让我们从最简单的方法开始。其余方法的基本方法是next()方法。这个函数将返回迭代中的下一个元素,或者如果迭代器已经被消耗,则返回None。这可以用来手动获取下一个元素,或者创建一个使用whilefor循环,例如:

let arr = [10u8, 14, 5, 76, 84];
let mut iter = arr.iter();

while let Some(elm) = iter.next() {
    println!("{}", elm);
}

这将等同于以下内容:

let arr = [10u8, 14, 5, 76, 84];

for elm in &arr {
    println!("{}", elm);
}

注意在for中的数组变量前面的&。这是因为基本数组类型没有实现Iterator特质,但数组的引用是一个切片,而切片实现了IntoIterator特质,这使得它可以作为一个迭代器使用。

你应该了解的下一个两个方法是skip()take()方法。这些方法使得获取已知有序迭代器的正确成员变得容易。例如,假设我们想要从一个长度未知的迭代器中取出第三到第十个元素(至少有 10 个元素)。在这种情况下,最好的办法是跳过前两个,然后取出接下来的八个。然后我们将它们收集到一个向量中。请注意,迭代器不会运行,直到你调用collect()方法或者在一个循环中使用它。这些就是next()方法被执行的时刻:

let arr = [10u8, 14, 5, 76, 84, 35, 23, 94, 100, 143, 23, 200, 12, 94, 72];

let collection: Vec<_> = arr.iter().cloned().skip(2).take(8).collect();

for elm in collection {
    println!("{}", elm);
}

这将开始遍历数组,并且它将首先克隆每个元素。这是因为默认情况下,迭代器会返回元素的引用,而在u8的情况下,最好复制它们而不是引用它们,就像我们在本章末尾将看到的那样。skip()方法将调用next()两次并丢弃它返回的内容。然后,对于每次next()操作,它将返回一个元素。直到它调用next()八次,take()方法将返回一个元素。然后它将返回Nonecollect()方法将创建一个空向量,并将元素推送到它里面,而next()方法返回Some,然后返回向量。

注意,collect()方法需要一个类型提示,因为它可以返回任何类型的集合——实际上,任何实现了FromIterator特质的类型。我们只是简单地告诉它它将是一个标准库的Vec,然后让编译器推断向量将持有的元素类型。

也有一些函数是前一个函数的泛化,分别是 skip_while()take_while()。这两个函数分别会在闭包返回 true 时跳过或取走元素。让我们看一个例子:

let arr = [10u8, 14, 5, 76, 84, 35, 23, 94, 100, 143, 23, 200, 12, 94, 72];

let collection: Vec<_> = arr.iter()
    .cloned()
    .skip_while(|&elm| elm < 25)
    .take_while(|&elm| elm <= 100)
    .collect();

for elm in collection {
    println!("{}", elm);
}

在这种情况下,skip_while() 方法将运行 next() 直到找到一个大于或等于 25 的元素。在这种情况下,这是第四个元素(索引 3),数字 76take_while() 方法随后调用 next() 并返回所有小于或等于 100 的元素。当它找到 143 时,它返回 None。然后 collect() 方法将包括所有这些元素,从 76100(包括),并将它们放入一个向量中,并返回它。请注意,23 也会被添加到最终结果中,因为即使它小于 25,跳过方法停止跳过后,它将永远不会再次跳过。

为了微调迭代中元素的过滤,还有一些其他非常有趣的方法,比如 filter() 方法及其伴随的 map() 方法。第一个方法允许你根据闭包过滤迭代器的元素,而第二个方法允许你将每个元素映射到不同的元素。让我们通过使用一个简单的迭代器来探索这一点,该迭代器产生迭代器的奇数元素并将它们收集到一个向量中:

let arr = [10u8, 14, 5, 76, 84, 35, 23, 94, 100, 143, 23, 200, 12, 94, 72];

let collection: Vec<_> = arr.iter()
    .enumerate()
    .filter(|&(i, _)| i % 2 != 0)
    .map(|(_, elm)| elm)
    .collect();

for elm in collection {
    println!("{}", elm);
}

在这种情况下,我们通过调用 enumerate() 来枚举迭代器。这将产生一个元组,包含每次 next() 调用的索引和元素。然后,通过检查索引进行过滤。如果索引是奇数,它将在 next() 调用中返回;如果不是,它将再次调用 next()。然后,它将被映射,因为过滤也会返回元组。map() 函数将只取元素,丢弃索引,并返回它。

通过使用有用的 filter_map() 函数,可以将过滤和映射函数简化,该函数结合了这两个功能:

let arr = [10u8, 14, 5, 76, 84, 35, 23, 94, 100, 143, 23, 200, 12, 94, 72];

let collection: Vec<_> = arr.iter()
    .enumerate()
    .filter_map(|(i, elm)| if i % 2 != 0 { Some(elm) } else { None })
    .collect();

for elm in collection {
    println!("{}", elm);
}

filter_map() 转换器期望一个闭包,当应该返回元素时返回 Some(element),当应该重试并调用 next() 时返回 None。这将避免一些额外的代码。在这个具体案例中,你也可以使用 step_by() 方法,它只返回每 n 个元素中的一个。在这种情况下,使用两步将产生相同的效果。

当尝试使用迭代器进行计算时,我们不必使用 for 循环,而是可以使用伟大的 fold() 方法。这个方法会在每次调用 next() 之间保持一个变量,你可以更新它。这样,你可以在迭代器中求和、乘法,以及执行任何其他操作。例如,让我们求迭代器中所有元素的总和:

let arr = [10u32, 14, 5, 76, 84, 35, 23, 94, 100, 143, 23, 200, 12, 94, 72];

let sum = arr.iter().fold(0u32, |acc, elm| acc + elm);
println!("{}", sum);

这将打印 985,而不需要循环。当然,这将在底层使用循环实现,但对于程序员来说,这是一个零成本的抽象,有助于简化代码。

真实世界的例子

作为现实生活中的例子,这里是用fold()方法实现的VSOP87算法的变量函数。VSOP87算法用于在天空中发现行星和卫星,具有非常好的精度,对于模拟器和望远镜星象仪等非常有用:

fn calculate_var(t: f64, var: &[(f64, f64, f64)]) -> f64 {
    var.iter()
       .fold(0_f64, |term, &(a, b, c)| term + a * (b + c * t).cos())
}

这等同于以下其他代码:

fn calculate_var(t: f64, var: &[(f64, f64, f64)]) -> f64 {
    let mut term = 0_f64;
    for &(a, b, c) in var {
        term += a * (b + c * t).cos();
    }
    term
}

而在 C/C++中,这可能需要一个结构来保存元组。五行代码简化为了一行,同时保持了原生代码。正如我们之前讨论的,这没有额外的成本,并且将被编译成相同的机器代码。

专门适配器

在求和或乘法的情况下,有一些专门的方法:sum()product()方法。这些方法将执行与fold()方法相同的功能,即用于在迭代器中添加所有数字或乘以迭代器中的所有项。我们之前看到的例子可以简化为这个:

let arr = [10u32, 14, 5, 76, 84, 35, 23, 94, 100, 143, 23, 200, 12, 94, 72];

let sum: u32 = arr.iter().sum();
println!("{}", sum);

目前需要类型注解,但代码看起来更简单。你还可以以相同的方式使用product()函数,它将等同于以下代码:

let arr = [10u32, 14, 5, 76, 84, 35, 23, 94, 100, 143, 23, 200, 12, 94, 72];

let prod = arr.iter().fold(0u32, |acc, elm| acc * elm);
println!("{}", prod);

适配器之间的交互

还有一些函数可以控制迭代器如何与其他迭代器或自身交互。例如,cycle()函数将在迭代器到达末尾后再次从开头开始。这对于使用迭代器创建无限循环非常有用。还有一些函数可以帮助你同时处理多个迭代器。假设你有两个长度相同的切片,并想生成一个具有相同长度的新的向量,但每个元素都是切片中相同索引的元素之和:

let arr1 = [10u32, 14, 5, 76, 84, 35, 23, 94, 100, 143, 23, 200, 12, 94, 72];
let arr2 = [25u32, 12, 73, 2, 98, 122, 213, 22, 39, 300, 144, 163, 127, 3, 56];

let collection: Vec<_> = arr1.iter()
    .zip(arr2.iter())
    .map(|(elm1, elm2)| elm1 + elm2)
    .collect();
println!("{:?}", collection);

在这个例子中,我们使用了zip()函数,它会生成一个元组,其中每个元素都是每个迭代器的下一个元素。我们还可以使用chain()函数将它们连接起来,该函数将生成一个新的迭代器,一旦第一个迭代器开始产生None,它将开始从第二个迭代器产生元素。还有许多其他的迭代函数,但我们现在先不讨论标准库,而是专注于外部 crate。

Itertools

有一个外部 crate 可以使使用迭代器的工作变得容易得多,并赋予你超级能力。还记得这些迭代器允许你执行在 C 语言中使用索引会执行的操作的想法吗?但是具有完整的内存安全和零成本抽象?它们还使代码更容易理解。在迭代器功能方面,最重要的 crate 是itertools crate。这个 crate 提供了一个新的 trait,即Itertools trait,它为迭代器提供了许多新的方法和函数,使开发者的生活变得更加容易,同时保持其核心价值——性能,这得益于零成本抽象。你可以通过将它们添加到Cargo.toml文件中的[dependencies]部分来将它们添加到你的项目中。

让我们探索一些它的迭代器适配器。我们从一个简单的适配器开始,它帮助我们创建给定迭代器的批次或块,即 batching() 函数。假设我们想要使用一个迭代器遍历之前的数组,并希望它以三组元素的形式返回元素。这就像使用该方法并创建一个直接调用 next() 方法并返回所需元组的闭包一样简单:

// Remember
extern crate itertools;
use itertools::Itertools;

let arr = [10u32, 14, 5, 76, 84, 35, 23, 94, 100, 143, 23, 200, 12, 94, 72];

for tuple in arr.iter().batching(|it| match it.next() {
    None => None,
    Some(x) => {
        match it.next() {
            None => None,
            Some(z) => {
                match it.next() {
                    None => None,
                    Some(y) => Some((x, y, z)),
                }
            }
        }
    }
})
{
    println!("{:?}", tuple);
}

这将按顺序以三个元素为一组打印数组:

(10, 5, 14)
(76, 35, 84)
(23, 100, 94)
(143, 200, 23)
(12, 72, 94)

可以通过使用 chunks() 函数完成类似的操作。我们可以说,batching() 适配器是 chunks() 适配器的泛化,因为它为你提供了创建函数内部逻辑的选项。在 chunks() 的情况下,它将只接收块中元素的数量作为参数,并返回这些块的切片。

使用 tuples() 方法将执行一个非常类似的操作。正如你所见,batching() 方法在创建迭代器的批次或块方面是一个完全的泛化。让我们看看之前用 tuples() 方法看到的相同示例:

// Remember
extern crate itertools;
use itertools::Itertools;

let arr = [10u32, 14, 5, 76, 84, 35, 23, 94, 100, 143, 23, 200, 12, 94, 72];

for tuple in arr.iter().tuples::<(_, _, _)>() {
    println!("{:?}", tuple);
}

代码量大大减少,对吧?在这种情况下,我们需要指定元组中的元素数量,但如果我们在 for 循环中使用类型推断,就可以避免这样做:

// Remember
extern crate itertools;
use itertools::Itertools;

let arr = [10u32, 14, 5, 76, 84, 35, 23, 94, 100, 143, 23, 200, 12, 94, 72];

for (a, b, c) in arr.iter().tuples() {
    println!("({}, {}, {})", a, b, c);
}

当然,在这种情况下,我们将进行模式赋值。还有一个有趣的功能允许创建两个迭代器的笛卡尔积。

毫不奇怪,函数名是 cartesian_product()。这将创建一个新的迭代器,包含前两个迭代器所有可能的组合:

// Remember
extern crate itertools;
use itertools::Itertools;

let arr1 = [10u32, 14, 5];
let arr2 = [192u32, 73, 44];

for row in arr1.iter().cartesian_product(arr2.iter()) {
    print!("{:?}, ", row);
}

这将打印以下内容:

(10, 192), (10, 73), (10, 44), (14, 192), (14, 73), (14, 44), (5, 192), (5, 73), (5,44),

Itertools 特性中还有许多其他方法,我邀请你查看官方文档,因为它非常详细,有很多示例。现在,这些常用方法应该可以帮助你以更高效的方式执行任何需要用切片执行的操作。

借用退化

翻译退化不仅发生在迭代过程中。还有一些额外的点,有时你可以看到相同的代码在 Rust 中的性能比 C/C++ 差得多。其中一个点是引用处理。由于借用检查器的限制,当你将变量传递给函数时,你可以对变量做三件事:发送一个引用(借用)、将变量的控制权交给新函数(拥有),或者复制/克隆变量以将其发送到函数。这似乎很容易决定,对吧?如果你不再需要变量,让函数拥有你的变量。如果你需要它,发送引用,如果你需要它且 API 只接受拥有,就克隆它。

嗯,实际上并不那么简单。例如,整数比引用更快地复制,小型结构也是如此。一般来说,如果它的大小小于或等于usize,就复制,总是如此。如果它在usize和 10 倍大小之间,可能最好复制。如果更大,可能最好引用。如果结构有堆分配(例如VecBox),通常最好传递一个引用。

然而,也有一些情况下,你无法决定变量的去向。例如,在宏中,变量是原样传递的,宏决定如何处理它。例如,println!宏通过引用获取所有元素,因为它不需要更多。问题是,如果你试图打印一个整数,例如,会出现瓶颈。这就是罗伯特·格罗斯(Robert Grosse)之前遇到的情况,他写了一篇文章关于这件事。

简而言之,他不得不强制复制整数。他是怎么做到的?嗯,这就像创建一个会返回那个整数的范围一样简单。由于整数实现了Copy,整数将被复制到范围中,然后返回,实际上是将它复制到了宏中:

let my_int = 76_u32;
println!("{}", {my_int});

对于正常的打印,这通常不是必要的,但如果你需要快速打印成千上万或数百万个整数,你将无法避免 I/O 接口,但至少可以避免这个瓶颈。

圈复杂度

另一个可能的瓶颈是函数的圈复杂度。虽然它与将代码从其他语言翻译过来没有直接关系,但确实,Rust 有时会增加代码的圈复杂度,因为它迫使你检查可选(可空)的结果,一些复杂的迭代器,函数式编程等等。这对于代码安全性来说是个好事情,但有时编译器在优化我们编写的代码时会出现问题。

避免这种情况的唯一方法是将代码分成更小的代码单元,这将有助于编译器逐个优化。实现这一目标的一种方法是通过创建更小的函数,每个函数不超过 20-25 个分支。分支是程序根据一个变量的值运行一个代码或另一个代码的地方。最简单的分支是条件分支,一个if。还有很多其他分支,比如循环(特别是当循环包含返回时)或?运算符。这将创建两个分支,每个结果选项一个。其中一个将返回函数,而另一个将分配变量。

嵌套循环和条件语句会使这个列表变得更大,分支可以变得越来越复杂,因此你将不得不尝试将这些深层嵌套的条件语句划分到新的函数中。这甚至被认为是一种良好的实践。正如你将在工具部分看到的那样,有一些工具可以帮助你找到这些瓶颈。

摘要

在本章中,我们学习了如何避免新 Rust 程序员遇到的最常见错误,并了解了 Rust 如何执行某些操作,以便我们可以利用它们。

我们学习了如何配置构建系统以允许精确编译。你现在可以设置优化过程、链接时优化或恐慌行为,以及其他许多事情。

现在,你已经掌握了迭代器,并且能够停止索引切片,从而获得宝贵的计算周期。你还发现了Itertools包,现在你可以使用它来对迭代器执行复杂操作。

最后,你学习了一些关于循环复杂度的技巧,并了解了借用或复制如何影响程序的工作方式。

从现在开始,我们将进入更复杂的问题的世界,这些问题有时对新开发者来说可能难以理解。我们将充分利用 Rust 编程语言的全部功能来创建快速且安全的应用程序。

第二章:额外的性能提升

一旦你的应用程序避免了常见的性能瓶颈,就是时候转向更复杂的功能提升了。Rust 有许多选项,允许你通过使用不太为人所知的 API 来提高你代码的性能。这将使你的性能与 C/C++ 相当,在某些场景中,它甚至可以提高大多数最快的 C/C++ 脚本的运行速度。

在本章中,我们将探讨以下主题:

  • 编译时检查

  • 编译时状态机

  • 额外的性能提升,例如使用闭包来避免运行时评估

  • 不稳定的排序

  • 映射哈希

  • 标准库集合

编译时检查

Rust 有一个惊人的类型系统。它如此强大,以至于它本身就是一个图灵完备的。这意味着你只需使用 Rust 的类型系统就可以编写非常复杂的程序。这可以极大地帮助你的代码,因为类型系统在编译时进行评估,这使得你的运行时速度更快。

从基础知识开始,我们所说的类型系统是什么意思?嗯,这意味着所有那些特质、结构、泛型和枚举,你可以在运行时使用它们来使你的代码非常专业化。一个有趣的事实是:如果你创建了一个泛型函数,它使用了两种不同的类型,Rust 将编译两个特定的函数,每个类型一个。

这可能看起来像是代码重复,但事实上,对于给定的类型,拥有一个特定的函数通常比尝试泛化多个函数要快。这也允许创建专门的方法,这些方法将考虑它们使用的数据。让我们用一个例子来看看。假设我们有两个结构,我们希望它们输出一些信息:

struct StringData {
    data: String,
}

struct NumberData {
    data: i32,
}

我们创建了一个特质,我们将为它们实现它,它将返回可以在控制台显示的内容:

use std::fmt::Display;

trait ShowInfo {
    type Out: Display;
    fn info(&self) -> Self::Out;
}

我们为我们的结构实现了它。请注意,我决定在StringData结构的情况下返回数据字符串的引用。这简化了逻辑,但增加了一些生命周期和一些额外的引用到变量。这是因为引用必须在StringData有效时有效。如果不是,它可能会尝试打印不存在的数据,而 Rust 阻止我们这样做:

impl<'sd> ShowInfo for &'sd StringData {
    type Out = &'sd str;
    fn info(&self) -> Self::Out {
        self.data.as_str()
    }
}

impl ShowInfo for NumberData {
    type Out = i32;
    fn info(&self) -> Self::Out {
        self.data
    }
}

如你所见,其中一个返回一个字符串,另一个返回一个整数,因此创建一个允许它们都工作的函数将非常困难,尤其是在强类型语言中。但是,由于 Rust 为它们创建了两个完全不同的函数,每个函数都使用自己的代码,这可以通过泛型来解决:

fn print<I: ShowInfo>(data: I) {
    println!("{}", data.info());
}

在这种情况下,println! 宏将调用 i32&str 结构的特定方法。然后我们简单地创建一个小的 main() 函数来测试一切,你应该能看到它如何完美地打印出这两个结构:

fn main() {
    let str_data = StringData {
        data: "This is my data".to_owned(),
    };
    let num_data = NumberData { data: 34 };

    print(&str_data);
    print(num_data);
}

你可能会想,这与 Java 等语言使用接口的方式相似,在功能上确实如此。但谈到性能,我们这本书的主题,它们非常不同。在这里,生成的机器代码在两次调用之间将会有所不同。一个明显的症状是print()方法获得了它接收的值的所有权,因此调用者必须将其传递到 CPU 的寄存器中。尽管这两种结构在本质上都是不同的。一个比另一个大(包含字符串指针、长度和容量),因此调用方式必须不同。

所以,太好了,Rust 不使用与 Java 接口相同的结构。但你为什么要关心这个呢?好吧,有多个原因,但有一个可能会让你了解这是如何完成的。让我们创建一个状态机。

顺序状态机

让我们先思考如何在 C/C++环境中实现它。你可能会有一个全局状态,然后是一个while循环,每次迭代后都会改变状态。当然,实现状态机有许多方法,但在 C 语言中,所有这些方法都需要元编程或对全局状态的运行时评估。

在 Rust 中,我们有一个图灵完备的类型系统,为什么不尝试用它来创建状态机呢?让我们首先定义一些将具有创建状态机能力的特质。我们首先定义一个StateMachine特质,它将具有从一个状态移动到另一个状态的功能:

pub trait StateMachine {
    type Next: MainLogic;
    fn execute(self) -> Self::Next;
}

如你所见,我已经添加了一个新的类型,MainLogic。这将是一个代表可以在状态中执行逻辑的结构特质的类型。StateMachine特质本身很简单。它只包含一个类型,它将是下一个状态,以及一个execute()函数,它消耗自身,这样就没有人可以在不进入下一个状态的情况下执行同一个状态两次(下一个状态又可以是它自己)。它简单地返回一个新的状态机。这里我们有MainLogic特质:

pub trait MainLogic {
    fn main_logic(self);
}

这只是一个执行状态逻辑的函数。这个状态机的主体功能,将使其能够从一个状态移动到下一个状态,始终执行正确的逻辑,是在MainLogic特质的默认实现中定义的:

impl<S> MainLogic for S
where
    S: StateMachine,
{
    fn main_logic(self) {
        self.execute().main_logic();
    }
}

这将为任何实现StateMachine特质的状态实现MainLogic特质。它将简单地执行状态,然后调用下一个状态的主逻辑。如果这个新状态也是StateMachine,它将被执行,然后执行下一个状态。这种模式在你想顺序执行不同的状态时特别有用。最后一个状态将是实现MainLogic但不是StateMachine的状态:

struct FirstState;
struct LastState;

impl StateMachine for FirstState {
    type Next = LastState;

    fn execute(self) -> Self::Next {
        unimplemented!()
    }
}

impl MainLogic for LastState {
    fn main_logic(self) {
        unimplemented!()
    }
}

编译器将确保你在编译时正确地从第一个状态转换到第二个状态,并强制你这样做。但更重要的是,这将编译成非常高效的代码,效率与逐个执行顺序调用一样高,但提供了 Rust 所提供的所有安全性。事实上,正如你所见,FirstStateLaststate都没有属性。这是因为它们没有大小。它们在运行时不会占用内存空间。

这是最简单的状态机。它只会允许你从一个状态前进到下一个状态。如果你想要这样做,这很有帮助,因为它将确保你的流程在编译时得到检查,但它不会执行复杂的模式。如果你在一个先前状态上循环,你将无限循环。这也会在每个状态都有一个定义的下一个状态,并且没有其他可能性从这个状态产生时很有用。

复杂的状态机

一个更复杂的状态机,允许你在代码中从一个状态移动到另一个状态,同时仍然使用类型系统来检查正确的使用,是可以实现的。让我们首先定义状态机。我们希望一个机器能够代表机器人在汽车制造设施中的工作方式。假设它的任务是安装两扇车门。它将首先等待下一辆车到来,取下车门,将其放置到位,安装螺栓,对第二扇车门做同样的操作,然后等待下一辆车。

我们将首先定义一些将使用传感器并模拟的函数:

fn is_the_car_in_place() -> bool {
    unimplemented!()
}
fn is_the_bolt_in_place() -> bool {
    unimplemented!()
}
fn move_arm_to_new_door() {
    unimplemented!();
}
fn move_arm_to_car() {
    unimplemented!()
}
fn turn_bolt() {
    unimplemented!()
}
fn grip_door() {
    unimplemented!()
}

当然,真正的软件需要考虑许多因素。它应该检查环境是否安全,移动车门到汽车的方式应该是最优的,等等,但现在的简化已经足够了。我们现在定义一些状态:

struct WaitingCar;
struct TakingDoor;
struct PlacingDoor;

然后,我们定义机器本身:

struct DoorMachine<S> {
    state: S,
}

这个机器将持有内部状态,可以附加一些信息(可以是任何类型的结构)或者可以有一个零大小的结构,因此具有零字节的大小。然后我们将实现我们的第一个转换:

use std::time::Duration;
use std::thread;

impl From<DoorMachine<WaitingCar>> for DoorMachine<TakingDoor> {
    fn from(st: DoorMachine<WaitingCar>) -> DoorMachine<TakingDoor> {
        while !is_the_car_in_place() {
            thread::sleep(Duration::from_secs(1));
        }
        DoorMachine { state: TakingDoor }
    }
}

这将简单地每秒检查一次汽车是否在正确的位置。一旦它在正确的位置,它将返回下一个状态,即TakingDoor状态。函数签名确保你无法返回错误的状态,即使你在from()函数内部执行非常复杂的逻辑。此外,在编译时,这个DoorMachine将具有零字节大小,正如我们所见,因此它不会消耗 RAM,无论我们的状态转换多么复杂。当然,from()函数的代码将存储在 RAM 中,但必要的检查都会在编译时完成。

然后,我们将实现下一个转换:

use std::time::Duration;
use std::thread;

impl From<DoorMachine<TakingDoor>> for DoorMachine<PlacingDoor> {
    fn from(st: DoorMachine<TakingDoor>) -> DoorMachine<PlacingDoor> {
        move_arm_to_new_door();
        grip_door();

        DoorMachine { state: PlacingDoor }
    }
}

最后,对于最后一个状态,也可以做类似的事情:

use std::time::Duration;
use std::thread;

impl From<DoorMachine<PlacingDoor>> for DoorMachine<WaitingCar> {
    fn from(st: DoorMachine<PlacingDoor>) -> DoorMachine<WaitingCar> {
        move_arm_to_car();
        while !is_the_bolt_in_place() {
            turn_bolt();
        }

        DoorMachine { state: WaitingCar }
    }
}

机器可以从任何给定的状态开始,从一个状态移动到另一个状态将像编写以下内容一样简单:

    let beginning_state = DoorMachine { state: WaitingCar };
    let next_state: DoorMachine<TakingDoor> = beginning_state.into();

你可能会想,“我为什么不简单地写两个函数并按顺序执行它们?”答案并不直接,但很容易解释。这让你在编译时避免了许多问题。例如,如果每个状态只有一个可能的下一个状态,你可以使用一个通用的into()函数,而无需知道当前状态,它将简单地工作。

在一个更复杂的环境中,你可能会发现自己做以下模式:

    let beginning_state = DoorMachine { state: WaitingCar };
    let next_state: DoorMachine<TakingDoor> = beginning_state.into();

    // Lots of code

    let last_state: DoorMachine<PlacingDoor> = next_state.into();

当然,如果你正确地看待它,我们就不再处于第一个状态了!如果机器试图再次改变状态,认为它仍然处于第一个状态,会发生什么?嗯,这正是 Rust 发挥作用的地方。into()函数获取绑定所有权,所以这根本无法编译。Rust 会抱怨beginning_state不再存在,因为它已经被转换成了next_state

真实的类型系统检查示例

在谈论编译时检查和高性能计算时,有一个例子我非常喜欢:Philipp Oppermann 为内核编写了一个只有两个特质的类型安全分页系统。让我们首先理解这个问题,然后尝试解决方案。

当一个程序在计算机上使用内存时,它必须将虚拟内存与物理内存分开。这是因为运行在操作系统中的每个程序都会认为整个地址空间是它们的。这意味着在一个 64 位机器上,每个程序都会认为它有 16 exbibytesEiB)的内存,即 2⁶⁴字节。

当然,这并不是世界上任何计算机的情况,所以内核所做的就是将内存从 RAM 移动到 HDD/SSD,并将所需的内存放入 RAM。为了正确工作,内存必须以块的形式进行管理,因为移动单个内存地址是没有意义的。这些被称为页面,对于 x86_64 处理器(大多数笔记本电脑和台式计算机的情况)来说,它们通常大小为 4 KiB。

为了使分页易于管理,创建了一个分页层次结构。每 512 页被添加到一个称为 P1 表的索引中,每 512 个 P1 表被添加到一个 P2 表中。这个过程递归进行,直到所有页面都被分配,这将达到 4 级。这就是它被称为 4 级分页的原因。

这个想法是,内核应该能够向一个表请求其页面之一,如果它是一个 P4 表,它应该能够请求一个 P3,然后是 P2,然后是 P1,最后加载由 P1 引用的页面。这个地址通过一个 64 位寄存器传递,所以所有数据都在那里。问题是,对于每种表类型,可能会很容易出现大量的代码重复,或者我们可能会得到一个适用于所有页面的解决方案,但为了返回下一个表(如果它是 P4-P2 表)或实际页面(如果它是 P1 表),它必须在运行时检查当前页面。

第一种情况确实容易出错,难以维护,而第二种情况不仅继续容易出错,甚至需要在运行时进行检查,使其变慢。Rust 可以做得更好。

解决方案是定义一个所有页面都有的特质,让我们称它为PageTable,以及一个只有高阶表有的特质(不能直接返回页面但需要返回另一个页面表的表)。让我们称它为HighTable。由于所有HighTable类型也都是PageTable,一个特质将继承另一个特质:

pub trait PageTable {}

pub enum P4 {}
pub enum P3 {}
pub enum P2 {}
pub enum P1 {}

impl PageTable for P4 {}
impl PageTable for P3 {}
impl PageTable for P2 {}
impl PageTable for P1 {}

这创建了代表页面表级别的四个枚举。使用枚举而不是结构体的原因是空枚举不能实例化,这样可以避免一些错误。然后我们编写HighTable特质:

pub trait HighTable: PageTable {
    type NextTable: PageTable;
}

impl HighTable for P4 {
    type NextTable = P3;
}

impl HighTable for P3 {
    type NextTable = P2;
}

impl HighTable for P2 {
    type NextTable = P1;
}

如你所见,我们为每个枚举添加了一个关联类型来表示分页的下一级。但是,当然,在最后一级的情况下,它下面不会有另一个页面表。

这允许你定义与HighTable关联的函数,这些函数将无法被 P1 表等访问。它还让你创建一个Page类型,该类型将包含Page的内容(大致是一个字节数组),它是通用的,适用于任何级别。

Rust 将确保你无法在编译时尝试获取 P1 表的下一个表,而在运行时,这些枚举将消失,因为它们是零大小的。逻辑将是安全的,并且将在编译时进行检查,没有开销。

额外的性能技巧

编译时检查并不是唯一可以免费获得性能提升的地方。在第一章,“常见性能陷阱”中,我们看到了人们在 Rust 中编写的常见错误,但我们把最先进的技巧和窍门留给了这一章。

使用闭包来避免运行时评估

有时候,编写代码可能看起来很自然,但它的性能并不如预期。很多时候,这是由于 Rust 在运行时进行一些额外的计算。某人可能会编写以下不必要的计算示例:

    let opt = Some(123);
    let non_opt = opt.unwrap_or(some_complex_function());

我故意使这个例子简单,因为真实示例通常需要非常长的代码。尽管如此,其背后的思想是有效的。当你有一个OptionResult时,你有一些非常有用的函数来允许你获取其内部的值或默认值。有一个特定的函数,即unwrap_or()函数,它允许你指定默认值。当然,你可以向该函数传递任何你想要的东西,但如果你需要进行复杂的计算来计算默认值(并且它不是一个常量),代码的性能将会很差。

这是因为在调用unwrap_or()函数时,你必须事先计算出你传递的值。如果大部分时间值都存在且不需要计算,这就没有太多意义。更好的选择是使用unwrap_or_else()。这个函数接受一个闭包,该闭包只有在Option/ResultNone/Err时才会执行。在这个具体案例中,由于some_complex_function()没有参数,你可以直接将其作为闭包使用:

    let opt = Some(123);
    let non_opt = opt.unwrap_or_else(some_complex_function);

但是,如果函数需要参数,你需要自己构建闭包:

    let opt = Some(123);
    let non_opt = opt.unwrap_or_else(|| {
        even_more_complex_function(get_argument())
    });

这样,你可以使用一个非常复杂的函数,复杂程度由你决定,如果函数内部有Option类型的内容,你将避免调用它。同时,你也会降低函数的圈复杂度。

不稳定排序

也有一个有趣的地方可以取得一些收益。通常,当你想要对一个向量进行排序时,例如,会使用稳定的排序。这意味着如果两个元素具有相同的排序,原始的顺序将被保留。让我们用一个例子来看看。假设我们有一个水果列表,我们想要按字母顺序对其进行排序,只考虑它们的第一个字母:

    let mut fruits = vec![
        "Orange", "Pome", "Peach", "Banana", "Kiwi", "Pear"
    ];
    fruits.sort_by(|a, b| a.chars().next().cmp(&b.chars().next()));

    println!("{:?}", fruits);

这将打印出以下内容:

图片

并且按照这个顺序。即使按照整个单词排序时,PeachPear应该在Pome之前,但由于我们只考虑第一个字符,排序是正确的。最终的顺序取决于开始的顺序。如果我改变了第一个列表,将Pome放在Peach之后,最终的顺序将是PomePeach之后。这被称为稳定的排序

另一方面,不稳定的排序并不试图保留之前的顺序。因此,PomePeachPear之间的顺序可以是任意的。这与按第一个字母排序的条件一致,但不会保留原始顺序。

这种不稳定排序实际上比稳定排序更快,如果你不介意尊重初始顺序,你可以在排序操作中节省宝贵的时间,排序操作是最耗时的操作之一。一个简单的例子是按字母顺序对结果列表进行排序。在出现不匹配的情况下,你通常不关心它们在数据库中的排序方式,所以一个在前一个在后或者反过来都没有关系。

要使用不稳定的排序,你需要调用sort_unstable()sort_unstable_by(),这取决于你是否想使用每个PartialOrd元素的默认比较,或者使用你自己的分类器,如果你需要一个自定义的分类器,或者向量的元素不是PartialOrd。考虑以下使用不稳定排序的示例:

    let mut fruits = vec![
        "Orange", "Pome", "Peach", "Banana", "Kiwi", "Pear"
    ];
    fruits.sort_unstable_by(|a, b| a.chars().next().cmp(&b.chars().next()));

    println!("{:?}", fruits);

这种情况下的可能输出如下,这在稳定的排序中是不可能的:

图片

因此,总结一下,如果你真的需要保持输入的顺序,使用稳定的排序;如果不,使用不稳定的排序,因为这将使你的程序运行得更快。

映射哈希

Rust 还有一个开发选项,允许你使映射的哈希更快。这来自于这样一个想法:当在HashMap中存储信息时,例如,键被哈希或进行快速查找。这很好,因为它允许使用任意长和复杂的键,但在检索值或插入新值时增加了开销,因为必须计算哈希。

Rust 允许你更改HashMap的哈希方法,甚至可以创建自己的。当然,通常最好的做法是使用默认的哈希算法,因为它已经过彻底测试,避免了冲突(不同的键具有相同的哈希值并相互覆盖)。Rust 的默认哈希器是一个非常高效的哈希器,但如果你需要性能,并且你正在处理一个非常小的HashMap或某种可预测的HashMap,使用你自己的函数或甚至 Rust 中包含的更快函数是有意义的。

但要注意——在一个用户可以提供(或操作)键的环境中,使用这些函数是非常危险的。他们可能会生成冲突并修改他们不应访问的键的值。他们甚至可以使用它来创建拒绝服务攻击。

使用不同的哈希方法就像在创建HashMap时使用with_hasher()函数一样简单:

use std::collections::HashMap;
use std::collections::hash_map::RandomState;

// <u8, u8> as an example, just to make the type inference happy.
let map: HashMap<u8, u8> = HashMap::with_hasher(RandomState::new());

目前,标准库中仅提供RandomState;其余的都已弃用。但你可以通过实现Hasher特性来创建自己的:

use std::hash::{BuildHasher, Hasher};

#[derive(Clone)]
struct MyHasher {
    count: u64,
}

impl Hasher for MyHasher {
    fn finish(&self) -> u64 {
        self.count
    }

    fn write(&mut self, bytes: &[u8]) {
        for byte in bytes {
            self.count = self.count.wrapping_add(*byte as u64);
        }
    }
}

impl BuildHasher for MyHasher {
    type Hasher = Self;
    fn build_hasher(&self) -> Self::Hasher {
        self.clone()
    }
}

这创建了MyHasher结构,其中包含一个可以按你的意愿初始化的计数器。hash函数实际上非常简单;它只是将键的所有字节相加,并返回一个包含求和结果的u64。在这里生成冲突很容易:你只需要让你的字节求和相同。所以[45, 23]将具有与[23, 45]相同的哈希值。但它作为一个哈希器的例子。还需要BuildHasher特性,它只需要返回一个Hasher的实例。我派生了Clone特性,并简单地克隆了它。

这很容易使用,就像我们之前看到的那样:

    use std::collections::HashMap;

    let mut map = HashMap::with_hasher(MyHasher { count: 12345 });
    map.insert("Hello", "World");

这可能比默认的哈希器更快,但安全性也会低得多。所以请小心选择你使用的哈希函数。

完美哈希函数

如果在编译时已知映射,并且在运行时不会改变,那么有一个非常、非常快的系统可以按数量级提高映射的使用效率。它被称为完美哈希函数,这是它们的关键:它们执行最小的计算来确定一个哈希值是否存储在哈希表中。这是因为它将一个,且仅有一个整数映射到每个元素。并且没有冲突。当然,这需要在编译时有一个已知的常量哈希表。

要使用它们,你需要 phf 包。使用这个包,你可以在 build.rs 文件中定义一个哈希表,该文件与 Cargo.toml 文件位于同一级别,并且使用它时,代码中的开销不会比比较操作更大。让我们看看如何配置它。

首先,你需要将 phf_codegen 包添加为开发依赖项。为此,你需要在 Cargo.toml 中添加一个 build-dependencies 部分,其语法与 dependencies 部分相同。然后,你需要创建一个 build.rs 文件,并在其中需要如下内容:

extern crate phf_codegen;

use std::path::Path;
use std::env;
use std::fs::File;
use std::io::{BufWriter, Write};

fn main() {
    let out_dir = env::var("OUT_DIR").unwrap();
    let path = Path::new(&out_dir).join("phf.rs");
    let mut file = BufWriter::new(File::create(&path).unwrap());

    let map = [("key1", "\"value1\""), ("key2", "\"value2\"")];

    write!(
        &mut file,
        "static MAP: phf::Map<&'static str, &'static str> =\n"
    ).unwrap();

    let mut phf_map = phf_codegen::Map::new();
    for &(key, value) in &map {
        phf_map.entry(key, value);
    }

    phf_map.build(&mut file).unwrap();
    write!(&mut file, ";\n").unwrap();
}

让我们检查这里发生了什么。build.rs 脚本在编译开始之前运行(如果存在)。我们有一个键/值元组的数组映射。然后它创建一个代码生成映射,并逐个将条目添加到映射中。这必须在一个循环中完成,因为编译器栈可能会因为深度递归而溢出。

它将写入一个名为 phf.rs 的文件,首先添加一个静态变量,然后写入整个映射到文件中,并以新行结束。这意味着一旦开始编译,就会存在一个名为 phf.rs 的新文件,我们可以从我们的代码中使用它。如何?你需要直接在代码中包含这个文件:

extern crate phf;

include!(concat!(env!("OUT_DIR"), "/phf.rs"));

fn main() {
    println!("{}", MAP.get("key1").unwrap());
}

这将打印与 key1 关联的值,在这种情况下,是 value1

注意,在 build.rs 文件中创建映射时,值是直接写入的,所以如果你想放一个字符串,你需要添加引号并转义它们。这使你能够添加枚举变体,或者为值直接编写代码。

一旦你学会了如何使用编译时哈希表,你应该了解标准库允许你使用的不同类型的集合,因为这对你的应用程序的速度和内存占用至关重要。

标准库集合

Rust 的标准库在 std::collections 模块中有八种不同的集合类型。它们被分为序列、映射、集合和一个不适合任何组的二叉堆。最著名的是 HashMapVec,但每个都有其用例,你应该了解它们,以便在每个时刻使用正确的类型。

官方的标准库文档非常好,所以你应该彻底检查它。无论如何,我将会介绍这些类型,以便你能够熟悉它们。让我们从序列开始。

序列

在 Rust 以及大多数语言中,最常用的动态序列是向量,在 Rust 中表示为 Vec。你可以使用 push() 方法向向量的末尾添加元素,并使用 pop() 方法获取最后一个元素。你也可以遍历向量,默认情况下它将从前往后遍历,但你也可以反转迭代器从后往前遍历。一般来说,Rust 中的向量可以比作栈,因为它主要是一个后进先出(LIFO)结构。

向量在你想向列表中添加新元素,并且你愿意使用索引在切片中获取元素时非常有用。记住,向量可以作为切片引用,并且可以使用范围进行索引。有趣的是,你可以将向量转换为 boxed slice,这类似于数组,但分配在堆上而不是栈上。你只需要调用into_boxed_slice()方法。这在向量增长完成后,你想让它占用更少的 RAM 时很有用。向量有一个容量、一个长度和一个指向元素的指针,而 boxed slice 将只包含指针和长度,从而避免一些额外的内存使用。

另一个有用的序列是VecDeque序列。这个结构是一个 FIFO 队列,你可以使用push_back()方法将元素追加到队列的末尾,并使用pop_front()从前面弹出元素。这可以用作缓冲区,因为它可以在你继续向队列末尾添加元素的同时从前端消耗。当然,为了在跨线程边界使用它作为缓冲区,你需要使用Mutex等锁。这些队列的迭代从前面到后面,与向量中的迭代方式相同。它是通过可增长的环形缓冲区实现的。

最后,LinkedList是另一种顺序列表,其特点是它不像在内存中有一个元素块,每个元素都链接到它前面的一个和后面的一个,因此不需要索引。迭代很容易,删除列表中的任何元素也很容易,无需留下空隙或重新排序内存,但通常它不是非常节省内存,并且需要更多的 CPU 消耗。

你大多数时候会倾向于使用VecVecDequeLinkedLists通常只有在需要在序列中间进行许多插入和删除操作时才是一个好的选择,因为在这种情况下,VecsVecDeques将不得不重新排序,这需要很多时间。但如果你通常只会从后端更改列表的结构,Vec是最好的选择;如果你也会从前端更改它,那么VecDeque。记住,在这两种情况下,你可以通过索引轻松读取任何元素,只是从中部删除或添加它们会更耗时。

映射

有两种类型的映射:HashMapBTreeMap。它们之间的主要区别在于它们在内存中的排序方式。它们有类似的方法来插入和检索元素,但性能会根据操作有很大变化。

HashMap 创建一个索引,其中每个键通过哈希值指向相应的元素。这样,你就不需要为每个新的 insert/delete/get 操作检查整个键。你只需对它进行哈希,然后在索引中搜索。如果存在,可以检索、修改或删除;如果不存在,可以插入。插入新元素非常快,只需将其添加到索引中即可。检索也大致相同:如果哈希索引存在,则获取其值;所有操作都在 O(1) 时间内完成。不过,你不能将一个 HashMap 添加到另一个中,因为它们的哈希算法将不同,或者至少处于不同的状态。

另一方面,BTreeMap 不创建索引。它维护一个有序的元素列表,因此当你想要插入或获取一个新元素时,它会进行二分搜索。检查键是否大于列表中间的键。如果是,将列表的后半部分分成两部分,并再次使用后半部分的中间元素尝试;如果不是,对前半部分做同样的操作。

这样,你不必将每个元素与映射中的所有元素进行比较,并且可以快速检索它们。添加新元素的操作成本也类似,所有操作都可以在 O(log n) 时间内完成。你还可以将另一个 BTreeSet 添加到这个集合中,并且元素将被重新排序,以便搜索尽可能快。

集合

HashMapBTreeMap 都有它们的集合对应物,分别称为 HashSetBTreeSet。两者都是基于相同的思想实现的:有时你不需要键/值存储,只需要一个元素存储,你可以通过迭代来检索元素列表,或者通过比较来检查元素是否在内部。

它们的实现方式与它们的映射对应物相同,你可以将它们视为它们的映射对应物,但带有空值,其中只有键在执行工作。

摘要

在本章中,你学习了如何利用编译时检查的优势。你学习了 Rust 的类型系统如何帮助你创建复杂且安全的操作,而无需运行时开销。你学习了如何创建状态机以及如何使你的代码更不容易出错。

你还了解了一些额外的性能增强,这些增强补充了 第一章 中提到的内容,常见的性能陷阱。你学习了不稳定的排序和映射哈希,包括编译时创建的完美哈希函数,以及如何创建没有运行时开销的编译时哈希映射。

最后,你了解了标准库中的集合,它们的分类以及根据情况选择哪种类型的集合。你了解了序列、映射和集合,以及它们如何适应你的代码。

在第三章,Rust 中的内存管理,我们将讨论 Rust 中的内存管理。即使,在 Rust 中,你不需要手动分配和释放内存,仍然有许多事情你可以做来优化你的内存占用。

第三章:Rust 中的内存管理

到目前为止,我们一直在谈论 Rust 编译器如何自己处理内存,以及这如何使它内存安全,并给我们一些额外的超级能力,而无需担心创建内存漏洞。尽管如此,使用不可安全作用域,甚至使用安全代码,你所能完成的事情是没有限制的。

我们将检查 Rust 提供的所有有关内存管理的配置和元编程选项,并看看我们如何通过使用安全和不可安全代码来改进我们的代码。

在本章中,我们将探讨以下主题:

  • 学习借用检查器的规则

  • 绑定生命周期

  • 内存表示

  • 与 C/C++的 FFI 数据表示

  • 共享指针

  • 引用计数指针

掌握借用检查器

为了确保内存和线程安全,Rust 的借用检查器有三个简单的规则。除了在不可安全的作用域之外,这些规则贯穿整个代码。以下是它们:

  • 每个绑定都将有一个所有者

  • 一个绑定只能有一个所有者

  • 当所有者超出作用域时,绑定将被丢弃

这三条规则看起来很简单,但它们对我们编码方式的影响很大。编译器可以在所有者超出作用域之前就知道,所以它总是会知道何时释放/销毁绑定/变量。这意味着你可以编写代码,而无需考虑在哪里创建变量,在哪里调用析构函数,或者你是否已经调用过析构函数或者你正在重复调用它。

当然,这伴随着一个额外的学习曲线,有时可能很难跟上。第二条规则是大多数人发现难以管理。由于一次只能有一个所有者,共享信息有时变得有些困难。

让我们用一个已知类型,Vec类型的例子,来看看这种行为:

    let mut my_vector = vec![0, 16, 34, 13, 95];
    my_vector.push(22);
    println!("{:?}", my_vector);

这将打印以下内容:

图片

在当前作用域的末尾(例如main()函数),将通过调用其析构函数来丢弃向量。在这种情况下,它将简单地干净地释放内存然后销毁自己。

分配

为了使变量可增长(以便它可以在不同时间占据内存中的不同空间),它需要在堆上分配,而不是在栈上。栈工作得更快,因为程序加载时,它会自动分配给它。但堆较慢,因为每次分配都需要对内核执行系统调用,这意味着你需要进行上下文切换(到内核模式)然后再切换回来(到用户模式)。这会使事情变得太慢。

向量(以及其他标准库结构)有一种有趣的内存分配方式,以便它们尽可能高效地执行。让我们检查它使用此代码分配新内存的算法:

    let mut my_vector = vec![73, 55];
    println!(
        "length: {}, capacity: {}",
        my_vector.len(),
        my_vector.capacity()
    );

    my_vector.push(25);
    println!(
        "length: {}, capacity: {}",
        my_vector.len(),
        my_vector.capacity()
    );

    my_vector.push(33);
    my_vector.push(24);
    println!(
        "length: {}, capacity: {}",
        my_vector.len(),
        my_vector.capacity()
    );

输出应该是这样的:

图片

这意味着,一开始,向量只会分配我们前两个元素所需的空间。但一旦我们添加一个新的元素,它将为两个新元素分配空间,所以当进行第四次 push 时,它就不需要再分配更多内存。当我们最终插入第五个元素时,它为另一个四个元素分配空间,这样它就不需要再分配,直到它达到第九个。

如果你遵循这个进程,下一次它将为 8 个更多元素分配空间,使容量增长到 16。这取决于第一次分配,如果我们从 3 个元素开始向量,数字将是 3、6、12、24...无论如何,我们可以使用两个函数reserve()reserve_exact()强制向量预先分配一定数量的元素。前者将为至少给定数量的元素保留空间,而后者将为正好给定数量的元素保留空间。当你知道输入的大小,这样它就不需要一次又一次地分配,这非常有用。它只会分配一次。

可变性、借用和拥有

Rust 中还有一些关于可变性的规则,可以防止线程之间的数据竞争。让我们看看它们:

  • 所有绑定默认都是不可变的

  • 在同一时间可以有无限制的不可变借用

  • 在任何给定时间点,一个绑定最多只能有一个可变借用

  • 如果存在可变借用,则在该时间点不能存在不可变借用

它们相对容易理解。你可以从你想要的地方读取绑定内容,但如果你想修改一个绑定,你必须确保没有其他读者或写者存在。这当然可以防止数据竞争,但会使你的编码变得有些麻烦。

让我们通过几个例子来看看这个。首先定义这两个函数:

fn change_third(slice: &mut [u32]) {
    if let Some(item) = slice.get_mut(2) {
        *item += 1
    }
}

fn print_third(slice: &[u32]) {
    if let Some(item) = slice.get(2) {
        println!("Third element: {}", item);
    }
}

change_third()函数需要一个可变的u32切片,如果切片至少有三个元素,它将用于将1添加到第三个元素。第二个将打印该元素。然后你可以使用这个main()函数来测试它:

fn main() {
    let mut my_vector = vec![73, 55, 33];
    print_third(&my_vector);
    change_third(&mut my_vector[..]);
    print_third(&my_vector);
}

如您所见,由于两个函数都借用了向量(一个可变借用,另一个不可变借用),您可以在main()函数中继续使用向量。这意味着向量的所有权在main()函数中。

如果我们有一个拥有向量所有权的函数,我们就无法在以后使用它。考虑将change_third()函数更改为这个:

fn change_third(mut slice: Vec<u32>) {
    if let Some(item) = slice.get_mut(2) {
        *item += 1
    }
}

在这种情况下,函数接收参数并拥有向量的所有权(在函数声明中没有切片或引用)。当然,我们需要更改对函数的调用:

    change_third(my_vector);

问题在于程序将无法编译。在我们将向量的所有权交给change_third()函数之后,main()函数中将不再有my_vector变量。Rust 编译器显示的错误信息非常明确,它甚至会指出问题所在:

总结来说,如果你在使用变量调用函数之后还需要继续使用该变量,应该通过引用传递,让函数借用你的变量但不拥有它。如果不这样做,而你又希望新函数对变量有绝对的控制权(甚至可以丢弃它),那么就通过值传递。这一点不适用于Copy类型,正如我们在第一章中看到的,常见性能陷阱,因为在这种情况下,整个对象都会被复制到新函数中。

引用可能有点难以管理。我们有时需要结构体具有引用值,但由于结构体不会拥有变量以丢弃它,它必须确保变量所有者不会在使用期间丢弃它。为此,我们有生命周期。

生命周期

在 Rust 中,每个变量、结构体属性和常量都有一个生命周期。其中大多数都可以省略,因为我们通常知道常量具有静态生命周期(它将始终存在),或者大多数变量具有其作用域的生命周期。尽管如此,有时我们还需要指定生命周期。让我们检查以下结构体:

struct Parent<'p> {
    age: u8,
    child: Option<&'p Child>,
}

struct Child {
    age: u8,
}

如您所见,父结构体有一个指向孩子的引用,但我们添加了两个以单引号为前缀的字母。这些都是生命周期指定,这意味着指向孩子的引用至少要和父结构体存在的时间一样长。让我们用一个简单的main()函数来看看这种行为:

fn main() {
    let child = Child { age: 10 };
    let parent = Parent {
        age: 35,
        child: Some(&child),
    };

    println!("Child's age: {} years.", parent.child.unwrap().age);
}

这将打印出孩子是10岁。孩子会在main函数的末尾被丢弃,所以引用在parent存在时是有效的。但让我们创建一个小的内部作用域来看看我们是否能欺骗编译器。内部作用域是显式的作用域,你可以通过使用花括号来创建。所有在内部定义的变量将在内部作用域的末尾被丢弃,如果在末尾添加了一个没有分号的表达式,那么这个表达式的值将是作用域的值,并且可以赋给任何变量。

让我们尝试向一个将在内部作用域中被丢弃的parent添加一个child

    let mut parent = Parent {
        age: 35,
        child: None,
    };

    {
        let child = Child { age: 10 };
        parent.child = Some(&child);
    }

    println!("Child's age: {} years.", parent.child.unwrap().age);

如果我们尝试编译这段代码,编译器会告诉我们child的生命周期不够长。编译器已经理解了我们在结构体中告诉它的,即child的生命周期至少要和Parent结构体一样长,并且由于在这种情况下,它知道内部作用域中定义的变量将在那里被丢弃,因此它会在编译时提出警告,并阻止你将其添加到parent中。

这可以扩展到函数。让我们考虑一个非常简单的函数,该函数返回两个提供的子项中最年长的子项的引用:

fn oldest_child(child1: &Child, child2: &Child) -> &Child {
    if child1.age > child2.age {
        child1
    } else {
        child2
    }
}

这将无法编译,因为它需要一个生命周期参数。这意味着编译器不知道返回的子项是否会像child1一样长时间存活,或者像child2一样长时间存活。我们也不知道,所以我们将指定所有生命周期必须至少与当前函数一样长,然后其余的就是我们调用者的问题:

fn oldest_child<'f>(child1: &'f Child, child2: &'f Child) -> &'f Child {
    if child1.age > child2.age {
        child1
    } else {
        child2
    }
}

这只是声明了一个新的生命周期(在参数前的第一个括号之前声明),我们称之为f,它将是函数的生命周期。然后我们指定所有引用必须至少与函数一样长。

内存表示

除了管理引用、所有权、分配和复制之外,我们还可以管理我们之前看到的那些结构的内存布局,我们可以通过使用安全和不可安全代码来实现这一点。让我们首先了解 Rust 如何管理内存。考虑以下结构:

struct Complex {
    attr1: u8,
    attr2: u16,
    attr3: u8,
}

对齐

当从内存中访问属性时,它们需要对齐,以便它们在内存中的位置是它们大小的倍数,在这种情况下是 16 位。这样,当我们尝试获取每个属性时,我们只需要将 16 位加到结构的基址上,乘以属性。这使得信息检索更加高效,并且这是由编译器自动完成的。主要问题是,为了使每个属性对齐为 16 位,编译器需要为前三个属性中的每一个填充 8 位。

这意味着结构将被转换为以下形式:

struct Complex {
    attr1: u8,
    _pad1: u8,
    attr2: u16,
    attr3: u8,
    _pad2: u8,
}

但是,在这个具体案例中,attr1attr3都有 8 位,所以它们不需要 16 位对齐;它们可以是 8 位对齐并且正常工作。这意味着我们可以将第一个属性移到末尾,这样它就会像有两个 16 位对齐的属性一样,第二个属性将包含两个 8 位对齐的属性:

struct Complex {
    attr2: u16,
    attr1: u8,
    attr3: u8,
}

这不需要额外的填充,因此结构将占用 32 位(而不是之前的 48 位)。这是一个典型的优化,在 C/C++中必须手动完成,这会打乱我们的属性顺序,但在 Rust 中我们可以做得更好。编译器知道这一点,并且会尽可能重新排序字段以获得更好的内存占用,因此你可以按照你想要的顺序放置属性。

但是,如果编译器已经自动完成这个操作,那么在性能优化书中这样做有什么意义呢?嗯,有一种情况你希望避免这种行为。

让我们面对现实,并不是所有的软件都是用 Rust 编写的,在高性能库的情况下,我们通常不得不使用 C 依赖项。幸运的是,Rust 可以无缝地与任何 C 兼容的接口集成,而且不收取任何费用。但是,如果你在 Rust 和 C 代码之间移动结构,你将遇到问题。

正如我们讨论的,Rust 会重新排序字段,这意味着 C 和 Rust 中的结构可能不会以相同的方式定位属性。不过,我们可以通过使用带有C值的repr属性来告诉 Rust 不要改变字段的顺序:

#[repr(C)]
struct Complex {
    attr3: u8,
    attr2: u16,
    attr1: u8,
}

这将使结构与 C 兼容。我们还可以告诉 Rust 不要对属性添加填充,因此即使对齐可能更好,结构也将是最小尺寸。请注意,这将破坏需要对齐结构的平台上的代码。如果您仍然想使用它,您只需简单地使用表示的packed形式:

#[repr(packed)]
struct Complex {
    attr3: u8,
    attr2: u16,
    attr1: u8,
}

复杂枚举

如果您了解 C/C++枚举,您知道每个元素代表一个值,并且您可以使用它们来避免记住可能值集中的正确整数。不过,它们不是强类型的,因此您可以混合不同的枚举。并且它们只能存储一个整数。

再次强调,Rust 可以做得更好,我们可以创建复杂的枚举,其中我们不仅可以有强类型(我们不会混合枚举),我们甚至可以在枚举中拥有比整数更多的内容。正如您在下面的Color枚举中可以看到的,我们可以有内部数据,甚至属性:

enum Color {
    Red,
    Blue,
    Green,
    Other { r: u32, g: u32, b: u32 },
}

如您所见,在这种情况下,枚举可以具有四种值之一,但在最后一种情况下,它将关联三个数字。这为您提供了几乎无限的可能性,您可以安全地表示任何数据结构。例如,查看Serde crate 对这个任何 JSON 值的实现,它是生态系统中最常用的 crate 之一:

pub enum Value {
    Null,
    Bool(bool),
    Number(Number),
    String(String),
    Array(Vec<Value>),
    Object(Map<String, Value>),
}

JSON 结构中的值可以是 null,布尔值(并且包含它是true还是false的信息),数字(这将是一个枚举,以了解它是正数、负数还是浮点数),字符串,包含文本信息,值的数组,或者一个整个 JSON 对象,其键为字符串,值为。

然而,这种方法有两个缺点。对于枚举的不同变体的比较,它们必须被标记。这意味着它们将需要占用一些额外的空间,仅为了在运行时区分它们。

第二个问题是,枚举类型的大小(不考虑标记)将是最大选项的大小。所以如果你有 10 个可以存储在 1 字节的选项,但另一个需要 10 字节,枚举将有 10 字节(加上标记)独立于存储的变体。这是因为它作为一个union(在 C/C++语言中)工作,其中所有变体共享相同的表示。

为了减轻这一点,一个选择是将大对象作为引用。我们可以有两种方式来做这件事。第一种方式是通过借用颜色,在这种情况下,编译器将强制我们不在创建颜色的任何函数中返回枚举(记住,引用将在作用域结束时被销毁):

enum Color<'c> {
    Red,
    Blue,
    Green,
    Other(&'c Rgb),
}

struct Rgb {
    r: u32,
    g: u32,
    b: u32,
}

如果我们想避免这种情况,我们可以简单地通过装箱(是的,这将降低性能)将那个元素存储在堆上。这取决于你是否需要较低的 RAM 消耗或更快的速度。要存储堆上的元素,你需要使用Box类型,就像你在这里可以看到的那样:

enum Color {
    Red,
    Blue,
    Green,
    Other(Box<Rgb>),
}

联合体

此外,还有一种未标记的联合体类型。如果联合体中的类型不是Copy,你需要使用untagged_unions特性,并使用夜间编译器编译代码。这可以通过在联合体内使用的结构中派生Copy特性来避免,但我们不应该对大型结构这样做,就像我们之前讨论的那样:

union Plant {
    g: Geranium,
    c: Carnation,
}

#[derive(Copy, Clone)]
struct Geranium {
    height: u32,
}

#[derive(Copy, Clone)]
struct Carnation {
    flowers: u8,
}

在这个特定的例子中,Plant可以是GeraniumCarnation。或者更准确地说,它将同时是两者。Plant将具有其中最大的结构的大小,并且它不会为描述它是哪个变体的标签添加任何额外的填充。

这意味着当你在联合体中写入一个字段时,你也会改变其他字段。在创建联合体时,你只需要指定一个字段,由于编译器在编译时不知道它是哪个变体,因此你需要使用一个不安全块来读取值,就像你可以在下一段代码中看到的那样,因为读取未设置值最终会导致未定义行为:

fn main() {
    let mut my_plant = Plant {
        c: Carnation { flowers: 15 },
    };
    my_plant.g = Geranium { height: 300 };
    let height = unsafe { my_plant.g }.height;

    println!("Height: {}", height);
}

在这个例子中,我们首先创建一个Plant,它是一个Carnation,然后我们将其转换为Geranium。这种变化不需要不安全块,因为Plant将始终具有 32 位,即Geranium的大小,因此它可以安全地分配。

当我们检索高度时,尽管如此,我们需要指定我们想要将Plant作为Geranium来读取,然后获取高度。在这种情况下,它工作得非常好,因为我们已经将Plant更改为Geranium。如果我们在这个例子中尝试将植物作为Carnation获取,它将触发未定义行为。这意味着花朵的数量可能是一个随机数,取决于联合体的布局。尽管如此,这并不是一个安全漏洞,因为我们获取的花朵数量的u8将是Geranium高度的字节之一,它只是感觉随机(在我的情况下,它显示有 44 朵花)。

但在任何情况下,这对于与 C(FFI)接口特别有用。如果我们使用#[repr(C)]属性在联合体中,它将结构与 C 中的结构完全相同,因此我们可以将联合体发送到 C 库,而无需考虑如何模拟 C 联合体。

共享指针

Rust 最被批评的问题之一是难以开发具有共享指针的应用程序。正如我们之前看到的,由于 Rust 的内存安全保证,开发这类算法可能确实比较困难,但正如我们现在将看到的,标准库为我们提供了一些我们可以用来安全地允许这种行为的类型。

单元模块

标准库有一个有趣的模块,即 std::cell 模块,它允许我们使用具有内部可变性的对象。这意味着我们可以有一个不可变对象,并且仍然可以通过获取对底层数据的可变借用来修改它。当然,这不会符合我们之前看到的可变性规则,但 Cell 通过在运行时检查借用或对底层数据进行复制来确保这一点。

Cells

让我们从基本的 Cell 结构开始。一个 Cell 将包含一个可变值,但它可以在没有可变 Cell 的情况下被修改。它主要有三个有趣的方法:set()swap()replace()。第一个允许我们设置包含的值,用新值替换它。之前的结构将被丢弃(析构函数将运行)。这一点与 replace() 方法不同。在 replace() 方法中,而不是丢弃之前的值,它将返回该值。另一方面,swap() 方法将取另一个 Cell 并在两个之间交换值。所有这些都不需要 Cell 是可变的。让我们用一个例子来看看:

use std::cell::Cell;

#[derive(Copy, Clone)]
struct House {
    bedrooms: u8,
}

impl Default for House {
    fn default() -> Self {
        House { bedrooms: 1 }
    }
}

fn main() {
    let my_house = House { bedrooms: 2 };
    let my_dream_house = House { bedrooms: 5 };

    let my_cell = Cell::new(my_house);
    println!("My house has {} bedrooms.", my_cell.get().bedrooms);

    my_cell.set(my_dream_house);
    println!("My new house has {} bedrooms.", my_cell.get().bedrooms);

    let my_new_old_house = my_cell.replace(my_house);
    println!(
        "My house has {} bedrooms, it was better with {}",
        my_cell.get().bedrooms,
        my_new_old_house.bedrooms
    );

    let my_new_cell = Cell::new(my_dream_house);

    my_cell.swap(&my_new_cell);
    println!(
        "Yay! my current house has {} bedrooms! (my new house {})",
        my_cell.get().bedrooms,
        my_new_cell.get().bedrooms
    );

    let my_final_house = my_cell.take();
    println!(
        "My final house has {} bedrooms, the shared one {}",
        my_final_house.bedrooms,
        my_cell.get().bedrooms
    );
}

如同示例中所示,要使用 Cell,包含的类型必须是 Copy。如果包含的类型不是 Copy,你需要使用 RefCell,我们将在下一节中看到。继续这个 Cell 示例,正如代码所示,输出将是以下内容:

图片

因此,我们首先创建两个房子,我们选择其中一个作为当前的房子,并保持对当前的和新的房子进行修改。正如你可能看到的,我也使用了 take() 方法,它仅适用于实现了 Default 特质的类型。此方法将返回当前值,并用默认值替换它。正如你所见,你实际上并没有修改内部的值,而是用另一个值替换它。你可以检索旧值或者失去它。另外,当使用 get() 方法时,你得到当前值的副本,而不是它的引用。这就是为什么你只能使用与 Cell 一起实现的 Copy 元素。这也意味着 Cell 不需要在运行时动态检查借用。

RefCell

RefCellCell 类似,但它接受非 Copy 数据。这也意味着在修改底层对象时,在返回它时不能简单地复制它,它需要返回引用。同样,当你想要修改内部的对象时,它将返回一个可变引用。这仅因为它在返回可变借用之前会动态检查运行时是否存在借用,或者反过来,如果存在,线程将崩溃。

Cell 中的 get() 方法不同,RefCell 有两个方法来获取底层数据:borrow()borrow_mut()。第一个将获取只读借用,你可以在作用域内拥有任意多个不可变借用。第二个将返回读写借用,你将只能在作用域内有一个,以遵循可变性规则。如果在同一作用域中先执行了 borrow(),然后尝试执行 borrow_mut(),或者先执行了 borrow_mut(),然后尝试执行 borrow(),线程将发生恐慌。

对于这些借用,有两种非恐慌的替代方案:try_borrow()try_borrow_mut()。这两个函数将尝试借用数据(第一个为只读,第二个为读写),如果存在不兼容的借用,它们将返回一个 Result::Err,这样你就可以在不恐慌的情况下处理错误。

CellRefCell 都有一个 get_mut() 方法,它将获取内部元素的可变引用,但它要求 Cell / RefCell 是可变的,所以如果你需要 Cell / RefCell 是不可变的,这就没有太多意义。尽管如此,如果在代码的一部分中你实际上可以有一个可变的 Cell / RefCell,你应该使用这个方法来更改内容,因为它将在编译时静态地检查所有规则,而不产生运行时开销。

令人惊讶的是,当我们调用 borrow()borrow_mut() 时,RefCell 并不返回对底层数据的普通引用。你可能会期望它们返回 &T&mut T(其中 T 是包装的元素)。相反,它们将分别返回一个 Ref 和一个 RefMut。这是为了安全地包装引用内部,以便编译器能够正确地计算生命周期,而不需要引用在整个 RefCell 生命周期内都存在。尽管如此,它们实现了 Deref 到引用,因此得益于 Rust 的 Deref 强制转换,你可以将它们用作引用。

rc 模块

std::rc 模块包含可以在单线程应用程序中使用的引用计数指针。由于计数器不是原子计数器,因此它们的开销非常小,但这意味着在多线程应用程序中使用它们可能会导致数据竞争。因此,Rust 将在编译时阻止你在线程之间发送它们。在这个模块中有两个结构:RcWeak

Rc 是对堆的拥有指针。这意味着它与 Box 相同,只不过它允许使用引用计数指针。当 Rc 超出作用域时,它将引用计数减 1,如果这个计数是 0,它将丢弃包含的对象。

由于 Rc 是一个共享引用,它不能被修改,但一个常见的模式是在 Rc 内部使用 CellRefCell 来允许内部可变性。

Rc可以被降级为Weak指针,这将有一个指向堆的借用引用。当Rc释放其内部的值时,它不会检查是否有指向它的Weak指针。这意味着Weak指针不一定总是有一个有效的引用,因此出于安全考虑,检查Weak指针值的唯一方法是将它升级为Rc,这可能会失败。如果引用已经被释放,upgrade()方法将返回None

让我们通过创建一个示例二叉树结构来检查所有这些:

use std::cell::RefCell;
use std::rc::{Rc, Weak};

struct Tree<T> {
    root: Node<T>,
}

struct Node<T> {
    parent: Option<Weak<Node<T>>>,
    left: Option<Rc<RefCell<Node<T>>>>,
    right: Option<Rc<RefCell<Node<T>>>>,
    value: T,
}

在这种情况下,树将有一个根节点,每个节点可以有最多两个子节点。我们称它们为左节点和右节点,因为它们通常被表示为每边有一个子节点的树。每个节点都有一个指向其子节点之一的指针,并且它拥有子节点。这意味着当一个节点失去所有引用时,它将被释放,连同它的子节点一起。

每个孩子都有一个指向其父节点的指针。这个问题的主要在于,如果孩子节点有一个指向其父节点的Rc指针,那么它将永远不会释放。这是一个循环依赖,为了避免这种情况,父节点的指针将是一个Weak指针。

摘要

在本章中,你学习了借用检查器的工作原理。你现在理解了你的代码必须遵循的规则以进行编译,以及一些小技巧可以使你的代码运行得更快,而无需担心让编译器满意。

你还学习了在 Rust 中结构体和枚举的内存表示,以及如何使你的 Rust 代码与 C/C++兼容。

最后,你了解了 Rust 如何管理复杂结构的共享指针,其中 Rust 的借用检查器可以使你的编码体验变得更加困难。

在第四章,代码检查和 Clippy中,我们将学习关于代码检查以及一个出人意料的优秀的代码检查工具Clippy。使用这些代码检查,你将能够找到我们在编译时看到的大多数问题。

第四章:Lint 和 Clippy

到目前为止,我们需要自己检查代码的所有细节。这往往难以控制,因为我们无法检查每一行代码。在本章中,你将了解 Rust 带给我们的 lint,包括默认启用的和你可以自己启用的。

此外,你还将了解一个伟大的工具 Clippy,它将为你提供更多可用的 lint,并帮助你编写更好的代码。在许多情况下,它会对性能陷阱进行 lint。在其他情况下,它们将是潜在的错误或惯用约定。它还将帮助你清理代码。

在本章中,你将学习以下主题:

  • Rust 中的 linting

  • 默认 lint

  • 使用和配置 Clippy

  • 额外的 Clippy lint

使用 Rust 编译器 lint

在撰写本文时,Rust 编译器有 70 个 lint。我们不会检查所有 70 个,但我们会查看其中最相关的几个。让我们首先从学习如何配置 lint 开始。我们将以 unused_imports 为例。默认情况下,编译器会为此 lint 提醒你。编译将继续,但会在命令行中显示警告,或者在配置了显示 Rust 编译器警告的编辑器中显示。

我们可以改变这种行为,并且我们可以为每个作用域改变它。选项是 allowwarndenyforbid lint。如果我们允许 lint,则不会出现更多警告。如果我们警告,则编译时会出现警告,如果我们拒绝或禁止,如果找到触发 lint 的内容,程序将无法编译。denyforbid 之间的区别在于前者可以在后续被覆盖,而后者则不能。因此,我们可以有一个拒绝一种行为的模块,但在一个特定的函数中,我们希望允许它。

此配置可以在 crate 级别应用,例如,在 lib.rsmain.rs 文件的顶部放置 #![deny(unused_imports)]。它也可以应用于任何作用域,甚至是你可能在函数内部创建的作用域。如果它在 hash 后面有一个感叹号(!),它将影响当前作用域;如果没有,它将影响紧邻的作用域。让我们看看 Rust 编译器提供了哪些 lint。

Lint

在本节中,我们将检查允许默认行为的 lint,你可能会在大多数情况下至少添加一个警告。

避免匿名参数

匿名参数已被弃用。这允许你在不要求在 traits 中绑定名称的情况下指定 traits:

trait MyTrait {
    fn check_this(String);
}

这是一个已弃用的遗留功能,可能在未来的版本中被移除,所以你可能应该避免使用这种语法。为了在你的代码库中警告或拒绝这种语法,你需要使用以下语法:#![warn(anonymous_parameters)]

避免堆分配的 box 指针

Rust 默认在栈上分配空间,因为它比使用堆快得多。尽管如此,有时,当我们不知道对象的大小在编译时,我们需要使用堆来分配新的结构体。Rust 通过使用VecStringBox等类型来明确这一点。最后一个允许我们将任何类型的对象放入堆中,这通常是一个坏主意,但有时这是必需的。

例如,查看以下代码:

fn main() {
    let mut int = Box::new(5);
    *int += 5;
    println!("int: {}", int);
}

这段代码编译得非常完美,它告诉我们整数是10 (5 + 5)。这个问题的主要问题是它执行了堆分配,进行了一个需要找到堆中空间的系统调用等。但我们已经知道整数在编译时具有固定的大小,所以我们应该使用栈来完成这个操作。

通过使用#![warn(box_pointers)]警告每个Box的使用,可以避免这类错误。但请注意:这将警告所有 boxed 类型的用法,所以你可能希望在许多地方明确允许它。

避免缺失的实现

我们可能希望许多类型实现一些特性。其中第一个是Debug特性。Debug特性可能应该由我们所有的类型实现,因为它使得开发者能够打印出关于我们的结构体、枚举等调试信息。此外,它还允许 API 用户通过仅添加一个属性,使用我们的 API 类型在结构体中推导出Debug特性。

我们可以通过添加#![warn(missing_debug_implementations)]来强制实现这个特性对所有我们的类型。唯一的细节是,这个特性将只检查 API 中暴露的类型。所以,它只适用于pub类型。

另一个有趣的特性是Copy特性。有时,我们会创建一个包含几个整数的简单结构体,在某些情况下最好是通过复制来实现,正如我们在前面的章节中看到的。问题是如果我们忘记实现它,我们可能会进行过多的引用,使我们的代码变慢。我们可以通过添加这个 lint 来解决:#![warn(missing_copy_implementations)]

然而,这个 lint 有几个限制。它只适用于pub类型,就像Debug实现 lint 的情况一样,并且它会 lint 所有所有成员都是Copy类型的结构体。这意味着如果我们有一个非常大的结构体,我们不想复制它,我们可能需要为该特定结构体允许这个 lint。

强制文档

这可以说是最重要的 lint,遗憾的是它默认是allow。每当创建一个 API 时,我们必须记录 API 的功能。这将使新开发者更容易使用它。#![warn(missing_docs)] lint 将确保至少所有公共 API 都有一些文档。我个人通常在开发期间将其设置为警告,一旦项目进入生产阶段,就将其更改为deny甚至forbid

指出平凡的转换

有时,我们可能会明确地将一个元素转换为编译器应该自动转换的类型。这通常发生在我们使用特质时,但也可能是因为我们将一个元素的类型更改为新类型,而没有更改转换。为了清理这类行为,我们有trivial_caststrivial_numeric_casts代码审查。让我们用一个例子来看看:

#![warn(trivial_casts, trivial_numeric_casts)]

#[derive(Default, Debug)]
struct MyStruct {
    a: i32,
    b: i32,
}

fn main() {
    let test = MyStruct::default();
    println!("{:?}", (test as MyStruct).a as i32);
}

在这种情况下,我们首先将test转换为MyStruct,但它已经是MyStruct了,所以这是多余的,并且使代码的可读性大大降低,从而增加了出错的可能性。然后我们将它的a属性转换为i32,但它已经是i32了,所以再次是冗余信息。第一种情况不常见,但第二种情况如果我们将这个参数用于只接受i32的函数,并且我们的结构在之前的实现中包含i16,例如,可能会被发现。

在任何情况下,这类铸造都不是好的实践,因为我们可能已经将a属性从i64更改为i32,并且我们会在无声中丢失精度。我们应该使用i32::from(),这样如果我们将它更改为i64,它将简单地停止编译。这将在我们稍后看到的 Clippy 工具中自动进行代码审查。

无论如何,启用这两个代码审查是个好主意,因为它将帮助我们找到这类错误。trivial_casts代码审查会审查非数值类型/特质的转换,而trivial_numeric_casts将审查数值转换。

代码审查不可安全代码块

在某些情况下,特别是如果我们使用的是极低级别的编程,有时用于高性能计算,我们可能需要进行一些指针算术,甚至单指令多数据SIMD)内联,这需要不可安全的作用域。这可能在某些特定的函数或代码片段中是这种情况,但一般来说,我们应该避免不可安全的作用域。

一个经验法则是这样的:如果你不是在处理性能关键代码,不要使用它们。如果你是,请非常小心地使用,并且只在没有其他改进性能的选项的地方使用。这意味着通常内联代码可以封装在一个模块或函数中。

为了确保没有人使用我们希望在显示范围之外使用的不可安全代码,我们可以使用unsafe_code代码审查来审查所有不可安全的作用域。让我们用一个例子来看看:

#![warn(unsafe_code)]

fn main() {
    let test = vec![1, 2, 3];
    println!("{}", unsafe { test.get_unchecked(2) });
}

如果你还记得第一章中的常见性能陷阱,切片中的get_unchecked()函数将获取给定索引处的元素,而不会检查切片的边界,这使得它运行得更快。这也意味着如果索引超出范围,你可能会从内存泄漏到段错误。

在这个例子中,当编译这段代码时,一个警告会告诉我们我们正在使用不可安全代码。如果它对于这个特定的函数是 100%必需的,我们可以允许它,或者我们可以更改代码。一个修复上述问题同时仍然使用不可安全代码的例子可以在这里看到:

#![deny(unsafe_code)]

fn main() {
    let test = vec![1, 2, 3];
    println!("{}", get_second(&test));
}

#[allow(unsafe_code)]
fn get_second(slice: &[i32]) -> i32 {
    *unsafe { slice.get_unchecked(1) }
}

在这个例子中,如果我们将不安全的范围添加到get_second()函数外部,crate 将无法编译。无论如何,这个函数是不安全的,因为它不会检查发送给它的切片的任何边界;我们可能应该在函数开始时添加一个assert!(),或者至少一个debug_assert!(),来检查切片的长度。

未使用的代码风格检查

诚实地讲,我们有时会忘记移除不再使用的依赖项,或者忘记write()方法返回写入的字节数。这通常不是什么大问题。第一个只会使我们的编译速度变慢,而第二个,在大多数情况下,将不会改变我们的代码。

但由于我们不希望有未使用的依赖项,或者我们不希望忘记我们可能没有将整个缓冲区写入文件,这就是下一个代码风格检查帮助我们的地方。让我们从第一个开始,即unused_extern_crates代码风格检查。这个代码风格检查将标记未在我们的代码中使用的外部 crate。这可以用来移除不再使用的依赖项,所以我通常在开始开发时将其配置为warn,一旦我的 crate 进入生产或依赖项在每次提交中都没有变化,就将其更改为forbid

你应该了解的第二个代码风格检查是unused_results。默认情况下,编译器将警告Result<T, E>返回值中未使用的返回值。这是一个重要的细节,因为可能是一个 I/O 操作失败了,例如,你应该相应地采取行动。尽管如此,还有其他情况下 Rust 编译器不会警告,但这可能和之前的一样危险。例如,WriteRead特质分别返回写入和读取的字节数,你应该可能对此数字有所了解。

这个代码风格检查将确保你始终考虑任何返回值,除了空元组()。这有时可能有些烦人,但你可以通过使用下划线绑定显式地丢弃结果,如下所示:

#![warn(unused_results)]

fn main() {
    let _ = write_hello();
}

fn write_hello() -> usize {
    unimplemented!()
}

此外,还有一些代码风格检查可以使你的代码更加易于阅读:unused_qualificationsunused_import_braces。第一个将检测你为某些元素使用额外限定符的地方:

#![warn(unused_qualifications)]

#[derive(Debug)]
enum Test {
    A,
    B,
}

fn main() {
    use Test::*;

    println!("{:?}", Test::A);
    println!("{:?}", B);
}

以下代码示例将警告我们在第一个println!()中不需要使用Test::限定符,因为我们已经导入了Test枚举中的所有值。第二个println!()不会警告我们,因为我们没有指定任何额外的限定符。这将使代码更易于阅读,可能减少错误。

第二个代码风格检查,unused_import_braces,将检查我们只使用导入花括号导入一个元素的地方:

#![warn(unused_import_braces)]

#[derive(Debug)]
enum Test {
    A,
    B,
}

#[derive(Debug)]
enum Test2 {
    C,
    D,
}

fn main() {
    use Test::{A, B};
    use Test2::{C};

    println!("{:?}, {:?}, {:?}", A, B, C);
}

尽管 Rust 格式化器会自动移除 Test2 导入的 C 变体的括号,如果我们不使用格式化器,这是一个有趣的 lint,会提醒我们不需要这些括号,并且移除它们会使代码更简洁。

变体大小差异

正如我们在前面的章节中看到的,枚举的大小将是最大元素的大小加上标签,但是,正如我们讨论的,如果我们有很多小的变体,而其中一个变体的大小更大,这可能会很麻烦:所有变体都将占用最大变体的整个空间。我们看到一个选择是将大变体移动到堆分配。

我们可以使用 variant_size_differences lint 来检测具有明显大于其他变体的枚举。它将检查至少有三个倍数大于其他变体的枚举:

#![warn(variant_size_differences)]

enum Test {
    A(u8),
    B(u32),
}

注意,它不适用于联合,并且如果我们有中等大小的变体,即使最大变体和最小变体之间的差异超过三倍,lint 也不会提醒我们。

Lint 组

Rust 编译器允许我们按组配置一些上述 lint。例如,unused 组将包含许多 unused_ 类型的 lint。warnings 组将包含所有被配置为警告的 lint,等等。

Lint 组可以使用与 lint 相同的方式使用,通过指定当编译器捕获到该行为时你希望发生什么:

#[deny(warnings)]

你可以通过运行 rustc -W help 来查看其余的内置 lint。它将显示我们讨论过的 lint 以及默认情况下会警告或拒绝的其他 lint。

Clippy

如果有一个工具能帮助你最大程度地清理你的代码,那就是 Clippy。在撰写本文时,Clippy 提供了 208 个额外的 lint,其中大多数都非常有用,可以避免一些有趣的陷阱,例如我们在第二章“额外的性能提升”中讨论的 unwrap_or() 使用,或者避免非惯用代码。当然,我们在这里不会看到所有这些,你可以在 Clippy lint 文档的rust-lang-nursery.github.io/rust-clippy/master/中找到它们的完整列表。

由于其中许多默认情况下会警告甚至拒绝,我们将检查一些默认允许但实际上可以极大地提高你的应用程序代码质量甚至性能的 lint。

安装

安装 Clippy 非常简单:你需要通过运行 rustup toolchain install nightly 来安装 Rust 夜间版本,然后你可以通过运行 cargo +nightly install clippy 来安装 Clippy。

注意,由于 Clippy 需要使用 nightly 编译器来构建,并且它使用编译器内建函数,一些 Rust nightly 编译器的更新可能会使其无法使用。这些问题通常在几天内得到修复,并且会发布一个新的 Clippy 版本,但在此期间,你可以通过向 nightly 工具链添加一个以前的日期来选择以前的 nightly 版本:rustup toolchain install nightly-YYYY-MM-DD

一旦安装了正确的工具链,Clippy 将会完美安装。要使用它,你需要进入你的项目并运行 cargo clippy 命令,而不是通常的 cargo checkcargo build 命令。这将运行所有 Clippy 检查并显示结果。

配置

即使我们在下一节将检查单个检查,我们现在将看到如何配置整个 Clippy 执行。Clippy 将读取与 Cargo.toml 文件同一级别的 clippy.toml 文件并根据其内容执行。

一些检查具有配置参数。例如,循环复杂度检查会在函数有超过 25 个分支时提醒你。正如我们在 第一章 中看到的,常见性能陷阱,这是不好的做法,因为它会使编译器对代码的优化变得更加困难,从而产生性能较差的代码。

然而,你可以更改创建警告的阈值。25 个分支是一个合理的数量,但根据你的产品,你可能希望不超过 20 个分支,或者能够达到 30 个分支,例如。在 clippy.toml 中更改此行为的设置是 cyclomatic-complexity-threshold

cyclomatic-complexity-threshold = 30

例如,当 Clippy 发现文档中缺少适当(`)字符来显示它们是代码的结构或枚举名称时,它也会警告你。例如,如果你的软件被命名为 MyCompanyInc,Clippy 会认为它是一个 struct 或一个 enum。对于这种情况也有配置参数。你可以在 Clippy 维基页面上检查所有这些配置:rust-lang-nursery.github.io/rust-clippy/master/

如果我们想在项目中添加 Clippy 检查,当我们不使用 Clippy 编译时,Rust 会警告我们那些检查是未知的。当然,这些是由 Clippy 定义的,但编译器并不知道这一点。Clippy 默认设置了一个 cargo-clippy 功能,当配置检查时,我们可以使用它来消除未知检查的警告:

#![cfg_attr(feature = "cargo-clippy", forbid(deprecated))]

这样,当我们运行 cargo clippy 时,检查将被考虑,但在运行 cargo check 时则不会。

检查

在 Clippy 目前可用的 208 个 lints 中,我们将仅分析那些默认配置为allow的一些。其余的可以在 Clippy wiki 上检查rust-lang-nursery.github.io/rust-clippy/master/,但你应该注意这些,因为它们默认不会显示警报。

投影

投影数字有时是一项危险的操作。我们可能会丢失精度,丢失符号,截断数字等等。Clippy 为我们提供了一些非常有用的 lints,可以避免这些情况。当然,通常你并不关心这些行为,因为你可能知道它们不会发生,或者它们可能是预期的行为。

尽管如此,我发现即使只激活一次以检查这些 cast 发生的地方并将它们设置为默认允许,这些 lints 也是有用的。

这些 lints 如下:

  • cast_possible_truncation

  • cast_possible_wrap

  • cast_precision_loss

  • cast_sign_loss

不良实践

Clippy 还提供了可以检测不良编码实践的 lints。例如,你不应该导入枚举变体,因为枚举变体应该始终以实际的枚举为前缀。为了对此实践进行 lint,你可以使用enum_glob_use lint。

可能会引发问题的其他代码实践是FromInto特质的恐慌实现。根据定义,这些特质永远不应该失败,使用unwrap()expect()panic!()assert!()函数和宏可能会使函数恐慌。尽管这可能是应用程序中期望的行为,但这是一种不良实践(你应该使用TryIntoTryFrom特质,或者在稳定编译器开发时创建一个新函数)。

但主要问题是当开发可能导致整个操作系统恐慌的软件,如内核时。你可以通过使用fallible_impl_from lint 来检测这些问题。

我们在第一章,“常见性能陷阱”中讨论了迭代器,我们看到,我们有时有有用的函数来包装filter()map()。这提高了可读性,并且可以使用filter_map lint 来检测这些函数的连接。

有时,我们编写的条件可能不容易理解,有时是因为我们在带有else分支的条件中使用否定,或者是因为我们添加了太多的条件,这会搞乱我们的比较。我们有两个 Clippy lint 会指出这些情况:if_not_elsenonminimal_bool

第一个将检测条件中的否定,并建议将条件改为正条件,并更改elseif代码部分。第二个将检查可以简化以去除冗余并清理代码的布尔值。

当只有两个分支且其中一个不需要任何参数时,例如处理Option类型时,一些match语句也可以得到改进。在这种情况下,将它们改为带有elseif let表达式会更简洁,这也会减少比较的缩进。这些失败点可以通过使用single_match_else lint 来显示。

另外两个有趣的 lint 会检查你可能只是为了使比较或范围包含而向整数添加1的地方。让我们看看一个例子:

    let max = 10;
    for i in 0..max + 1 {
        println!("{}", i);
    }

那段代码只加1是为了能够打印出10。你可以通过在两个范围符号之后使用等号(..=)来创建 nightly Rust 的包含范围:

#![feature(inclusive_range_syntax)]

fn main() {
    let max = 10;
    for i in 0..=max {
        println!("{}", i);
    }
}

指出这种错误的 lint 叫做range_plus_one,而用于检测诸如a < b+1这样的比较,这些比较可以用a <= b来替换的 lint 叫做int_plus_one

也有时候,我们可能会更改变量的名称或拼错它,从而破坏我们的代码,即使它看起来可以编译。其他时候,我们可能会创建名称过于相似的变量,最终将它们混淆。这可以通过使用similar_names lint 来避免。

另一个坏习惯是将枚举的名称包含在枚举的变体中,或者包含当前模块名称的结构中。由于名称可以有资格,因此不需要重复,这会增加很多文本。这默认会发出警告,但在公共 API 中不会。你可以通过stutterpub_enum_variant_names lint 来控制这一点。

最后,就像 Rust 编译器给了我们一个missing_docs lint 来指出缺失的公共文档一样,missing_docs_in_private_items Clippy lint 也会对私有项做同样的事情。这对于强制整个代码库的文档化来说是非常好的。

性能 lint

如果你正在阅读这本书,这两条 lint 可能比 Clippy 默认不警告的 lint 更重要。第一条相当简单:如果你想在多个线程之间共享一个整数,而你不需要将其用作同步变量时,使用Mutex是一个非常糟糕的主意。

通常,使用原子类型的事物,如计数器,会快得多。在撰写本文时,只有指针大小的原子和布尔值是稳定的,但其余的也在路上,现在可以在 nightly Rust 中使用。你可以通过mutex_integer lint 来发现这个问题。

此外,你可能会被诱惑使用std::mem::forget()来启用向 C API 发送数据,或者能够做一些奇怪的内存技巧。这可能是可以的(尽管它可能导致内存泄漏),但有时会阻止运行析构函数。如果你想确保你的Drop类型永远不会被遗忘,请使用mem_forget lint。

如果你担心无限迭代器可能会挂起你的应用程序,你应该使用 maybe_infinite_iter 检查项,它会找到这些迭代器。它不会检测停止条件,因此可能会显示过多的误报。

在开发过程中,我们可能会使用 print!() 宏和调试格式添加调试信息。一旦应用程序进入生产环境,避免这些日志留在代码库中的好方法就是使用 print_stdoutuse_debug 检查。

解包

Rust 允许你解包 Results 和 Options,但代价是如果它们分别是 Err(_)None,则会引发恐慌。在任何生产代码中都应该避免这样做,可以使用 expect() 添加信息消息,或者使用带有 ? 操作符的错误链,例如。你也可以对它们进行匹配并控制错误。

为了避免这些不必要的恐慌,你可以使用 result_unwrap_usedoption_unwrap_usedoption_map_unwrap_or_elseoption_map_unwrap_orresult_map_unwrap_or_else 检查项。

覆盖

在 Rust 中,你可以通过创建另一个具有相同名称的 let 绑定来覆盖一个变量。这通常是可以接受的,除非我们可能只想修改一个变量,例如。一般来说,你应该避免这种做法,只在有助于可读性的情况下使用它。

你可以使用 shadow_unrelatedshadow_sameshadow_reuse 检查项,并默认警告这种行为,然后在特定情况下允许它。

整数溢出

有时,当我们对整数进行操作时,我们没有考虑到溢出、下溢和覆盖。C 语言默认允许这样做,而 Rust 在调试模式下运行时会引发恐慌。然而,在发布模式下,这些整数溢出可能成为一个大问题。

你可以使用 integer_arithmetic 检查项,它会建议使用 wrapping_...()saturating_...() 方法之一,以确保你知道操作的结果。

检查项组

Clippy 中有两个检查项组。clippy 检查项组将控制默认警告的所有检查项,例如,你可以拒绝所有这些检查项。clippy_pedantic 组将作为一个组控制其余的检查项,但将所有这些检查项设置为警告,例如,会导致你的编译结果充满警告,因为存在误报。

要使用它们,你只需将一组检查作为检查项使用:

#![deny(clippy)]

你可以在 Clippy 的维基百科中查看其余的检查项和配置选项:rust-lang-nursery.github.io/rust-clippy/master/

摘要

在本章中,你学习了如何配置 Rust 和 Clippy 工具提供的不同检查项。通过它们,你可以获得更多具体粒度的警告选项,这些选项会影响性能和代码质量。

我们涵盖了默认情况下您看不到的代码检查,这使得您的探索之旅变得更加容易。在第五章,分析您的 Rust 应用程序,我们将了解其他工具;在这种情况下,用于分析您的应用程序并找到那些不太容易直接看到的性能瓶颈。

第五章:分析你的 Rust 应用程序

理解为什么你的应用程序运行速度比预期慢的一个基本步骤是检查你的应用程序在低层次上正在做什么。在本章中,你将了解低级优化的重要性,并了解一些可以帮助你找到瓶颈的工具。

在本章中,你将了解以下主题:

  • 处理器在低层次上的工作原理

  • CPU 缓存

  • 分支预测

  • 如何修复一些最常见的瓶颈

  • 如何使用 Callgrind 找到你最常用的代码

  • 如何使用 Cachegrind 查看你的代码在缓存中可能表现不佳的地方

  • 学习如何使用 OProfile 了解你的程序在执行时间上花费最多的地方

理解硬件

要了解我们的软件正在做什么,我们首先应该了解编译后的代码是如何在我们的系统中运行的。因此,我们将从中央处理单元(CPU)的工作原理开始。

理解 CPU 的工作原理

CPU 负责运行你应用程序的核心逻辑。即使你的应用程序是一个主要在 GPU 上运行大部分工作负载的图形应用程序,CPU 仍然会管理所有这些过程。有各种各样的 CPU,有些在特定方面比其他 CPU 更快,有些则更高效且功耗更低,牺牲了一些计算能力。无论如何,Rust 可以编译为大多数 CPU,因为它知道它们是如何工作的。

但我们的任务是自己弄清楚它们是如何工作的,因为有时编译器在改进我们的机器代码方面可能不如我们高效。所以,让我们深入到处理的核心,那里是事情发生的地方。

处理器有一组它知道如何执行的指令。我们可以要求它执行该组中的任何类型的指令,但我们有一个限制:在大多数情况下,它只能与所谓的 寄存器 一起工作。寄存器是 CPU 内部靠近 算术逻辑单元ALU)的一个小位置。它可以包含一个变量,大小与处理器字大小相同;如今,这通常是 64 位,但在某些嵌入式处理器中可能是 32 位、16 位甚至 8 位。

这些寄存器的速度与处理器本身一样快,因此可以在其中修改信息,而无需等待任何事情(好吧,只是实际指令执行)。这很棒;事实上,你可能想知道,如果我们已经有了寄存器,为什么还需要 RAM?

好吧,答案很简单:价格。在 CPU 中拥有更多的寄存器是非常昂贵的。在最佳情况下,你不可能拥有超过几十个寄存器,因为处理器布线会变得非常复杂。考虑到每个指令和寄存器你都会有专用数据线和控制线,这是非常昂贵的。

因此,需要一个外部内存,一个可以自由访问而不需要你按顺序读取所有内存的内存,同时仍然非常快。随机存取存储器RAM)就在那里。你的程序将从 RAM 加载,并将使用 RAM 来存储在软件执行过程中需要被操作的数据。当然,RAM 中的软件在可用之前必须从硬盘或固态硬盘加载到 RAM 中。

RAM 的主要问题是即使它比最快的 SSD(固态硬盘)快得多,它仍然不如处理器快。处理器可以在等待 RAM 将一些数据放入处理器的一个寄存器以便操作它们时执行数十条指令。因此,为了避免处理器每次需要从 RAM 中加载或存储数据时都要等待,我们有了缓存。

使用缓存加速内存访问

第一级缓存,也称为 L1 缓存,是一个几乎与 ALU(算术逻辑单元)一样靠近的缓存。L1 缓存的速度几乎与寄存器一样快(大约慢三倍),并且它有一个非常有趣的特性。其内部结构可以表示为一个查找表。它将有两列:第一列将包含 RAM 中的内存地址,而第二列将包含该内存地址的内容。当我们需要从 RAM 中加载某物到寄存器时,如果它已经在 L1 缓存中,这将几乎是瞬间的。

不仅如此,这个缓存通常被分为两个非常不同的区域:数据缓存和指令缓存。第一个将包含程序正在使用的变量的信息,而第二个将包含程序将要执行的指令。这样,因为我们知道程序是如何被执行的,我们可以在指令缓存中预加载下一条指令并执行它们,而无需等待 RAM。同样,因为我们知道在程序执行过程中需要哪些变量,我们可以在缓存中预加载它们。

但是,这个缓存也有一些问题。即使它比处理器慢三倍,它仍然太昂贵。此外,在处理器附近没有太多的物理空间,所以其大小通常限制在大约 32 KiB。由于大多数软件需要比这更大的空间,而我们希望它执行得快,无需等待 RAM,我们通常有一个二级缓存,称为 L2 缓存,它也以处理器的时钟速度运行,但由于离 L1 缓存较远,其信号到达的延迟更高。

L2 缓存因此几乎和 L1 缓存一样快,通常有高达 4 MiB 的空间。指令和数据是合并的。但这对于许多软件可能进行的操作来说仍然不够。记住,你需要使用你所有的数据,在图像处理中,那可能就是数百万像素及其值。因此,为此,一些高端处理器在这种情况下有一个 L3 缓存,距离更远,时钟速度更慢,但仍然比 RAM 快得多。有时,这个缓存在写作时可以达到 32 MiB。

但即便如此,我们知道即使在像 Ryzen 处理器这样的处理器中,拥有超过 40 MiB 的组合缓存,我们仍然需要更多的空间。我们当然有 RAM,因为最终,缓存只是我们放在处理器附近以供更快使用 RAM 的副本。对于每一条新的信息,我们都需要将其从 RAM 加载到 L3 缓存,然后是 L2,然后是 L1,最后是寄存器,这使得整个过程变慢。

为了这个,处理器有高度复杂的算法,这些算法是用纯硅编写的,在硬件中,能够预测将要访问的内存位置,并批量预加载这些位置。这意味着如果处理器知道你将访问 RAM 中地址 1,000 到 2,000 的变量,它将请求 RAM 加载整个内存批次到 L3 缓存,当使用它们的时间接近时,L2 缓存将从 L3 复制数据,L1 也从 L2 复制。当你的程序请求该内存位置的值时,它将神奇地已经在 L1 缓存中,并且检索速度极快。

缓存未命中

但这效果如何?是否可能达到 100% 的效率?好吧,不。有时,你的软件会做一些处理器没有设计去预测的事情,数据就不会在 L1 缓存中。这意味着处理器会向 L1 缓存请求数据,而这个 L1 缓存会发现它没有,浪费了时间。然后它会询问 L2,L2 会询问 L3,以此类推。如果你很幸运,它会在第二级缓存中,但可能甚至不在 L3 中,因此你的程序需要等待 RAM,在等待了三个缓存之后。

这就是所谓的缓存未命中。这种情况发生得多频繁?根据你的代码和 CPU 的优化程度,它可能在 2% 到 5% 之间。这似乎很低,但一旦发生,整个处理器都会停下来等待,这意味着即使它不会经常发生,它也会对性能产生巨大的影响,使事物变得非常慢。而且正如我们所见,这不仅仅是等待较慢存储的时间损失,还包括在先前存储中的查找时间损失,所以在缓存未命中中,直接询问 RAM(如果值不在任何缓存中)会更快。

你该如何解决这个问题?

修复这种情况并不容易。缓存未命中有时发生是因为你只使用了一次大数组,例如在视频缓冲区等场景中。如果你正在流式传输一些数据,可能会发生数据尚未在缓存中,并且使用一次后就不会再使用。这会带来两个问题。首先,当你需要它时,它仍然在 RAM 的某个区域,造成缓存未命中。其次,一旦你将其加载到缓存中,它将占用大部分缓存,忘记你可能需要的其他变量,并造成更多的缓存未命中。这种最后的效果被称为缓存污染

避免这种行为最好的方法是用更小的缓冲区,但这会带来其他问题,比如数据需要持续的缓冲,因此你需要看看对你特定情况来说什么才是最好的。如果不是由缓冲区引起的,可能是因为你创建了太多的变量,但只使用了一次。尝试找出你是否可以重用信息,或者是否可以改变一些循环的执行。但要注意,因为一些循环可能会影响分支预测,正如我们稍后将要看到的。

缓存失效

缓存还有一个大问题,称为缓存失效。由于通常在新处理器中,你使用多线程应用程序,有时会发生一个线程更改内存中的某些信息,而其他线程需要检查它。正如你可能知道的那样,或者正如你将在第十章多线程中看到的,Rust 在编译时使这一点完全安全。数据竞争不会发生,但这不会防止缓存失效的性能问题。

当 RAM 中的某些信息被另一个 CPU 或线程更改时,就会发生缓存失效。这意味着如果该内存位置被任何 L1 到 L3 缓存缓存,它将需要以某种方式从那里移除,因为它将包含旧值。这通常是通过存储机制完成的。每当内存地址更改时,任何指向该内存地址的缓存都会被失效。这样,下一个尝试从该地址读取数据的指令将创建一个缓存未命中,从而使得缓存刷新并从 RAM 获取数据。

无论如何,这都很低效,因为每次你更改一个共享变量时,该变量将需要在其他使用它的线程中刷新缓存。在 Rust 中,为此,你将使用一个 Arc。为了尝试避免这种性能陷阱,你应该尽量在线程之间共享尽可能少的内容,如果必须将消息传递给它们,有时使用 std::sync::mpsc 模块中的结构体可能是有意义的,正如我们将在第十章多线程中看到的。

CPU 流水线

指令集变得越来越复杂,处理器的时钟速度也越来越快,这有时使得大多数 CPU 指令需要多个时钟周期来执行。这通常是因为 CPU 需要首先理解正在执行的指令,理解其操作数,产生获取这些操作数的有效信号,执行操作,然后保存这些操作。并且每个时钟周期不能完成超过一个步骤。

这通常在处理器中通过创建 CPU 流水线来解决。这意味着当一条新指令进来时,在指令被分析和执行的同时,下一条指令来到 CPU 以进行分析。这可能会带来一些复杂性,正如你可能想象的那样。

首先,如果一条指令需要前一条指令的输出,它有时可能需要等待另一条指令的结果。也可能有时正在执行的指令是跳转到内存中的另一个位置,因此需要从 RAM 中获取新的指令,并移除流水线。

总的来说,这项技术实现的是在理想条件下(一旦流水线充满)每个时钟周期执行一条指令。大多数新型处理器都这样做,因为它可以在不提高时钟速度的情况下实现更快的执行,正如我们在这张图中可以看到的:

图片

这种方法的另一个额外好处是,将指令处理分解成更多步骤使得每一步更容易实现。不仅如此,由于每个部分在物理上会更小,光速下的电子能够在更短的时间内同步整个步骤电路,使得时钟可以运行得更快。

然而,无论如何,将每个指令执行分解成更多步骤会增加 CPU 布线的复杂性,因为它必须解决潜在的并发问题。如果一条指令需要前一条指令的输出才能工作,可能会发生四种不同的情况。首先,也是最糟糕的情况,行为可能会变得未定义。这不是我们想要的,解决这个问题会复杂化处理器的布线。

解决这个问题的最重要的布线部分是首先检测它。仅此一项就会使布线更加复杂。一旦 CPU 能够检测到潜在的问题,最简单的修复方法就是简单地等待输出而不推进流水线。这被称为停顿,这会损害 CPU 的性能,但它会正常工作。

一些处理器将通过添加一些额外的输入路径来处理这个问题,这些路径将包含之前的结果,以防需要使用它们,但这将大大增加流水线的复杂性。另一个选择是检测一些安全指令,并在需要前一个指令输出的指令之前运行它们。这个最后的选项被称为乱序执行,它也会增加 CPU 的复杂性。

因此,总结来说,为了提高 CPU 的速度,除了让时钟运行得更快之外,我们还有创建指令流水线的选项。这将使得每时钟周期运行一条指令(理想情况下)成为可能,有时甚至可以提高时钟速度。然而,这也会增加 CPU 的复杂性,使其成本大大提高。

那么,你可能会问,当前处理器的流水线是什么样的呢?嗯,它们的长度和行为各不相同,但在一些高端英特尔芯片的情况下,流水线的长度可以超过 30 步。这将使它们运行得非常快,但会极大地增加它们的复杂性和价格。

当你开发应用程序时,避免流水线变慢的一种方法是将不需要先前结果的操作先执行,然后使用生成的结果,尽管在实践中这非常困难,一些编译器实际上会为你这样做。

分支预测

有一种情况是我们没有看到如何解决,那就是当我们的处理器接收到一个条件跳转指令时,根据处理器标志位的当前状态,下一个要执行的指令将是其中一个。这意味着我们无法预测某些指令并将它们加载到流水线中。或者我们可以吗?

有多种方法可以预测接下来将要运行的代码,而无需计算最后几条指令。最简单的方法,也是老处理器中使用的方法,是静态地决定哪些分支将加载下一个指令,哪些将在跳转地址加载指令。

一些处理器会通过决定某些类型的指令比其他指令更有可能跳转来做到这一点。其他处理器会查看跳转地址。如果地址较低,它们会将目标地址的指令加载到流水线中,如果不低,它们会将下一个地址的指令加载到流水线中。例如,在循环中,返回循环开始的可能性比继续程序流程的可能性要大得多。

在上述两种情况下,决策是在处理器开发时静态做出的,实际的程序执行不会改变流水线加载的工作方式。一个改进的方法,即动态方法,是计算处理器在给定条件跳转时跳转的次数。

处理器第一次到达分支时,它不知道代码是否会跳转,所以它可能会将下一个指令加载到流水线中。如果它跳转,下一个指令将被取消,并将新的指令加载到流水线中。这将使处理器等待流水线阶段数那么多个周期。

在这个旧方法中,我们会将这些指令的跳转计数器设置为1,没有跳转的计数器设置为0。下次程序到达相同的跳转时,看到计数器,处理器将开始将来自跳转的指令加载到流水线中,而不是加载下一个指令。

一旦完成计算,如果处理器实际上需要跳转,它已经在流水线中有指令了。如果没有,它需要将其加载到下一个指令中。在这两种情况下,相应的计数器都会增加 1。

这意味着,例如,在一个长的for循环中,跳转计数器会增加到很高的数字,因为它大多数时候必须跳回到循环的开始,只有在最后一次迭代之后才会继续应用程序的流程。这意味着除了第一次和最后一次迭代之外,将不会有空流水线,分支将被正确预测。

这些计数器实际上要复杂一些,因为它们会在 1 或 2 位饱和,这意味着计数器可以指示上一次分支是否被采取,或者处理器下一次分支被采取的确定性如何。计数器可以是0,如果它通常从不采取分支,1,如果它可能采取,2,如果它多次采取分支,或者3,如果它几乎总是采取。这意味着只有部分时间采取的分支将会有更好的预测。一些基准测试表明,准确性可以高达 93.5%。

简单的计数器如何使分支预测变得更加高效,真是令人惊讶,对吧?当然,这有很大的局限性。在代码中,这种分支依赖于某些条件,但可以在其中看到模式的地方(例如,几乎每次调用都返回真的if条件),计数器会完全失败,因为它们对模式一无所知。

对于这种行为,会使用复杂的自适应预测表。它将在表中存储最后n跳转指令的发生,并查看是否存在模式。如果有,它将把结果分组为模式中元素数量的组,并更好地预测这种行为。在某些情况下,这可以提高准确性到 97%。

有许多不同的分支预测技术,并且根据流水线的大小,使用更复杂的预测器或更简单的预测器更有意义。如果处理器有一个 30 阶段的流水线,未能预测分支将导致下一个指令的 30 个周期延迟。如果它有 2 个阶段,它将只损失 2 个周期。这意味着更复杂和昂贵的流水线也需要更复杂和昂贵的分支预测器。

分支预测对我们代码的相关性

这本书不是关于创建处理器的,所以你可能认为所有这些分支预测理论对我们提高 Rust 代码效率的使用案例来说都没有意义。但现实是,这个理论可以让我们开发出更高效的应用程序。

首先,知道新的昂贵处理器将在两次遍历后检测到条件执行中的模式,这会让我们尝试使用这些模式,如果我们知道我们的代码将主要被较新的处理器使用。另一方面,如果我们知道我们的代码将在较便宜或较旧的处理器上运行,我们可能可以通过可能地(在循环中)按顺序编写模式条件的计算结果或通过尝试以其他方式组合条件来优化其执行。

此外,我们还需要考虑编译器优化。Rust 通常会优化循环,如果它知道它将始终执行相同次数的代码,就会复制一些代码 12 次,以避免分支。如果我们在同一个代码生成单元(例如,一个函数)中有许多分支,它也会丢失一些优化预测。

这就是像循环复杂度这样的 Clippy 检查开始发挥作用的地方。它们会显示在添加太多分支的功能中。可以通过将这些函数分解成更小的函数来修复这个问题。Rust 编译器将更好地优化给定的函数,如果我们启用了链接时间优化,最终甚至可能是在同一个函数中完成,从而使得处理器无分支。

我们不应该完全依赖硬件分支预测,尤其是如果我们的代码是性能关键的话,我们应该在开发时考虑到处理器也会对其进行优化。如果我们确定我们的代码将在哪个处理器上运行,我们甚至可以决定从开发手册中学习该处理器的分支预测技术,并据此编写我们的代码。

性能分析工具

你可能会想知道我们如何在应用程序中检测到这些瓶颈。我们都知道并不是所有的开发者都会考虑到这样的低级细节,即使他们考虑到了,他们也可能忘记在程序需要连续多次运行的关键代码中执行它。我们无法手动检查整个代码库,但幸运的是,有一些性能分析工具会给我们提供关于我们软件的信息。

Valgrind

让我们先从一个可以帮助你找到软件花费更多时间的工具开始。Valgrind是一个帮助找到瓶颈的工具。Valgrind 内的两个主要工具将为我们提供所需的统计数据,以找出我们需要改进代码的地方。它包含在大多数 Linux 发行版中。有 Windows 的替代品,但如果你可以访问一台 Linux 机器(即使是一个虚拟机),Valgrind 在获取结果时真的会带来很大的差异。

使用它的最简单方法是使用 cargo-profiler。这个工具在 crates.io 上,但它已经不再更新,GitHub 上的版本有一些急需的修复。你可以通过运行以下命令来安装它:

cargo install --git https://github.com/kernelmachine/cargo-profiler.git

安装完成后,你可以通过运行 cargo profiler callgrindcargo profiler cachegrind 来使用它,具体取决于你想要使用的 Valgrind 工具。不过,cargo-profiler 默认不编译带有源注释的代码,所以使用 cargo rustc 命令并带上 -g 标志来编译,然后在那些二进制文件上直接运行 Valgrind 可能是有意义的:

cargo rustc --release --bin {binary_name} -- -g

Callgrind

Callgrind 将显示程序中最常用的函数的统计数据。要运行它,你需要运行 cargo profiler callgrind {args},其中 args 是你的可执行文件的参数。不过这里有一个有趣的问题。Rust 使用 jemalloc 作为默认分配器,但 Valgrind 会尝试使用它自己的分配器来检测调用。有一种方法可以使用 Valgrind 的分配器,但这只能在 nightly Rust 中工作。

你需要将以下行添加到你的 main.rs 文件中:

#![feature(alloc_system)]
extern crate alloc_system;

这将强制 Rust 使用系统分配器。你可能需要向文件中添加 #![allow(unused_extern_crates)],这样它就不会提醒你未使用的包。对于 Valgrind 来说,一个有趣的标志是 -n {num}。这将限制结果只显示最相关的 num 个。在 Callgrind 的情况下,它只会显示最常用的函数。一个可选的 --release 标志会告诉 cargo profiler 你是否想要在发布模式下而不是在调试模式下进行性能分析。

让我们看看 Callgrind 工具的输出:

图片

让我们分析一下这代表什么。我只选择了最常用的函数,但我们已经可以看到很多信息。最常用的函数是一个内存复制的系统调用。这意味着这个程序在内存中的不同位置之间复制了大量的数据。幸运的是,至少它使用了一个高效的系统调用,但也许我们应该检查我们是否真的需要为这项工作做这么多复制。

第二次最常用的函数是在 abxml 模块/包中称为 Executor 的东西。你可能会认为 regex 引用更常用,因为它排在第二位,但 Executor 似乎被分割了,因为似乎有些引用丢失了最初的 lib.rs(第三和第五个元素看起来相同)。看起来我们使用那个函数很多,或者至少它占用了我们大部分的 CPU 时间。

我们应该问问自己这是否正常。它是否应该在该函数上花费这么多时间?在这个程序的情况下,SUPER Android Analyzer (superanalyzer.rocks/),它使用这个函数来获取应用程序的资源。大多数时间实际上分析应用程序而不是解压缩它(实际上,我们没有看到占用 80%时间的 Java 依赖项的使用)。但看起来apk文件的资源解压缩花费了很长时间。

我们可以检查在该函数或其子函数中是否有可以优化的地方。如果我们能优化该函数的 10%,那么在应用程序中我们会获得很多速度提升。

另一个选择是检查我们的正则表达式使用情况,因为我们可以看到许多指令被用来检查和编译正则表达式。对正则表达式引擎的改进也会有所影响。

我们最终看到 SHA-1 和 SHA-256 算法的执行也花费了很长时间。我们需要它们吗?它们可以被优化吗?也许通过使用在新处理器中经常发现的本地算法实现,我们可以加快执行速度。在主 crate 中创建一个 pull request 可能是有意义的。

如您所见,Callgrind 为我们提供了大量关于程序执行的有价值信息,我们至少可以看到在哪里花费时间尝试优化代码是有意义的。在这个特定案例中,散列算法、正则表达式和资源解压缩占据了大部分时间;我们应该尝试优化这些函数。另一方面,例如,一个较少使用的函数是 XML 发射器。所以即使我们找到了如何将这个函数优化 90%的方法,实际上也不会有多大区别。如果这是一个简单的优化,我们可以做(总比什么都不做好),但如果它需要我们花费很长时间来实现,那么可能没有做的必要。

Cachegrind

我们在本章中详细讨论了缓存,但有没有办法看到我们的应用程序在这方面的表现呢?实际上是有方法的。它被称为Cachegrind,它是 Valgrind 的一部分。它和 Callgrind 一样使用,与cargo profiler一起使用。在相同的前置应用程序中,cargo profiler未能解析 Cachegrind 的响应,所以我不得不直接运行 Valgrind,如下面的截图所示:

图片

这是我第二次或第三次运行,所以一些信息可能已经被缓存。但仍然,如您所见,一级数据缓存未命中率为 2.1%,考虑到二级缓存大部分时间都有这些数据(它只有 0.1%的时间未命中),这并不算太坏。

指令数据几乎总是从一级缓存中正确获取,除了 0.04%的时间。在二级缓存中几乎没有缺失。Cachegrind 也可以通过一些标志提供更多有价值的信息。

使用--branch-sim=yes作为参数,我们可以看到分支预测是如何工作的:

图片

如我们所见,3.3%的分支没有被正确预测。这意味着如果分支能被更好地预测,或者如果某些循环被展开,就像我们之前看到的那样,我们可以进行一些有趣的改进。

仅凭这一点,并不能告诉我们如何改进我们的代码。但使用这个工具会在当前目录下创建一个cachegrind.out文件。这个文件可以被另一个工具cg_anotate使用,它会显示改进的统计数据。运行它,你将看到基于函数的各种统计数据,你可以看到哪些函数给缓存带来了更多麻烦,然后去那里尝试修复它们。

在 SUPER 的情况下,资源解压缩似乎导致了更多的缓存缺失。尽管如此,这可能是合理的,因为它正在从文件中读取新数据,并且第一次几乎总是从内存中读取这些数据。但也许我们可以检查这些函数,并尝试通过使用缓冲区等方法来改进获取。

OProfile

OProfile 是另一个可以给我们提供关于程序有趣信息的优秀工具。它也仅适用于 Linux,但你会发现 Windows 也有类似的工具。再次强调,如果你能获得一个 Linux 分区来检查这个,你的结果可能会更接近你接下来要读到的。要安装它,安装你发行版的oprofile包。你可能还需要安装通用的 Linux 工具(在 Ubuntu 中为linux-tools-generic)。

没有源代码注释,OProfile 帮助不大,所以你应该首先使用以下命令编译带有它们的二进制文件:

cargo rustc --release --bin super-analyzer -- -g

你需要 root 权限来进行性能分析,因为它将直接获取内核计数器。别担心;一旦你分析了应用程序,你就可以停止使用 root。要分析应用程序,只需运行带有二进制文件和要分析参数的operf

图片

这将在当前路径下创建一个oprofile_data目录。为了从中获得一些意义,你可以使用opannotate命令。这将显示一些统计数据,其中包含一些源代码,以及 CPU 在每个地方花费的时间。在我们的 Android 分析器的情况下,我们可以看到规则处理花费了相当多的时间:

图片

在这种情况下,这可能是合理的。一个旨在用规则分析文件的软件花费大量时间用这些规则分析文件是有道理的。但,尽管如此,这也意味着在那段代码中,我们可能找到一些优化,这可能会带来差异。

使用 OProfile,我们可以找到程序花费时间过多的区域。也许我们会在一个意想不到的领域发现瓶颈。这就是为什么使用这些工具很重要的原因。

摘要

在本章中,你了解了处理器是如何真正工作的。你理解了我们在硬件中实施的多个技巧,这样一切运行得比 CPU 总是等待 RAM 时快得多。你还掌握了最常见的性能问题以及一些关于如何修复它们的信息。

最后,你学习了关于 Callgrind、Cachegrind 和 OProfile 工具的内容,这些工具将帮助你找到那些瓶颈,以便你可以轻松地修复它们。它们甚至还会显示在你的源代码中可以找到哪些减速点。

在第六章“基准测试”中,你将学习如何对你的应用程序进行基准测试。将其与其他应用程序或你自己的应用程序的早期版本进行比较特别有趣。你将学会如何发现使你的应用程序变慢的变化。

第六章:基准测试

我们已经学习了如何分析我们的应用程序,以及如何找到和修复主要瓶颈,但在这个过程中还有另一个步骤:检查我们的更改是否提高了性能。

在本章中,你将学习如何基准测试你的应用程序,以便你可以衡量你的改进。这可以满足两个目标:首先,检查你的应用程序的新版本是否比旧版本运行得更快,其次,如果你正在创建一个新应用程序来解决现有应用程序已经解决的问题,比较你的新应用程序与现有应用程序的效率。

在这个背景下,你将在本章中了解以下主题:

  • 选择要基准测试的内容

  • 夜间 Rust 的基准测试

  • 稳定 Rust 中的基准测试

  • 基准测试的持续集成

选择要基准测试的内容

知道你的程序在每次更改后是否提高了效率是一个好主意,但你可能会想知道如何正确地衡量这种改进或退化。这实际上是基准测试中的一个重要问题,因为如果做得好,它将清楚地显示你的改进或退化,但如果做得不好,你可能会认为你的代码正在改进,而实际上它正在退化。

根据你想要基准测试的程序,你应该对其执行的不同部分感兴趣。例如,一个处理一些信息然后结束的程序(分析器、CSV 转换器、配置解析器...),将受益于全程序基准测试。这意味着你可能需要一些测试输入数据,看看处理它们需要多少时间。应该有多个集合,这样你就可以看到性能如何随着输入数据的变化而变化。

一个具有界面并需要一些用户交互的程序,用这种方式进行基准测试是困难的。最好的办法是选取最相关的代码片段进行基准测试。在前一章中,我们学习了如何在我们的软件中找到最相关的代码片段。通过分析技术,我们可以了解哪些函数和代码片段对我们的应用程序的执行影响最大,因此我们可以决定对这些进行基准测试。

通常,你将希望主要拥有细粒度的基准测试。这样,你将能够检测到影响应用程序整体性能的某个小代码片段的变化。如果你有更广泛的基准测试,你可能会知道应用程序某个部分的整体性能有所下降,但很难确定代码中的哪个部分导致了这种情况。

在任何情况下,正如我们稍后将看到的,为基准测试设置持续集成是一个好主意,如果某个特定的提交降低了性能,则会创建警报。对于所有基准测试都在尽可能相似的环境中运行也很重要。这意味着运行它们的计算机不应从一个运行到下一个运行时发生变化,并且它应该只运行基准测试,以便结果尽可能真实。

另一个问题是我们将在上一章中看到的那样,我们在计算机上第一次运行某些东西时,速度会变慢。缓存需要填充,分支预测需要激活,等等。这就是为什么你应该多次运行基准测试的原因,我们将看到 Rust 将如何为我们做这件事。还有选项可以在某些秒数内预热缓存,然后开始基准测试,还有一些库为我们做这件事。

因此,在接下来的章节中,您应该考虑所有这些因素。创建小型微基准测试,选择您代码中最相关的部分进行基准测试,并在已知的不变环境中运行它们。

此外,请注意,创建基准测试并不意味着您不应该编写单元测试,因为我不止一次看到过这种情况。基准测试只会告诉您代码运行得多快,但您不知道它是否正确执行。单元测试不在此书的范围之内,但在您考虑基准测试之前,您应该彻底测试您的软件。

在夜间 Rust 中进行基准测试

如果您在网上搜索有关如何在 Rust 中进行基准测试的信息,您可能会看到一堆关于如何在夜间 Rust 中进行的指南,但关于如何在稳定 Rust 中进行的却不多。这是因为内置的 Rust 基准测试仅在夜间通道中可用。让我们首先解释内置基准测试是如何工作的,这样我们就可以了解如何在稳定 Rust 中实现它。

首先,让我们看看如何为库创建基准测试。想象以下小型库(代码在lib.rs中):

//! This library gives a function to calculate Fibonacci numbers.

/// Gives the Fibonacci sequence number for the given index.
pub fn fibonacci(n: u32) -> u32 {
    if n == 0 || n == 1 {
        n
    } else {
        fibonacci(n - 1) + fibonacci(n - 2)
    }
}

/// Tests module.
#[cfg(test)]
mod tests {
    use super::*;

    /// Tests that the code gives the correct results.
    #[test]
    fn it_fibonacci() {
        assert_eq!(fibonacci(0), 0);
        assert_eq!(fibonacci(1), 1);
        assert_eq!(fibonacci(2), 1);
        assert_eq!(fibonacci(10), 55);
        assert_eq!(fibonacci(20), 6_765);
    }
}

如您所见,我添加了一些单元测试,这样我们就可以确保我们对代码所做的任何修改都将得到测试,并检查结果是否正确。这样,如果我们的基准测试发现某些改进了代码,那么生成的代码将(或多或少)保证能够工作。

我创建的fibonacci()函数是最简单的递归函数。它非常容易阅读和理解正在发生的事情。如您在代码中所见,斐波那契序列是一个以01开始的序列,然后每个数字都是前两个数字的和。

正如我们稍后将看到的,递归函数更容易开发,但它们的性能不如迭代函数。在这种情况下,对于每次计算,它都需要计算前两个数字,而对于它们,又需要计算前两个,依此类推。它不会存储任何中间状态。这意味着,从一个计算到下一个计算,最后的数字会丢失。

此外,这会将栈推到极限。对于每次计算,必须执行两个函数并将它们的栈填满,并且在每个函数中,当它们再次调用自己时,它们必须递归地创建新的栈,因此栈的使用呈指数增长。此外,这个计算可以并行进行,因为我们丢弃了之前的计算,所以我们不需要按顺序执行它们。

在任何情况下,让我们检查一下这个性能如何。为此,我们将以下代码添加到lib.rs文件中:

/// Benchmarks module
#[cfg(test)]
mod benches {
    extern crate test;
    use super::*;
    use self::test::Bencher;

    /// Benchmark the 0th sequence number.
    #[bench]
    fn bench_fibonacci_0(b: &mut Bencher) {
        b.iter(|| (0..1).map(fibonacci).collect::<Vec<u32>>())
    }

    /// Benchmark the 1st sequence number.
    #[bench]
    fn bench_fibonacci_1(b: &mut Bencher) {
        b.iter(|| (0..2).map(fibonacci).collect::<Vec<u32>>())
    }

    /// Benchmark the 2nd sequence number.
    #[bench]
    fn bench_fibonacci_2(b: &mut Bencher) {
        b.iter(|| (0..3).map(fibonacci).collect::<Vec<u32>>())
    }

    /// Benchmark the 10th sequence number.
    #[bench]
    fn bench_fibonacci_10(b: &mut Bencher) {
        b.iter(|| (0..11).map(fibonacci).collect::<Vec<u32>>())
    }

    /// Benchmark the 20th sequence number.
    #[bench]
    fn bench_fibonacci_20(b: &mut Bencher) {
        b.iter(|| (0..21).map(fibonacci).collect::<Vec<u32>>())
    }
}

你需要在lib.rs文件的顶部添加#![feature(test)](在第一个注释之后)。

让我们首先理解为什么我们创建了这些基准测试。我们正在测试程序生成斐波那契序列中索引为0121020的数字需要多长时间。但是,问题是如果我们直接将这些数字提供给函数,编译器实际上会运行递归函数本身,并且只编译生成的数字(是的,低级虚拟机LLVM)会这样做)。所以,所有基准测试都会告诉我们计算需要 0 纳秒,这并不是特别好。

因此,对于每个数字,我们添加一个迭代器,它会生成从0到给定数字的所有数字(记住,范围是从右边非包含的),计算所有结果,并生成一个包含它们的向量。这将使 LLVM 无法预先计算所有结果。

然后,正如我们之前讨论的,每个基准测试都应该运行多次,这样我们就可以计算一个中值。Rust 通过给我们test包和Bencher类型来简化这一点。Bencher是一个迭代器,它将多次运行我们传递给它的闭包。

如你所见,map 函数接收一个指向fibonacci()函数的指针,该函数将给定的u32转换为它的斐波那契序列数字。要运行它,只需运行cargo bench即可。结果是:

图片

这很有趣。我选择了这些数字(0121020)来展示一些内容。对于01这两个数字,结果是直接的,它只会返回给定的数字。从第二个数字开始,它需要进行一些计算。例如,对于数字2,它只是将前两个数字相加,所以几乎没有开销。但是对于数字10来说,它必须将第 9 个和第 8 个数字相加,而对于每一个,第 8 个和第 7 个,第 7 个和第 6 个分别相加。你可以看到这很快就会变得难以控制。另外,记住我们为每次调用丢弃之前的结果。

所以,正如你在结果中看到的那样,对于每个新的数字,它都会变得非常指数级。考虑到这些结果是在我的笔记本电脑上进行的,你的结果肯定会有所不同,但彼此之间的比例应该保持相似。我们能做得更好吗?当然可以。这通常是最好的学习体验之一,可以看到递归和迭代方法之间的差异。

那么,让我们开发一个迭代的 fibonacci() 函数:

pub fn fibonacci(n: u32) -> u32 {
    if n == 0 || n == 1 {
        n
    } else {
        let mut previous = 1;
        let mut current = 1;
        for _ in 2..n {
            let new_current = previous + current;
            previous = current;
            current = new_current;
        }
        current
    }
}

在这段代码中,对于前两个数字,我们简单地返回之前正确的数字。对于其余的,我们从数字 2 的序列状态(0, 1, 1)开始,然后迭代到数字 n(记住右边的范围是不包含的)。这意味着对于数字 2,我们已经有结果了,对于其余的,它将简单地重复将两个数字相加,直到得到结果。

在这个算法中,我们总是记住前两个数字,这样我们不会从一次调用中丢失信息。我们也没有使用太多的栈(从数字 2 开始,我们只需要三个变量,并且我们不调用任何函数)。所以它需要的分配(如果有的话)会更少,而且应该会快得多。

此外,如果我们给它一个更大的数字,它应该线性扩展,因为它只会计算每个前面的数字一次,而不是多次。那么,它会快多少呢?

哇!结果真的改变了!我们现在看到,至少直到第 10 个数字,处理时间是恒定的,之后它只会略微上升(在计算 10 个更多数字时,乘数将小于 10)。如果你运行 cargo test,你仍然会看到测试成功通过。此外,请注意,结果更加可预测,测试之间的偏差也较低。

但是,在这个案例中有些奇怪。就像之前一样,0 和 1 不进行任何计算就运行了,这就是为什么它花费的时间如此之少。我们可能可以理解对于数字 2,它也不会进行任何计算(即使它需要比较以确定是否需要运行循环)。但是,数字 10 会发生什么呢?

在这种情况下,它应该运行了七次迭代来计算最终值,所以它肯定比不运行迭代一次要花更多的时间。嗯,关于 LLVM 编译器(Rust 在幕后使用的编译器)的一个有趣的事情是,它非常擅长优化迭代循环。这意味着,即使它不能为递归循环进行预计算,它可以为迭代循环进行预计算。至少七次。

LLVM 在编译时能计算多少次迭代?嗯,这取决于循环,但我看到它做过超过 10 次。有时,它会展开这些循环,这样如果它知道它将被调用 10 次,它就会连续写 10 次相同的代码,这样编译器就不需要分支了。

这是否违背了基准测试的目的?嗯,部分是,因为我们不再知道数字 10 的差异有多大,但对于这一点,我们有数字 20。尽管如此,它告诉我们一个很好的故事:如果你可以创建一个迭代循环来避免递归函数,就做吧。你不仅会创建一个更快的算法,编译器甚至知道如何优化它。

稳定 Rust 中的基准测试

到目前为止,我们已经看到了如何使用夜间发布通道来基准测试我们的代码。这是因为 Rust 需要基准测试的 test 夜间功能才能运行。这是 test crate 和 Bencher 类型所在的地方。如果你仍然想使用稳定编译器进行基准测试之外的所有操作,你可以将所有基准测试放在 benches 目录中。稳定编译器将忽略它们进行正常构建,但夜间编译器将能够运行它们。

但是,如果你真的想使用稳定编译器来运行基准测试,你可以使用 bencher crate。你可以在 crates.io 中找到它,使用它非常类似于使用内置的夜间基准测试,因为这个 crate 只是基准测试库的稳定版本。

要使用它,你首先需要更改 Cargo.toml 文件,确保在包元数据和依赖项之后看起来像以下内容:

[lib]
name = "test_bench"
path = "src/lib.rs"
bench = false

[[bench]]
name = "example"
harness = false

[dev-dependencies]
bencher = "0.1.4"

在这里,我们使用一个示例名称创建一个基准测试,并指定不要围绕它创建一个 harness。然后,创建一个名为 benches/example.rs 的文件,并包含以下内容:

//! Benchmarks

#[macro_use]
extern crate bencher;
extern crate test_bench;
use test_bench::*;
use self::bencher::Bencher;

/// Benchmark the 0th sequence number.
fn bench_fibonacci_0(b: &mut Bencher) {
    b.iter(|| (0..1).map(fibonacci).collect::<Vec<u32>>())
}

/// Benchmark the 1st sequence number.
fn bench_fibonacci_1(b: &mut Bencher) {
    b.iter(|| (0..2).map(fibonacci).collect::<Vec<u32>>())
}

/// Benchmark the 2nd sequence number.
fn bench_fibonacci_2(b: &mut Bencher) {
    b.iter(|| (0..3).map(fibonacci).collect::<Vec<u32>>())
}

/// Benchmark the 10th sequence number.
fn bench_fibonacci_10(b: &mut Bencher) {
    b.iter(|| (0..11).map(fibonacci).collect::<Vec<u32>>())
}

/// Benchmark the 20th sequence number.
fn bench_fibonacci_20(b: &mut Bencher) {
    b.iter(|| (0..21).map(fibonacci).collect::<Vec<u32>>())
}

benchmark_group!(
    benches,
    bench_fibonacci_0,
    bench_fibonacci_1,
    bench_fibonacci_2,
    bench_fibonacci_10,
    bench_fibonacci_20
);
benchmark_main!(benches);

最后,移除基准测试模块。这将为之前每个函数创建一个基准测试。主要区别在于你需要导入你正在基准测试的 crate,你不需要为每个函数添加 #[bench] 属性,并且你使用两个宏来使基准测试运行。benchmark_group! 宏将创建一个以宏的第一个参数为名称的基准测试组,并包含给定的函数。benchmark_main! 宏将创建一个 main() 函数,该函数将运行所有基准测试。

让我们看看结果:

图片

如您所见,这种方法并没有给我们带来美丽的颜色,并且给原生方法添加了一些额外的开销,但结果仍然是等效的。在这种情况下,我们可以看到第 10 个数字实际上不会在编译时计算。这是因为,在稳定 Rust 中,使用外部 crate,编译器无法在编译时计算一切。尽管如此,它还是给我们提供了关于不同选项性能差异的非常好的信息。

基准测试的持续集成

一旦我们知道了如何进行基准测试(从现在起我将使用夜间版本),我们就可以设置我们的持续集成环境,以便在性能回归发生时收到警报。实现类似功能有多种方式,但我会使用 Travis-CI 基础设施、一些 Bash 和一个 Rust 库来完成。

Travis-CI 集成

首先,让我们感谢 Lloyd Chan 和 Sunjay Varma 的杰出工作,他们是第一个提出这种方法的。您可以在 Sunjay 的博客中找到我们将要使用的代码(sunjay.ca/2017/04/27/rust-benchmark-comparison-travis)。尽管如此,检查它、理解它并看看它是如何工作的还是有意义的。

这个想法很简单:在 Travis-CI 构建中,您可以针对多个 Rust 渠道进行构建。当收到针对 nightly 渠道的构建请求时,让我们运行所有基准测试,然后将它们与我们将在 pull request 目标分支上运行的基准测试进行比较。最后,在 Travis-CI 的构建日志中输出比较结果。

让我们先配置我们的 Travis-CI 构建脚本。为此,我们需要在我们的仓库中创建一个类似于以下内容的.travis.yml文件:

language: rust
dist: trusty # Use a little more updated system
os:
  - linux # Build for Linux
  - osx # Build also for MacOS X

# Run builds for all the supported trains
rust:
  - nightly
  - beta
  - stable
  - 1.16.0 # Minimum supported version

# Load travis-cargo
before_script:
  - export PATH=$PATH:~/.cargo/bin

# The main build
script:
  - cargo build
  - cargo package
  - cargo test

after_success:
  # Benchmarks
  - ./travis-after-success.sh

让我们看看这段代码做了什么。首先,如果您从未使用过 Travis-CI 进行持续集成,您应该知道.travis.yml YAML 文件包含了构建配置。在这种情况下,我们告诉 Travis-CI 我们想要构建一个 Rust 项目(这样它就会自己设置编译器),并且我们告诉它我们想要针对 nightly、beta 和稳定发布渠道进行构建。我通常喜欢添加最小支持的 Rust 版本,主要是为了知道何时会出错,这样我们就可以在我们的文档中宣传最小 Rust 编译器版本。

然后,我们导出cargo二进制路径,这样我们就可以通过在构建中安装它们来添加cargo二进制文件。这将是基准比较脚本所需要的。然后,我们告诉 Travis-CI 构建库/二进制 crate,我们告诉它打包以检查是否生成了有效的包,最后运行所有单元测试。到目前为止,与正常的 Travis-CI Rust 构建没有太大不同。

一旦我们到达after-success部分,我们会调用一个尚未定义的 shell 脚本。这个脚本将包含基准比较的逻辑。

在编写所有代码之前,让我们先了解一个将使事情变得容易得多的库。我指的是cargo-benchcmp,一个cargo二进制文件。这个可执行文件可以读取 Rust 基准测试的输出并进行比较。要安装它,您只需运行cargo install cargo-benchcmp。它还有一些很好的命令行参数,可以帮助我们获得想要的输出。

要将基准测试的结果输出到文件中,只需执行cargo bench > file即可。在这种情况下,我们将有两个基准测试,一个是控制基准测试,这是我们决定作为参考的基准测试;另一个是变量基准测试,这是我们想要比较的基准测试。通常,pull request 的目标分支将作为控制基准测试,而 pull request 分支将作为变量基准测试。

使用可执行文件就像运行 cargo benchcmp control variable 一样简单。这将显示一个带有并列比较的出色输出。你可以要求工具稍微过滤一下输出,因为你可能不想看到有数十个具有非常相似值的基准测试,你可能更感兴趣的是大的改进或回归。

要查看改进,请在命令行中添加 --improvements 标志,要查看回归,请添加 --regressions 标志。你还可以设置一个作为百分比的阈值,低于该阈值的基准测试将不会显示,以避免显示没有变化的基准测试。为此,请使用 --threshold {th} 语法,其中 {th} 是一个大于 0 的数字,表示应考虑的百分比变化。

现在我们明白了这个,让我们看看 travis-after-success.sh 文件中的代码:

#!/usr/bin/env bash

set -e
set -x

if [ "${TRAVIS_PULL_REQUEST_BRANCH:-$TRAVIS_BRANCH}" != "master" ] && [ "$TRAVIS_RUST_VERSION" == "nightly" ]; then
    REMOTE_URL="$(git config --get remote.origin.url)"

    # Clone the repository fresh...
    cd ${TRAVIS_BUILD_DIR}/..
    git clone ${REMOTE_URL} "${TRAVIS_REPO_SLUG}-bench"
    cd "${TRAVIS_REPO_SLUG}-bench"

    # Bench the pull request base or master
    if [ -n "$TRAVIS_PULL_REQUEST_BRANCH" ]; then
      git checkout -f "$TRAVIS_BRANCH"
    else # this is a push build
      git checkout -f master
    fi
    cargo bench --verbose | tee previous-benchmark
    # Bench the current commit that was pushed
    git checkout -f "${TRAVIS_PULL_REQUEST_BRANCH:-$TRAVIS_BRANCH}"
    cargo bench --verbose | tee current-benchmark

    cargo install --force cargo-benchcmp
    cargo benchcmp previous-benchmark current-benchmark
   fi

让我们看看这个脚本在做什么。set -eset -x 命令将简单地改善命令在 Travis-CI 构建日志中的显示方式。然后,仅对 nightly 版本,它将在新位置克隆仓库。如果是拉取请求,它将克隆基础分支;如果不是,它将克隆 master 分支。然后,它将在两个地方运行基准测试,并使用 cargo-benchcmp 进行比较。这将结果显示在构建日志中。

当然,这个脚本可以被修改以适应任何需求,例如,使用默认分支之外的分支,或者过滤比较的输出,就像我们之前看到的。

Criterion 的基准测试统计信息

如果我们想了解更多关于基准测试比较的信息,没有比 Criterion 更好的库了。它将生成你可以用来比较多个提交的基准测试的统计数据,不仅如此,它还允许你显示图表,如果你已经安装了 gnuplot。它需要 Rust nightly 版本才能运行。

让我们看看如何使用它。首先,你需要在你的 Cargo.toml 文件中添加 Criterion 作为依赖项并创建一个基准测试文件:

[dev-dependencies]
criterion = "0.1.1"

[[bench]]
name = "example"
harness = false

然后,你需要创建一个基准测试。我将使用我们之前看到的斐波那契函数来演示行为。声明基准测试的方式几乎与 Rust 稳定版 bencher crate 完全相同。让我们在 benches/example.rs 文件中写下以下代码:

//! Example benchmark.

#[macro_use]
extern crate criterion;
extern crate test_bench;

use criterion::Criterion;
use test_bench::fibonacci;

fn criterion_benchmark(c: &mut Criterion) {
    Criterion::default().bench_function("fib 20", |b| b.iter(|| fibonacci(20)));
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

如果我们现在运行 cargo bench,我们将看到与此类似的输出(与递归版本相同):

图片

如你所见,我们在这里得到了大量的信息。首先,我们看到 Criterion 为处理器预热了三秒钟,以便它可以加载缓存并设置分支预测。然后,它对函数进行 100 次测量,并显示有关样本的有价值信息。

我们可以看到运行一个迭代的耗时(大约 42 微秒),样本的平均值和中位数,异常值(显著不同的样本)的数量,以及一个带有其函数的斜率。到目前为止,它只提供了关于基准的一些额外信息。如果你检查当前目录,你会看到它创建了一个.criterion文件夹,其中存储了之前的基准测试。你甚至可以检查 JSON 数据。

让我们再次运行基准测试,通过将递归函数替换为迭代函数:

图片

哇!数据更多了!标准比较工具将这个新基准与之前的基准进行了比较,并发现有力证据表明这种改进并非仅仅是统计上的异常。基准提高了 99.96%!

如你所见,标准比较工具在统计分析方面比内置的基准测试提供了更好的信息方法。偶尔运行这个工具将向我们展示应用程序性能的变化。

该库允许进行函数比较、图形创建等。它可以针对每个基准进行配置,因此你将能够根据你的需求微调你的结果。我建议你查看项目的官方文档以获取更多信息(crates.io/crates/criterion)。

要将此包含在你的 Travis-CI 构建中,只需修改之前的 shell 脚本即可。只需调用cargo bench而不是cargo benchcmp,并确保将.criterion文件夹移动到运行基准测试的位置(因为它下载了两个仓库)。

摘要

在本章中,你学习了如何基准测试你的 Rust 应用程序。你看到了不同的选项,并找到了最适合你特定需求的方案。你还了解了一些可以帮助你比较基准测试结果的库,甚至如何在持续集成环境中使用它们。

在下一章中,你将通过学习 Rust 的宏系统和标准库中内置的宏来进入元编程的世界。

第七章:内置宏和配置项

现在我们知道了如何提高我们的代码效率,我们可以学习如何使它在多个平台上工作,并确保我们充分利用所有可能的本地优化,同时使代码更快、更容易实现。元编程允许我们通过非常简单的代码片段来完成所有这些,你可能已经了解其中的一些特性。

在本章中,您将学习如何使用以下宏和编译器以及标准库内置的配置项:

  • 属性

  • Crate 功能

  • Nightly 功能

理解属性

Rust 允许我们根据我们调用的属性有条件地编译代码的某些部分。这些属性可以应用于完整的 crate/module,也可以应用于特定的函数、作用域,甚至是结构字段或枚举变体。我们在讨论 Clippy 时看到了一些例子,但这些属性允许我们做更多的事情,我们现在将深入探讨它们。

让我们先看看属性是如何工作的。要应用于整个当前模块/crate 的属性将写成这样:#![{attribute}]。应用于紧邻的作用域/函数/字段/变体的属性将写成这样:#[{attribute}]。请注意,第一个在井号和属性之间有一个!符号。

你可能已经在某些代码中看到过如#[macro_use]#[derive(Debug)]之类的属性。第一个将允许使用外部 crate 中的宏,而第二个将推导给定结构或枚举中的Debug特性。让我们先检查一下我们可以通过特性推导避免输入什么。

特性推导

特性推导有两种类型:内置推导和自定义推导。我们将在第九章“创建您的宏”中讨论第二种,但现在让我们看看推导能帮助我们实现什么。让我们想象以下结构:

struct MyData {
    field1: String,
    field2: u64,
}

建议每个结构实现Debug特性,这样,例如,如果我们需要调试代码中某个部分的运行情况,我们可以使用println!("{:?}", element);语法。它应该显示字段的值,因此我们可以想象以下内容:

use std::fmt;

impl fmt::Debug for MyData {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(
            f,
            "MyData {{ field1: \"{}\", field2: {} }}",
            self.field1, self.field2
        )
    }
}

这将打印字段信息。例如,假设我们有以下代码:

fn main() {
    let data = MyData {
        field1: "This is my string".to_owned(),
        field2: 4402,
    };

    println!("Data: {:?}", data);
}

我们将收到以下输出:

Data: MyData { field1: "This is my string", field2: 4402 } 

这很好,因为它使我们能够获取有关我们结构的信息,但它难以维护,并且给我们的代码库添加了大量样板代码。假设我们有一个 20 个字段的结构,我们需要删除 2 个字段,并添加 4 个新字段。这迅速演变成一个混乱的局面。我们需要修改特性实现,可能改变字段的顺序,等等。

这就是#[derive]属性发挥作用的地方:它会为我们编写代码,并且如果我们的结构发生变化,它将重新编写代码。而且,更好的是,它不会污染我们的代码库,因为这段代码将在编译时编写。整个Debug特性实现可以通过在结构的开始处添加#[derive(Debug)]来替换:

#[derive(Debug)]
struct MyData {
    field1: String,
    field2: u64,
}

如果我们再次运行程序,我们会看到没有任何变化。可以派生出多个特性:比较特性(PartialEqEqPartialOrdOrd)、CopyCloneHashDefault以及我们看到的Debug。让我们看看这些特性各自的作用。我们已经讨论了Debug特性,所以让我们从比较特性开始。

前两个可派生的特性是PartialEqEq。两者都使得可以使用==!=运算符与结构一起使用,但它们是如何工作的呢?

PartialEq旨在描述部分等价关系,这意味着如果A部分等于BB部分等于A,并且如果在这个例子中B部分等于CA也部分等于C,因为这种性质是对称的和传递的。

当为结构或枚举派生时,它只有在结构或枚举的所有成员已经实现了PartialEq时才可用,并且如果所有字段都相等,它将考虑两个结构或枚举相等。

Eq特性需要额外的条件,并且不能在编译时检查。它要求A等于A。如果我们谈论具有简单字段的结构,这可能会听起来很奇怪,但在标准库中有一个简单的类型显示了相反的行为。浮点类型(f32f64)在它们是NaN不是一个数字)时不尊重这一点。两个NaN不相等,即使它们都是NaN

要派生Eq特性,结构或枚举中的所有字段都必须实现Eq。这意味着你将无法为包含浮点数的任何结构或枚举实现Eq。这个特性不需要任何方法实现,它只是告诉编译器结构或枚举总是等于自身,而不需要任何额外的代码。

接下来的两个特性PartialOrdOrdPartialEqEq的工作方式类似,但它们增加了比较两个元素以了解它们顺序的能力,因此允许你使用<<==>>运算符与结构或枚举一起使用。两者都需要满足如果A < BB < C,则A < C(对于==>也是如此),并且如果A > B,则A < B是错误的。Ord特性还要求只有一个A < BA == BA > B是正确的。

作为额外信息点,PartialOrd 特性添加了一个 partial_cmp() 函数,而 Ord 特性添加了 cmp() 函数。两者都返回一个 Ordering,但对于第一个函数,它是可选的(Option<Ordering>),而对于第二个函数,则是强制的。这是因为部分比较可能对于某个特定值没有定义的顺序;记住浮点数的 NaN 情况。

对于只包含 PartialOrdOrd 字段的结构的函数实现相当简单:定义哪个字段对于排序是最相关的,然后在结构之间比较它们,然后,如果相等,比较下一个相关字段。这可以通过使用 #[derive(PartialOrd)]#[derive(PartialOrd, Ord)] 来避免。

派生将按从第一个到最后一个的顺序比较字段,所以请确保您将最相关的字段放在前面。在枚举的情况下,它将考虑第一个变体比最新变体。如果您想改变这一点,您可以通过更改字段的顺序或变体,或者自己实现特性来实现。您可能还只想比较结构的一个字段,并认为其余字段无关紧要。在这种情况下,您将需要自己实现特性。

要实现任何这些特性,您只需逐个比较字段。请注意,Ord 需要 Eq,因此我们需要实现 PartialEq 来检查日期、月份和年份,然后派生 Eq。您可以按照以下方式检查实现的细节:

use std::cmp::Ordering;

#[derive(Eq)]
struct DateNotes {
    day: u8,
    month: u8,
    year: i32,
    comment: String,
}

impl PartialEq for DateNotes {
    fn eq(&self, other: &Self) -> bool {
        self.day == other.day && self.month == other.month && self.year == other.year
    }
}

impl Ord for DateNotes {
    fn cmp(&self, other: &Self) -> Ordering {
        match self.year.cmp(&other.year) {
            Ordering::Equal => match self.month.cmp(&other.month) {
                Ordering::Equal => self.day.cmp(&other.day),
                o => o,
            },
            o => o,
        }
    }
}

impl PartialOrd for DateNotes {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

在这个例子中,我们首先检查年份来比较日期。如果年份相同,我们比较月份,如果月份相等,我们比较日期。我们不需要检查与日期关联的评论,因为我们不需要。PartialOrd 特性的实现只返回 Ord 特性包裹在 Option::Some 中的结果。

接下来的两个特性,CopyClone,允许在内存中复制结构。这意味着您将能够逐个复制实例的所有内容到另一个实例。Clone 特性通过添加 clone() 方法来实现这一点,通常它只调用每个字段的 clone() 方法。但它可以运行任何任意的代码,您永远不知道复制对象是否会昂贵。这就是为什么使用它需要显式调用 clone() 方法的原因。

另一方面,Copy 特性使得复制一个元素是隐式的。这意味着,例如,当将变量移动到函数中时,如果它是 Copy 变量,您在移动后仍然可以使用它,因为只有它的副本会被移动。我们在第一章,常见性能陷阱中看到了这种方法的一些优点和缺点。

然而,你不能为具有复杂不可复制类型的结构实现Copy特质,因为 Rust 要求它必须非常便宜,并且它使用编译器内建函数实现。所以,你可以安全地使用Copy类型,知道复制它不会很昂贵,但你不能自己实现它。你可以推导它。为结构或枚举推导Copy需要该结构或枚举实现Clone(如果所有内部元素都实现了Clone,你也可以推导它)以及所有内部元素都实现Copy

因此,你可以为以下基本类型结构的结构推导出Copy

#[derive(Clone, Copy)]
struct MyData {
    field1: u64,
    field2: f64,
    field3: i32,
}

但是,你不能为具有复杂不可复制类型的结构推导它:

#[derive(Clone)]
struct MyData {
    field1: String,
    field2: Vec<u32>,
    field3: i32,
}

尽管如此,在大多数情况下,你可以推导出Clone,因为大多数标准库类型都实现了它。但请记住,clone()方法通常成本较高,不应过度使用。实际上,通常认为如果你直接使用clone()方法,你可能在做错事,而且在大多数情况下,其他方法,如to_owned()into(),会更有效地完成任务。

to_owned()将获取变量的所有者版本,在切片和字符串中这意味着只进行memcpy()堆操作。另一方面,into()方法将使用专门的转换实现,以便产生最佳输出代码。这两个方法都会改变变量的类型。最后,clone()通常是通用的,这意味着它将为每个成员属性调用clone(),有时会使其变慢。

现在我们来谈谈Hash特质。这个特质允许将给定的结构或枚举用作哈希结构(如HashMap)中的键。它使我们能够使用Hasher对结构进行哈希处理,以获取包含的信息的哈希值。Hasher是一个特质,它接收输入,如字节或数字,一旦你在它上面调用finish(),它将返回一个包含哈希值的u64

由于Hasher是一个特质,特质本身不提供实现细节,但正如我们在第二章“额外性能增强”中看到的,标准库中提供了一些默认实现:SipHasherSipHasher13SipHasher24DefaultHasher。我们已经看到了它们之间的一些区别。

Hash特质背后的主要思想是它允许对任何结构进行哈希处理,而不限制HashMap键必须是字节或数字。你可以为你自己的结构实现这个特质(如果你想要微调哈希过程),但如果你的目的是能够简单地将你的结构或枚举用作HashMap中的键,你只需推导出Hash特质,编译器就会为你编写代码。

不仅如此,您可能还希望实现它的 Eq 特性,因为对于 HashMap 键来说,这是必需的。如果您自己实现它,您需要确保如果 A = B,则 hash(A) = hash(B),这可能不是显而易见的。最好的办法是简单地推导两者。让我们用我们之前定义的结构来检查这个示例代码:

use std::collections::HashMap;

#[derive(Clone, Hash, PartialEq, Eq)]
struct MyData {
    field1: String,
    field2: Vec<u32>,
    field3: i32,
}

fn main() {
    let key1 = MyData {
        field1: "myField".to_owned(),
        field2: vec![0, 1, 2],
        field3: 1898,
    };

    let key2 = key1.clone();

    let key3 = MyData {
        field1: "myField2".to_owned(),
        field2: vec![5, 3, 1],
        field3: 2345,
    };

    let mut map = HashMap::new();
    map.insert(key1, "MyFirst");

    assert!(map.get(&key2).is_some());
    assert!(map.get(&key3).is_none());
}

在这里,我们首先在 MyData 结构中推导 HashPartialEqEq,然后创建两个相同的键和一个不同的键。我使用了 clone 以便于理解,但使用具有相同值的另一个键也会起作用。我们使用第一个键向映射中添加一个值,并检查是否可以无问题地使用键的副本检索元素。然而,如果我们尝试使用不同的键,我们就无法获取值。您还可以检查,如果 MyData 结构没有实现 EqHash,您将无法将其用作 HashMap 的键。

与之前一样,一个结构要推导 Hash 的唯一要求是它的所有成员已经实现了它,而大多数标准库类型都实现了 Hash。默认实现将简单地使用给定的 Hasher 逐个哈希所有属性,这可能是您手动实现的内容。这种实现的例子可能如下所示:

use std::hash::{Hash, Hasher};

impl Hash for MyData {
    fn hash<H>(&self, state: &mut H)
    where
        H: Hasher,
    {
        self.field1.hash(state);
        self.field2.hash(state);
        self.field3.hash(state);
    }
}

如您所见,这是一段简单的代码,但如果您自己推导它,可以使代码更加整洁且易于维护。尽管如此,自己实现它可以帮助您处理那些没有实现 Hash 的字段,或者使用自定义哈希技术,或者在比较结构时忽略某些字段以获得更好的性能(如果忽略这些字段结构比较仍然有效的话)。

最后,Rust 可以直接推导的最后一个特性是 Default 特性。这个特性为结构或枚举提供了一个 default() 方法,该方法将创建具有默认值的结构。这些默认值是,例如,数字的 0,字符串的空字符串,向量的空向量等等。它通常用作未来计算的占位符。

如果您有一个希望具有默认值的结构,您可以实现 Default 特性。而且,这样做可能只需要为每个属性提供一个值。如果您不需要特定的默认值(所有零对您来说都很好),您可能更愿意简单地推导 Default 特性。让我们用 MyData 结构来检查一个示例:

#[derive(Debug, Default)]
struct MyData {
    field1: String,
    field2: Vec<u32>,
    field3: i32,
}

fn main() {
    let test1 = MyData {
        field1: "sth".to_owned(),
        ..Default::default()
    };
    let test2 = MyData::default();

    println!("test1: {:?}", test1);
    println!("test2: {:?}", test2);
}

如您所见,我推导了 Default 特性(以及 Debug 特性,仅用于打印结构)。这允许您通过仅调用 MyData::default() 来创建 test2 变量。如果您为变量提供了类型提示,也可以调用 Default::default()

let test3: MyData = Default::default();

如您所见,如果结构体的一些字段实现了 Default,您可以使用该特性来完成您不想指定的字段,就像您在 test1 变量中看到的那样。只需指定非默认字段,然后在最后一个逗号之后添加几个点(..),然后是 Default::default(),这样编译器就会使用 Default 特性来填充其他字段。您可以使用任何对其他字段通用的函数,并使用此语法。

如您所见,Default 特性是一个非常实用的特性,如果您不需要对结构体的任何字段进行特殊处理以设置默认值,那么使用它是一个很好的主意。您可能会避免以下潜在实现:

impl Default for MyData {
    fn default() -> Self {
        Self {
            field1: Default::default(),
            field2: Default::default(),
            field3: Default::default(),
        }
    }
}

如您所见,使用它可以使您的工作变得容易得多。尽管如此,您可以使用此实现来自定义结构体默认实例的任何字段,如果,例如,您希望所有结构体默认将字符串字段设置为 "This is my data",这可能是一个好主意。如果您自己实现它,还可以在您的任何字段没有实现 Default 的情况下自定义它,这在使用标准库类型时很少见。

铁盒功能

第二个、非常有趣的属性用途是启用铁盒功能。这些功能可能封装了一些某些使用铁盒的人不需要的功能,因此使编译成为可选操作。Rust 编译器将在编译过程中删除任何未使用的代码,但如果没有从开始就编译某些代码部分,这将加快处理速度。

您可以通过在 Cargo.toml 文件中使用 [features] 部分,来定义铁盒功能。您可以指定一些默认功能,如果未指定任何内容,这些功能将被构建:

[features]
default = ["add"]
add = []
multiply = ["expensive_dependency"]

在此示例中,定义了两个功能,即 add 功能和 multiply 功能。add 功能没有额外的依赖项,但 multiply 功能依赖于 expensive_dependency 铁盒。默认情况下,仅构建 add 功能。如果这是一个二进制铁盒,您可以使用 cargo--features 命令行选项来指定要构建的功能:

cargo build --features "multiply"

如果您想禁用默认功能,只需使用 --no-default-features 命令行选项运行即可。如果您想将具有功能的铁盒作为项目依赖项,您可以在 Cargo.toml 文件中声明依赖项时指定要包含哪些功能:

[dependencies.my_dep]
version = "1.0"
default-features = false
features = ["nice_feat"]

[section.subsection] 语法仅用于我们不需要在 dependencies 部分添加内联对象。在这种情况下,它禁用了默认功能并请求了 nice_feat 功能。

但是,这在代码中看起来如何呢?让我们看看。如果我们有一个 add 功能,就像我们之前看到的那样,我们可能添加一个属性来仅为此情况启用一个函数或模块:

#[cfg(feature = "add")]
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

這將只在請求 add 特性時編譯。我們在使用 cargo clippy 時已經看到過類似的語法,因為它會從我們的包中請求 cargo-clippy 特性,使我們能夠挑選 lints。

配置屬性

最終類型的屬性是 #[cfg] 屬性。這些屬性非常強大,使我們能夠根據我們要編譯到的目標編譯代碼的某些部分。例如,我們可能想使用特定的 Windows 函數,並為其他部分提供一個備用版本,或者我們可能希望代碼在小端和大端機器上執行不同的操作。

語法相對簡單。例如,如果你想檢查系統架構,你可以使用 #[cfg(target_arch = "arm")],而不是 ARM,你也可以檢查 "x86""x86_64""mips""powerpc""powerpc64""aarch64"。要僅為 FreeBSD 編譯某個東西,我們可以使用 #[cfg(target_os = "freebsd")]。你可以比較 target_os 配置屬性與 "windows""macos""ios""linux""android""freebsd""dragonfly""bitrig""openbsd""netbsd"

如果你只關心 Windows/Unix 的差異,你可以使用 #[cfg(target_family = "windows")]#[cfg(target_family = "unix")],或者甚至直接使用 #[windows]#[unix]。這可以通過使用 #[cfg(target_env = "gnu")]"msvc""musl" 進一步指定。你可以使用 #[cfg(target_endian = "little")]#[cfg(target_endian = "big")] 檢查系統的端序,並使用 #[cfg(target_pointer_width = "32")]#[cfg(target_pointer_width = "64")] 依次檢查指針寬度(32 或 64 位)。

也可以檢查更複雜的細節,例如目標是否具有原子整數類型,以及這些原子整數的大小。例如,要檢查目標平台是否具有原子 8 位整數,你將使用 #[cfg(target_has_atomic = "8")]。你可以檢查 8163264 和指針寬度整數(使用 "ptr")。你甚至可以通過檢查 #[cfg(target_vendor = "apple")] 檢查目標架構的供應商。你可以檢查 "apple""pc""unknown"

最後,有幾個屬性可以告訴你你是否正在進行測試(使用 #[test])以及是否已啟用调试斷言(使用 #[debug_assertions])。第一個可能在你只想為測試更改特定行為時有用(不推薦;測試應該運行與生產環境相同的代碼),而第二個則允許你在應用程序以调试模式編譯時添加一些调试信息。

你可以通过使用 #[cfg_attr(a, b)] 选择性地设置/使用配置项。这将产生与 #[b] 相同的效果,但只有在 a 为真时才会执行某些操作。如果它是假的,就像什么都没写一样。这在例如你想根据其他属性启用或禁用 lint,或者你想只为某些目标派生 trait 并为其余部分实现它时很有用。

你也可以通过使用 cfg!() 宏在逻辑代码内部检查这些配置属性。只需使用与属性相同的语法:

if cfg!(target_pointer_width = "32") {
    do_something();
}

Rust 最有用的功能之一是其宏生态系统。你可能已经知道 println!() 宏,但还有更多。这些宏允许你以简单的方式编写复杂的样板代码(例如 println!() 中的 stdio 处理),而不必添加大量的样板代码。让我们来看看最常用的几个。

控制台打印

当你需要锁定标准 I/O 接口,然后写入字节到它,最后在每个调用中刷新它时,print!()println!() 宏允许你通过只向它们提供一个格式化的静态字符串和一系列参数来实现这一点。不仅如此,你还可以使用整个 std::fmt 模块来指定数字精度,以调试模式格式化事物,等等。

对于标准错误输出接口或 stderr,也存在类似的宏。它们被称为 eprint!()eprintln!(),允许你以与 print!()println!() 相同的格式轻松地在 stderr 中打印。这四个宏使用来自 format!() 宏的语法,我们将在下一节中看到。

字符串格式化

创建字符串很简单:你只需调用 String::new(),然后使用静态字符串或向其中添加字符。有时,你可能想要更容易地访问字符串的创建方式。例如,如果你想字符串显示为 Hello {user}!,即使你可能会创建一个包含 HelloString,然后追加用户名和感叹号,这并不理想。

这就是 std::fmt 模块及其 format!() 宏和所有格式化选项派上用场的地方。这些选项适用于控制台打印、字符串格式化,甚至使用 write!()writeln!() 宏进行缓冲区写入。你可以在标准库文档中找到完整的指南,通过运行 rustup doc --open 来查看 std::fmt 模块的文档。

编译环境

你可以通过使用 env!("VAR")option_env!("VAR") 宏在编译时检查环境变量。第一个将检索环境变量作为 &'static str。如果变量未定义,编译将失败。option_env!() 宏通过在环境变量未设置时返回 Option::None,在变量设置时返回 Option::Some(&'static str) 来避免这种情况:

const THE_KEY: &str = env!("KEY");

在编译时加载字节数组和字符串

你可以在编译时加载各种类型的常量。include_bytes!() 宏将创建一个包含指定文件内容的字节数组(u8)。另一方面,include_str!() 宏将获取文件内容作为字符串并创建一个 &static str。两者都会在编译时如果文件不存在而使编译失败。

你还可以使用 include!() 宏,该宏将在编译时将指定文件的代码包含到当前文件中。如果该文件中的代码不是有效的 Rust 代码,编译将失败:

const CRATE_CONFIG: &str = include_str!("../Cargo.toml");

代码路径

有些路径永远不应该被遍历,在我们的代码中,这通常是代码正常工作的条件。如果我们收到不良的输入数据,我们可能希望返回一个错误,但如果我们的库被误用,我们可能更愿意引发 panic。有时,我们还想确保变量在到达函数的逻辑时不可能超出某些界限,以避免安全漏洞,例如。在这些情况下,可以使用 unreachable!() 宏,甚至显式的 panic!() 宏来帮助我们。

还有一条可能尚未准备好遍历的路径。当我们的 crate 正在实现时,我们可以使用之前在某些示例中看到的 unimplemented!() 宏,来指示我们正在编写的代码尚未实现。这将使代码能够编译,但如果执行,它将带有 尚未实现 消息的 panic。

检查先决条件和后置条件

在测试时,甚至在我们的日常代码中,我们可能希望我们的函数有一些先决条件,或者我们可能想检查一些后置条件。我们使用断言来做这件事。它们有两种变体,调试断言和正常断言。

正常断言总是会进行检查,但它们会减慢你的生产代码,因为它们需要每次运行。调试断言只有在调试模式下编译时才会运行,因此你可以在那时捕获错误,而生产代码将不会出现性能问题。

通常,你应该尽可能使用所有调试断言,只在从用户或其他 crate(如果构建库)接收输出时使用正常断言。

三个宏分别是 assert!()assert_eq!()assert_ne!(),它们的调试版本分别是 debug_assert!()debug_assert_eq!()debug_assert_ne!()。第一个宏接受一个返回布尔值的表达式作为第一个参数,可选的第二个参数可以包含一个消息,当第一个参数为假时,这个消息将在 panic 时被打印出来。

另外两个宏接受两个参数,它们之间将进行比较,还有一个可选的注释字符串。如果两个元素不同,assert_eq!() 宏将引发 panic,而如果它们相等,assert_ne!() 将引发 panic。

其他

还有更多宏。我们已经使用了一些,例如cfg!()vec![]宏。您还可以使用compile_error!("message")宏来引发显式的编译错误,或者使用file!()line!()column!()宏来获取当前代码的位置,甚至可以使用module_path!()宏来获取当前模块。

如果您想了解更多信息,可以通过运行rustup doc --open来打开标准库文档,并查看那里的其他宏。

夜间 Rust

在某些情况下,夜间 Rust 甚至可以进一步加快您的代码。如果您不需要与稳定版 Rust 兼容,您可能想检查所有夜间功能。在某些情况下,例如内核开发,使用稳定版 Rust 无法获得所有所需的功能。您可以通过覆盖默认编译器来使用夜间 Rust:

rustup override add nightly

或者,您可以使用带有+nightly标志的 cargo。这些方法只有在您使用rustup管理您的 Rust 安装时才会生效,如果您有这个选项,那么您可能应该这样做。

要使用夜间功能,您需要在 crate 级别使用#![feature]属性。例如,如果您想使用conservative_impl_trait功能,您需要将#![feature(conservative_impl_trait)]添加到您的main.rslib.rs文件的开始部分。

让我们看看一些最有趣的不稳定功能。请注意,这些功能可能会迅速变化,并且它们在您阅读这本书的时候可能已经发生了变化。始终检查最新的 Rust 不稳定功能列表(doc.rust-lang.org/unstable-book/the-unstable-book.html)以获取最新信息。这里有数十个功能,在这个章节中不可能检查所有这些功能,但在这里您可以找到最相关的一些功能的解释,以便您了解它们能为您做什么,以及您如何使用它们来提高您应用程序的性能。

保守特型返回

这个功能使您能够直接从函数中返回一个特型。这意味着在稳定版 Rust 中,如果您想返回一个实现了特型的类型而不指定类型,您需要编写以下内容:

fn iterate_something() -> Box<Iterator<Item = u32>> {
    unimplemented!()
}

这意味着在返回迭代器之前,您需要将所有相关信息移动到堆上(这很容易完成,但使用Box::new()会非常昂贵),然后返回它。这不应该必要,因为 Rust 应该在编译时知道您返回的类型,并相应地分配栈,然后只允许您使用特型,因为这是您事先指定的。

好吧,这已经在夜间 Rust 中实现了,但您需要使用conservative_impl_trait功能:

#![feature(conservative_impl_trait)]

fn iterate_something() -> impl Iterator<Item = u32> {
    (0..3).into_iter()
}

这允许 Rust 直接使用栈,这将避免昂贵的分配,并使您的代码运行更快。

常量函数

const_fn 功能允许您将一些函数声明为常量,这样它们就可以在编译时接收常量参数并在那时执行,而不是在运行时执行。这对于构造函数或需要尽快创建对象的常量特别有用。

对于最后一个选项,我们有 lazy_static!{} 宏,正如我们将在下一章中看到的,但这个宏在其首次使用时运行所有代码,而不是在编译时。在编译时这样做会使编译时间稍微长一些,但在运行时,它不需要计算任何东西,因为所有东西都已经是一个常量。尽管如此,似乎并不是所有的 lazy_static!{} 情况都可以用 const_fn 解决。

让我们看看它是什么样子:

#![feature(const_fn)]

const FIRST_CONST: MyData = MyData::new(23, 275);
const SECOND_CONST: MyData = MyData::new(336, 7);

#[derive(Debug)]
struct MyData {
    field1: u32,
    field2: f32,
}

impl MyData {
    pub const fn new(a: u32, b: u32) -> MyData {
        MyData {
            field1: a / b,
            field2: b as f32 / a as f32,
        }
    }
}

fn main() {
    println!("FIRST_CONST: {:?}", FIRST_CONST);
    println!("SECOND_CONST: {:?}", SECOND_CONST);

    let third = MyData::new(78, 22);
    println!("third: {:?}", third);
}

在这种情况下,正如您所看到的,我们创建了两个使用 MyData::new() 方法创建的常量。然后在运行时的 main() 函数中使用了相同的方法。在常量函数中可以做的事情非常有限。例如,您不能创建绑定,如果您调用另一个函数或宏,它也必须是常量。但您仍然可以执行一些复杂的操作,这些操作不会影响应用程序的性能。正如您所想象的,这是这段代码的输出:

FIRST_CONST: MyData { field1: 0, field2: 11.956522 }
SECOND_CONST: MyData { field1: 48, field2: 0.020833334 }
third: MyData { field1: 3, field2: 0.2820513 }

内联汇编和裸函数

这可能是 Rust 中最有趣的夜间功能之一。使用 #[feature(asm)],我们将获得一个新的宏 asm!(),我们将在代码中使用它。使用这个宏,如果我们需要进一步的性能优化,我们可以在代码中编写内联汇编以执行细粒度操作。

正确的语法仍在开发中,但它已经允许您在函数中编写任意汇编代码。这对于内核开发来说是一个必须的功能,例如,在这种情况下,只能通过直接 CPU 指令来访问 CPU 功能。确保您彻底测试这段代码,因为它在使用时是不安全的。

此外,#[feature(naked_functions)] 允许您将 #[naked] 属性添加到函数中。这将删除在函数前后添加的一些样板汇编代码,这样您就可以编写纯汇编代码。很多时候,这对于使用某些 CPU 内置函数是必不可少的。

使用更大的整数

i128_type 功能为我们提供了 i128u128 整数,它们的工作方式与 i64u64 类型相同,但使用 128 位而不是 64 位,这使它们具有更大的容量。它们具有与整数其余部分相同的 API,因此您可以执行相同类型的操作。有时拥有一个更大、全精度的整数是非常好的,在这种情况下,因为它使用了 LLVM 内置函数,所以这个类型几乎与 u64i64 一样轻量级(在 64 位机器上大约是双倍的处理时间;在 128 位机器上应该差不多)。主文档中给出了一个简单的例子:

#![feature(i128_type)]

fn main() {
    assert_eq!(1u128 + 1u128, 2u128);
    assert_eq!(u128::min_value(), 0);
    assert_eq!(u128::max_value(),
               340282366920938463463374607431768211455);

    assert_eq!(1i128 - 2i128, -1i128);
    assert_eq!(i128::min_value(),
               -170141183460469231731687303715884105728);
    assert_eq!(i128::max_value(),
               170141183460469231731687303715884105727);
}

单指令多数据

单指令多数据SIMD)CPU 特性已经彻底改变了我们在 CPU 中执行操作的方式。使用处理器特定的特性,我们现在可以同时运行相同的操作。假设我们需要成对地添加四个数字。我们首先可以添加前两个,然后添加后两个,并得到两个结果。SIMD 允许我们同时计算这两个结果,通过同时将加法操作应用于这两对。

然而,这需要汇编,尽管 LLVM 尽可能地使用尽可能多的 SIMD 指令,但对于一些高性能应用来说,有时这还不够。当然,我们可以使用内联汇编,但使用汇编时出错并不罕见,并且您需要为每个目标重新编写它,因此正在开发一个针对 SIMD 的特定前端。

API 仍在开发中,但请查看simd功能,以了解它将如何实现。目前看来,似乎将开发一个包含所有内建的独立包。您将能够生成数据组,并支持处理器的每个元素应用同时操作。

分配 API

一些特定的项目需要改变默认的堆分配算法的能力。Rust 默认使用 jemalloc,对于允许它的目标。正如我们在前面的章节中看到的,这个分配器的特性之一是,在集合中,它将分配上一次分配的两倍。

您可以通过使用allocallocator_apialloc_jemallocalloc_system功能来改变这一点。后两个指定了包的全局分配器,在例如内核开发的情况下,必须指定并实现一些函数,以便集合能够工作。其他两个允许进行更定制的分配器操作,甚至给您提供更改每个集合分配器的选项。

编译器插件

完成这个列表后,我们将讨论编译器插件。这些插件可以通过在main.rslib.rs文件的顶部添加#![feature(plugin)]来使用,就像使用其他夜间功能一样。如果您实际上要创建一个插件,您将需要使用plugin_registrarrustc_private功能。

不稳定的特性列表提供了一个有趣的指南来创建插件,这些插件将在第九章“创建您自己的宏”中扩展。您需要使用libsyntax包,以及编译器语法的内部结构和编译器本身的内部结构,以便您能够解析高级源树(AST)标记,并执行插件所需的操作。

插件允许对语言进行大语法扩展,这可以让您在宏内部运行任意 Rust 代码或生成任何类型的样板代码。我们将在下一章看到一个真实示例,该示例通过大量使用插件来创建出色的 Web 开发体验。

其中一些特性可能短期内不会得到稳定,一些可能会发生很大变化,还有一些甚至可能不会实现,尽管我对您刚才读到的列表表示怀疑。这些变化可能会使您的代码在一夜之间变得过时,因此您必须确保,如果您使用了一些这些特性,您能够维护一个不断变化的生态系统。

摘要

在本章中,我们通过学习属性和宏开始了元编程的学习。两者都将帮助您编写更少的代码,并确保您为琐碎的细节获得最佳实现。

我们随后学习了夜间 Rust 版本,以及一些夜间特性如何为我们提供新的语言扩展,这些扩展可以极大地帮助我们提高代码的效率、性能和清晰度。

在下一章中,我们将看到crates.io上的 crates 如何为生态系统带来新的宏和插件,我们将探讨其中最常用的那些,它们可以提升您应用程序的性能和开发时间。

第八章:必备的宏 crate

Rust 最有用的特性之一是其 crate 生态系统。在 C/C++等语言中,有时很难找到合适的库来使用,而且实际上使用它可能也很困难。在 Rust 中这几乎是直截了当的,在本章中,我们将看到一些最有趣的 crate,它们为我们提供了强大的元编程原语:

  • Serde: 数据序列化和反序列化支持

  • Nom: 创建零拷贝的字节级解析器

  • Lazy static: 惰性初始化的静态变量

  • 派生构建器: 为你的结构派生出常见的构建器模式

  • 失败: 简单的错误处理

  • Log 和 env_logger: 为你的软件进行日志记录

  • CLAP: 创建命令行界面

  • Maud: 编译时模板,具有巨大的性能

  • Diesel: MySQL/MariaDB、PostgreSQL 和 SQLite 数据库管理以及 ORM

  • Rocket: 仅 Nightly 版本的高性能 Web 框架

与外部数据交互

有时,我们无法完全控制我们的软件栈。通常,如果你想创建一个项目,你将需要联系外部数据源,这可能导致许多问题,因为使你的代码与外部 API 或源兼容可能很困难。此外,它可能导致性能损失,我们应该尽可能避免。让我们检查一些高效且易于使用的解决方案。

数据序列化和反序列化

当谈到 Rust 中的数据序列化和反序列化时,毫无疑问我们是在谈论 serde (crates.io/crates/serde)。Serde,从序列化和反序列化中,为我们提供了一个独特的工具,能够将我们的数据结构转换为 JSON、TOML、XML 或任何其他可序列化格式。让我们看看它是如何工作的。

我们从一个简单的结构开始:

struct MyData {
    field1: String,
    field2: u32,
    field3: Vec<u8>,
}

然后我们在Cargo.toml文件中添加serdeserde_derive作为依赖项:

[dependencies]
serde = "1.0.0"
serde_derive = "1.0.0"

然后,在我们的main.rs文件中,我们只需要使用extern crate导入 crate 并为我们的结构派生Serialize特质:

extern crate serde;
#[macro_use]
extern crate serde_derive;

#[derive(Debug, Serialize)]
struct MyData {
    field1: String,
    field2: u32,
    field3: Vec<u8>,
}

现在,我们需要为我们的可序列化结构提供一个前端。这是因为serde本身只给了我们的结构序列化的能力,但没有给出它将要序列化的语言。让我们以 JSON 为例,因为它是一个非常著名的对象表示语言。我们首先在Cargo.toml文件中添加依赖项:

serde_json = "1.0.0"

然后,我们在main.rs文件中导入它并检查数据序列化:

extern crate serde_json;

fn main() {
    let example = MyData {
        field1: "Test field".to_owned(),
        field2: 33_940,
        field3: vec![65, 22, 96, 43],
    };

    let json = serde_json::to_string_pretty(&example)
                .expect("could not generate JSON string");
    println!("{}", json);
}

如果我们执行cargo run,我们将看到这段代码的输出如下:

这是一个格式完美且美观的 JSON 结构。好的,那么我们如何将这个字符串转换回我们的数据结构呢?我们需要派生Deserialize

#[derive(Debug, Serialize, Deserialize)]
struct MyData {
    field1: String,
    field2: u32,
    field3: Vec<u8>,
}

fn main() {
    let example = MyData {
        field1: "Test field".to_owned(),
        field2: 33_940,
        field3: vec![65, 22, 96, 43],
    };

    let json = serde_json::to_string_pretty(&example)
                .expect("could not generate JSON string");
    println!("JSON:");
    println!("{}", json);

    let example_back: MyData = serde_json::from_str(&json)
                        .expect("could not parse JSON string");
    println!("Back from JSON:");
    println!("{:?}", example_back);
}

这将给出以下输出:

这意味着我们可以轻松地在 JSON 和内存结构之间来回转换!但,当然,这仅适用于直接的<->对象序列化/反序列化。如果它们中的任何一个有不同的字段或字段名称,则不会工作。或者,它真的不会工作吗?

当然,不是直接这样做,但我们可以请求serde在序列化或反序列化我们的结构时修改一些参数。例如,由于在 Rust 中我们应该使用 snake case 来表示结构字段,以及pascal case 来表示枚举和结构名称,我们可能会认为无法反序列化具有 pascal case 字段的结构或具有 snake case 变体的枚举。

幸运的是,serde crate 提供了一些属性来自定义这种行为。例如,假设我们想在 Rust 中表示以下结构:

{
    "FirstData": 56,
    "SecondData": "hello, world",
    "ThirdData": -1.23
}

我们首先需要创建一个 Rust 结构来保存这些信息,如下所示:

struct MyData {
    first_data: u32,
    second_data: String,
    third_data: f32,
}

然后,我们派生适当的特质。为了重命名字段,我们需要在结构级别使用#[serde]属性和rename_all指令,如下面的代码片段所示:

extern crate serde;
#[macro_use]
extern crate serde_derive;
extern crate serde_json;

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
struct MyData {
    first_data: u32,
    second_data: String,
    third_data: f32,
}

fn main() {
    let json = r#"{
        "FirstData": 56,
        "SecondData": "hello, world",
        "ThirdData": -1.23
    }"#;

    let in_rust: MyData = serde_json::from_str(json)
                            .expect("JSON parsing failed");
    println!("In Rust: {:?}", in_rust);

    let back_to_json = serde_json::to_string_pretty(&in_rust)
                        .expect("Rust to JSON failed");
    println!("In JSON: {}", back_to_json);
}

当你运行它时,你会看到输出正好符合预期:

图片

你可以选择"lowercase""PascalCase""camelCase""snake_case""SCREAMING_SNAKE_CASE""kebab-case"。你也可以重命名特定的字段,这在原始结构有保留关键字(如type)时特别有用。在这种情况下,你可以在字段上使用#[serde(rename = "type")],并在你的 Rust 结构中使用你想要的名称。

序列化和反序列化复杂结构

在某些情况下,你可能需要序列化或反序列化复杂的数据结构。大多数时候,你会有一个为你做这件事的 crate(例如,用于日期和时间的chrono crate)。但在某些情况下,这还不够。假设你有一个数据结构,它有一个字段可以取 1 或 2 的值,并且每个值都代表不同的含义。在 Rust 中,你会使用枚举来处理它,但我们可能并不总是能控制外部 API,例如。

让我们看看这个结构:

{
    "timestamp": "2018-01-16T15:43:04",
    "type": 1,
}

假设我们有一些代码,几乎可以编译,它代表了这个结构:

#[derive(Debug)]
enum DateType {
    FirstType,
    SecondType,
}

#[derive(Debug, Serialize, Deserialize)]
struct MyDate {
    timestamp: NaiveDateTime,
    #[serde(rename = "type")]
    date_type: DateType,
}

如你所见,我们需要定义NaiveDateTime结构。我们需要在Cargo.toml文件中添加以下内容:

[dependencies.chrono]
version = "0.4.0"
features = ["serde"]

然后在main.rs文件的顶部添加导入:

extern crate chrono;
use chrono::NaiveDateTime;

剩下的唯一事情就是为DateType实现SerializeDeserialize。但如果这个枚举不是我们 crate 的一部分,我们无法修改它怎么办?在这种情况下,我们可以通过在我们的 crate 中使用一个函数来指定一种使其工作的方式,将函数名称作为serdedeserialize_with属性添加到MyDate类型中:

#[derive(Debug, Serialize, Deserialize)]
struct MyDate {
    timestamp: NaiveDateTime,
    #[serde(rename = "type",
            deserialize_with = "deserialize_date_type")]    
    date_type: DateType,
}

然后,我们需要实现这个函数。该函数需要有以下的签名:

use serde::{Deserializer, Serializer};

fn deserialize_date_type<'de, D>(deserializer: D)
    -> Result<DateType, D::Error>
    where D: Deserializer<'de>
{
    unimplemented!()
}

fn serialize_date_type<S>(date_type: &DateType, serializer: S)
    -> Result<S::Ok, S::Error>
    where S: Serializer
{
    unimplemented!()
}

然后,使用DeserializerSerializer特性就变得非常简单。您可以通过运行cargo doc来获取完整的 API 文档,但我们将了解如何针对这个特定情况进行操作。让我们从Serialize实现开始,因为它比Deserialize实现简单。您只需调用带有适当值的serialize_u8()(或任何其他整数)方法,就像您在下面的代码片段中看到的那样:

fn serialize_date_type<S>(date_type: &DateType, serializer: S)
    -> Result<S::Ok, S::Error>
    where S: Serializer
{
    use serde::Serializer;

    serializer.serialize_u8(match date_type {
        DateType::FirstType => 1,
        DateType::SecondType => 2,
    })
}

如您所见,我们只是根据日期类型的变体序列化一个整数。要选择要序列化的整数,我们只需匹配枚举。但是,Deserializer特性使用访问者模式,因此我们还需要实现一个小结构,该结构实现了Visitor特性。这并不难,但第一次做时可能会有些复杂。让我们来看看:

fn deserialize_date_type<'de, D>(deserializer: D)
    -> Result<DateType, D::Error>
    where D: Deserializer<'de>
{
    use std::fmt;
    use serde::Deserializer;
    use serde::de::{self, Visitor};

    struct DateTypeVisitor;

    impl<'de> Visitor<'de> for DateTypeVisitor {
        type Value = DateType;

        fn expecting(&self, formatter: &mut fmt::Formatter)
            -> fmt::Result
        {
            formatter.write_str("an integer between 1 and 2")
        }

        fn visit_u64<E>(self, value: u64)
            -> Result<Self::Value, E>
            where E: de::Error
        {
            match value {
                1 => Ok(DateType::FirstType),
                2 => Ok(DateType::SecondType),
                _ => {
                    let error =
                        format!("type out of range: {}", value);
                    Err(E::custom(error))
                }
            }
        }

        // Similar for other methods, if you want:
        //   - visit_i8
        //   - visit_i16
        //   - visit_i32
        //   - visit_i64
        //   - visit_u8
        //   - visit_u16
        //   - visit_u32
    }

    deserializer.deserialize_u64(DateTypeVisitor)
}

如您所见,我为Visitor实现了visit_u64()函数。这是因为serde_json似乎在序列化和反序列化整数时使用该函数。如果您想让Visitor与其他序列化和反序列化前端(如 XML、TOML 等)兼容,您可以实现其余部分。您可以看到,结构和Visitor特性实现是在函数内部定义的,所以我们不会污染函数外部的命名空间。

您可以使用新的main()函数来测试它:

fn main() {
    let json = r#"{
        "timestamp": "2018-01-16T15:43:04",
        "type": 1
    }"#;

    let in_rust: MyDate = serde_json::from_str(json)
                            .expect("JSON parsing failed");
    println!("In Rust: {:?}", in_rust);

    let back_to_json = serde_json::to_string_pretty(&in_rust)
                        .expect("Rust to JSON failed");
    println!("In JSON: {}", back_to_json);
}

应该显示以下输出:

图片

当然,如果您需要,可以为您需要的完整结构和枚举实现SerializeDeserialize特性,如果serde属性不足以满足您的需求。它们的实现与这些函数中看到的大致相同,但您需要检查 API 以获取更复杂的数据序列化和反序列化。您可以在serde.rs/找到一个很好的指南,解释了这个 crate 的具体选项。

解析字节流

有时,您可能想要解析字节流或字节切片以获取有价值的数据。一个例子可能是解析 TCP 字节流以获取 HTTP 数据。多亏了RustNom crate,我们有一个非常高效的解析生成器,它不会在您的 crate 内部复制数据时添加额外的开销。

使用Nom crate,您创建函数来逐字节读取输入数据并返回解析后的数据。本节的目标不是掌握Nom crate,而是理解它的强大功能,并指向适当的文档。所以,让我们看看 Zbigniew Siciarz 的 24 天 Rust(siciarz.net/24-days-rust-nom-part-1/)中改编的示例,他在其中展示了如何解析 HTTP 协议的第一行的一个简短示例。您可以在他的博客上阅读更复杂的教程。

让我们先定义协议的第一行看起来是什么样子:

let first_line = b"GET /home/ HTTP/1.1\r\n";

正如你所见,first_line变量是一个字节数组(在字符串前用b表示)。它只包含第一个单词作为方法,在这个例子中是GET,但可能是POSTPUTDELETE或其他任何方法。我们将坚持使用这四种方法以保持简单。然后,我们可以读取客户端试图获取的 URL,最后是 HTTP 协议版本,在这个例子中将是1.1。这一行以回车符和换行符结束。

Nom使用一个名为named!()的宏,其中你定义一个解析函数。宏的名字来源于你为函数命名,然后是它的实现。

如果我们要开始检查第一条 HTTP 行,我们需要解析request方法。为了做到这一点,我们必须告诉解析器第一条行可以是任何可能的request方法之一。我们可以通过使用带有多个tag!()宏的alt!()宏来实现这一点,每个宏对应一个协议。让我们将Nom添加到我们的Cargo.toml文件中,并开始编写方法解析代码:

#[macro_use]
extern crate nom;

named!(parse_method, 
    alt!(
        tag!("GET") |
        tag!("POST") |
        tag!("PUT") |
        tag!("DELETE")
    )
);

fn main() {
    let first_line = b"GET /home/ HTTP/1.1\r\n";
    println!("{:?}", parse_method(&first_line[..]));
}

这将输出以下内容:

Ok(([32, 47, 104, 111, 109, 101, 47, 32, 72, 84, 84, 80, 47, 49, 46, 49, 13, 10], [71, 69, 84]))

这里发生了什么?这似乎只是一串数字,一个接一个。好吧,正如我们之前提到的,Nom按字节工作,并且不关心(除非我们告诉它)事物的字符串表示。在这种情况下,它已经正确地找到了GET,ASCII 中的字节 71、69 和 84,其余的尚未解析。它返回一个元组,首先是未解析的数据,然后是解析的数据。

我们可以告诉Nom我们想要读取实际的GET字符串,通过将结果映射到str::from_utf8函数。让我们相应地更改解析器:

named!(parse_method<&[u8], &str>,
    alt!(
        map_res!(tag!("GET"), str::from_utf8) |
        map_res!(tag!("POST"), str::from_utf8) |
        map_res!(tag!("PUT"), str::from_utf8) |
        map_res!(tag!("DELETE"), str::from_utf8)
    )
);

正如你所见,除了添加map_res!()宏之外,我还必须指定parse_method在解析输入后返回&str,因为Nom默认假设你的解析器将返回字节切片。这将输出以下内容:

Ok(([32, 47, 104, 111, 109, 101, 47, 32, 72, 84, 84, 80, 47, 49, 46, 49, 13, 10], "GET"))

我们甚至可以创建一个枚举并将其直接映射,就像你在这里看到的:

#[derive(Debug)]
enum Method {
    Get,
    Post,
    Put,
    Delete,
}

impl Method {
    fn from_bytes(b: &[u8]) -> Result<Self, String> {
        match b {
            b"GET" => Ok(Method::Get),
            b"POST" => Ok(Method::Post),
            b"PUT" => Ok(Method::Put),
            b"DELETE" => Ok(Method::Delete),
            _ => {
                let error = format!("invalid method: {}",
                                    str::from_utf8(b)
                                        .unwrap_or("not UTF-8"));
                Err(error)
            }
        }
    }
}

named!(parse_method<&[u8], Method>,
    alt!(
        map_res!(tag!("GET"), Method::from_bytes) |
        map_res!(tag!("POST"), Method::from_bytes) |
        map_res!(tag!("PUT"), Method::from_bytes) |
        map_res!(tag!("DELETE"), Method::from_bytes)
    )
);

我们可以将多个解析器组合在一起,在一个解析器中创建变量,这些变量将在下一个解析器中重用。这在某些数据部分包含用于解析其余部分的信息时非常有用。这是 HTTP 内容长度头的情况,它告诉你应该解析多少数据。让我们用它来解析完整的请求:

use std::str;

#[derive(Debug)]
struct Request {
    method: Method,
    url: String,
    version: String,
}

named!(parse_request<&[u8], Request>, ws!(do_parse!(
    method: parse_method >>
    url: map_res!(take_until!(" "), str::from_utf8) >>
    tag!("HTTP/") >>
    version: map_res!(take_until!("\r"), str::from_utf8) >>
    (Request {
        method,
        url: url.to_owned(),
        version: version.to_owned()
    })
)));

fn main() {
    let first_line = b"GET /home/ HTTP/1.1\r\n";
    println!("{:?}", parse_request(&first_line[..]));
}

让我们看看这里发生了什么。我们创建了用于存储行数据的结构,然后通过使用ws!()宏(它将自动消费令牌之间的空格)创建了一个解析器。do_parse!()宏允许我们创建多个解析器的序列。

我们调用我们刚刚为请求方法创建的parse_method()解析器,然后我们只需将其他两个字符串作为变量存储。然后我们只需要用这些变量创建结构。注意,我也在main()函数中更改了调用。让我们看看结果:

Ok(([], Request { method: Get, url: "/home/", version: "1.1" }))

如我们所见,没有更多的字节需要解析,Request 结构体已经被正确生成。你可以为极其复杂的结构体生成解析器,例如,你可以解析 URL 来获取段,或者版本号来获取主版本号和次版本号,等等。唯一的限制是你的需求。

在这种情况下,当我们调用 to_owned() 为两个字符串进行复制时,我们确实需要这样做,如果我们想要生成一个拥有字段。如果你需要更快的处理速度,可以使用显式生命周期来避免大量复制。

了解有用的小型 crate

虽然数据处理可能产生一些最易出错的代码,但我们也应该了解一些使我们的生活更加轻松的小型库。以下是一些 crate,其中一些宏可以防止我们编写大量易出错或可能非最优的代码,使我们的最终可执行文件更快、更易于开发。

创建懒加载的静态变量

在前面的章节中,我们已经看到在夜间 Rust 中,可以调用一些在编译时评估的平凡常量函数。然而,这可能不足以满足我们的需求,我们甚至可能不想使用夜间 Rust。

在这种情况下,我们可以使用一个很棒的 crate,以及具有相同名称的宏——lazy_static。这个宏允许我们创建在首次使用时运行生成代码的静态变量。让我们以 HashMap 为例检查它,创建一个 HashMap 或向其中添加值不能在编译时完成。正如我们在前面的章节中看到的,这可以通过使用 phf crate 来改进。但如果我们想根据某些环境变量向 HashMap 添加值怎么办?这就是 lazy_static!{} 发挥作用的地方:

#[macro_use]
extern crate lazy_static;

use std::collections::HashMap;

lazy_static! {
    static ref MY_MAP: HashMap<&'static str, &'static str> = {
        use std::env;

        let mut map = HashMap::new();
        if let Ok(val) = env::var("GEN_MAP") {
            if val == "true" {
                map.insert("firstKey", "firstValue");
                map.insert("secondKey", "secondValue");
            }
        }

        map
    };
}

fn main() {
    for (key, value) in MY_MAP.iter() {
        println!("{}: {}", key, value);
    }
}

正如你所见,我们首次使用时在运行时创建了一个 HashMap,因此它将不会定义,直到我们调用 MyMap.iter(),如果我们再次使用它,它就不需要重新创建。不仅如此,它还依赖于 GEN_MAP 环境变量。所以,如果我们用 cargo run 运行程序,它不会显示任何内容;但如果我们用 GEN_MAP=true cargo run 运行它,它将显示两个键值对。

在底层,这将创建一个新的类型,该类型实现了对 HashMapDeref。这将首次尝试访问底层类型时调用 initialize() 函数,生成实际的 HashMap。如果你只想初始化一次将多次使用的东西,这非常高效。

避免构建模式的样板代码

这个很简单。如果你了解构建模式,你就会知道它是一个非常有用的模式来创建结构。我们可以通过使用 derive_builder crate 来避免编写整个新的构建结构。所以,让我们将它添加到我们的 Cargo.toml 文件中,并检查它是如何工作的:

#[macro_use]
extern crate derive_builder;

use std::path::PathBuf;

#[derive(Default, Debug, Builder)]
#[builder(setter(into), default)]
struct MyData {
    field1: u8,
    field2: PathBuf,
    field3: String,
}

fn main() {
    let data = MyDataBuilder::default()
                .field2("path/to/file.png")
                .field3("Some string")
                .build().unwrap();

    println!("{:?}", data);
}

正如你所看到的,我们只是在这个结构中添加了#[derive(Build)],并添加了一些额外的参数,例如允许非初始化字段使用默认值,并允许设置器使用泛型参数(Into<T>)。请注意,这要求结构实现Default特质。

这使得我们可以使用简单的&str变量来初始化结构,例如,然后构建器会完成剩下的工作。正如你所看到的,它会创建一个{your_structure}Builder结构,你将使用它来构建主要的结构。确保你在crates.io的 crate 页面上检查所有让你能够根据需要调整构建器的选项。

管理错误

如果你已经在 Rust 中使用过多个库,你可能已经注意到管理错误并不简单。我们有一个很棒的?操作符,但如果一个函数有多个错误,使用起来就不那么容易了。我们可以创建自己的错误类型,为每个错误提供变体,并为可能遇到的每个错误实现一个Into特质。这是一个繁琐的方法,但直到最近,这是唯一的方法。

幸运的是,我们有一个可以帮我们处理这个问题的 crate。这个 crate 为我们提供了一个Fail特质,它已经保证了线程安全,并且已经为所有标准库错误类型提供了默认的转换实现。它还提供了一些宏,帮助我们处理一些样板代码。让我们看看一个例子,看看它是如何工作的:

extern crate failure;

use std::fs::File;
use std::io::Read;

use failure::{Error, ResultExt};

fn main() {
    match read_file() {
        Err(e) => {
            eprintln!("Error: {}", e);

            for cause in e.causes().skip(1) {
                eprintln!("Caused by: {}", cause);
            }
        },
        Ok(content) => {
            println!("{}…",
                     content.chars()
                            .take(15)
                            .collect::<String>());
        }
    }
}

fn read_file() -> Result<String, Error> {
    let file_name = "Cargo.toml";
    let mut file = File::open(file_name)
                    .context("error opening the file")?;

    let mut content = String::new();
    file.read_to_string(&mut content)
        .context("error reading the file")?;

    Ok(content)
}

在这个简单的例子中,我们获取Cargo.toml文件的前几个字符。正如你所看到的,我们使用?操作符将std::io::Errors转换为failure::Errors。然后,我们可以遍历错误(如果存在的话)。如果出现问题,这将输出代码。我们为每个潜在的错误添加了一些上下文,以便输出得到正确打印:

你也可以创建自己的错误特质,并使用failure_derive crate 来推导Fail特质。我建议查看完整的文档,并在所有新的项目中使用它。它带来了许多自己实现或使用前驱error-chain crate 所没有的优势。

在 Rust 中高效地记录日志

记录是许多应用程序最重要的部分之一,而且很高兴知道 Rust 在这方面为我们提供了保障。默认的 goto crate 应该是log crate,它为我们提供了有用的宏来记录。然后,你可以使用你想要的日志记录器后端,例如env_logger crate 或log4rs crate。

log crate 为我们提供了一些宏,主要是trace!()debug!()info!()warn!()error!(),按照相关性的升序排列,我们可以使用它们来记录应用程序中发生的事件。它提供了一些样板代码,但基本上就是这样,你现在需要配置这些宏的行为。为此,你有实际的实现。

如果你需要一个易于使用、通用的日志记录器,你应该选择env_logger。它占用空间小,可以通过环境变量进行配置。如果你需要为诸如多个输出、控制台和文件以及额外配置等额外配置,你应该选择一个替代方案,例如log4rs。让我们检查一个小的env_logger示例,看看这种日志记录机制的力量。你需要在你的Cargo.toml文件中添加logenv_logger

#[macro_use]
extern crate log;
extern crate env_logger;

fn main() {
    env_logger::init();

    trace!("Logging {} small thing(s)", 1);
    debug!("Some debug information:  {}",
            "the answer is 42");
    info!("This is an interesting information");
    error!("An error happened, do something!");
}

如果我们使用cargo run运行它,我们将看到以下输出,因为默认情况下会显示错误:

但我们可以用不同的RUST_LOG环境变量来运行它,例如RUST_LOG=trace cargo run。这将显示以下内容:

如你所见,颜色表示消息的重要性。请注意,使用带有RUST_LOG变量的cargo运行将显示大量的额外输出,因为 cargo 本身使用env_logger。我建议你阅读这个 crate 的完整文档,因为它允许你更改格式化程序、日志记录器以及更多默认行为之外的功能。

创建命令行界面

创建命令行界面并不总是容易。在 C/C++中,你需要开始解析参数,然后决定哪些标志被设置,以及它们是否满足所有条件。在 Rust 中,这并不是一个问题,多亏了命令行参数解析器CLAP)。CLAP crate 使我们能够仅用一点代码就创建非常复杂的命令行界面。

不仅如此;它还会为我们创建帮助菜单,并且由于它易于添加或删除参数和标志,因此它将是可维护的。它将确保我们接收到的输入是有效的,甚至为最常用的 shell 创建命令行完成脚本。

你可以使用宏生成完整的 CLI,但我个人更喜欢使用简单的 Rust 代码。尽管如此,它有几个helper宏来收集一些信息。请记住将clap添加到你的Cargo.toml文件中,并让我们看看我们如何创建一个简单的命令行界面:

#[macro_use]
extern crate clap;

use clap::{App, Arg};

fn main() {
    let matches = App::new(crate_name!())
                    .version(crate_version!())
                    .about(crate_description!())
                    .author(crate_authors!())
                    .arg(
                       Arg::with_name("user")
                           .help("The user to say hello to")
                           .value_name("username")
                           .short("u")
                           .long("username")
                           .required(true)
                           .takes_value(true)
                    )
                    .get_matches();

    let user = matches.value_of("user")
            .expect("somehow the user did not give the username");

    println!("Hello, {}", user);
}

如你所见,我们定义了一个带有 crate 名称、描述、版本和作者的 CLI,这些将在编译时从Cargo.toml文件中获取,这样我们就不需要为每次更改更新它。然后它定义了一个必需的user参数,它接受一个值并使用它来打印该值。这里的expect()是安全的,因为clap确保了提供了参数,因为我们要求它使用required(true)。如果我们简单地执行cargo run,我们将看到以下错误:

它告诉我们需要 username 参数,并指向由 clap 自动添加的 --help 标志,以及 -V 标志,以显示 crate 版本信息。如果我们用 cargo run -- --help 运行它,我们将看到 help 输出。请注意,任何在 cargo 后面跟双横线的参数都将作为参数传递给可执行文件。让我们检查一下:

如我们所见,它显示了格式良好的帮助文本。如果我们想真正看到传递正确用户名后的结果,我们可以用 cargo run -- -u {username} 来执行它:

使用 Rust 进行 Web 开发

你可能会认为 Rust 只适用于复杂系统开发,或者它应该用于安全是首要关注的地方。考虑将其用于 Web 开发可能对你来说听起来像是过度杀鸡用牛刀。我们已经有了一些证明有效的面向 Web 的语言,比如 PHP 或 JavaScript,对吧?

这远非事实。许多项目将 Web 作为他们的平台,对于他们来说,有时能够处理大量流量而不需要投资昂贵的服务器,比使用过时的技术更重要,尤其是在新产品中。这就是 Rust 发挥作用的地方。多亏了它的速度和一些真正深思熟虑的面向 Web 的框架,Rust 的表现甚至比传统的 Web 编程语言还要好。

Rust 甚至试图取代应用程序客户端的一些 JavaScript,因为 Rust 可以编译成 WebAssembly,这使得它在处理重客户端 Web 工作负载时非常强大。我们在这本书中不会学习如何为 Web 客户端编译,但我们将了解一些允许你使用 Rust 进行高效 Web 开发的 crate。

创建极其高效的模板

我们已经看到,Rust 是一种非常高效的编程语言,正如你在前两章中看到的,元编程允许创建更加高效的代码。Rust 拥有强大的模板语言支持,例如 Handlebars 和 Tera。Rust 的 Handlebars 实现比 JavaScript 实现要快得多,而 Tera 是基于 Jinja2 为 Rust 创建的模板引擎。

在这两种情况下,你定义一个模板文件,然后使用 Rust 来解析它。尽管这对于大多数 Web 开发来说可能是合理的,但在某些情况下,它可能比纯 Rust 替代方案要慢。这就是 Maud crate 发挥作用的地方。我们将看到它是如何工作的,以及它是如何实现比其竞争对手快几个数量级的性能的。

要使用 Maud,你需要 nightly Rust,因为它使用过程宏。正如我们在前面的章节中看到的,如果你使用 rustup,你可以简单地运行 rustup override set nightly。然后,你需要在 Cargo.toml 文件的 [dependencies] 部分添加 Maud:

[dependencies]
maud = "0.17.2

Maud 引入了一个 html!{} 过程宏,它允许你在 Rust 中编写 HTML。因此,你需要在 main.rslib.rs 文件中导入必要的 crate 和宏,正如你将在下面的代码中看到的那样。记得在 crate 的开头添加过程宏功能:

#![feature(proc_macro)]

extern crate maud;
use maud::html;

现在,你将能够在 main() 函数中使用 html!{} 宏。这个宏将返回一个 Markup 对象,然后你可以将其转换为 String 或返回给 Rocket 或 Iron 以实现你的网站(在这种情况下,你需要使用相关的 Maud 功能)。让我们看看一个简短的模板实现看起来像什么:

fn main() {
    use maud::PreEscaped;

    let user_name = "FooBar";
    let markup = html! {
        (PreEscaped("<!DOCTYPE html>"))
        html {
            head {
                title { "Test website" }
                meta charset="UTF-8";
            }
            body {
                header {
                    nav {
                        ul {
                            li { "Home" }
                            li { "Contact Us" }
                        }
                    }
                }
                main {
                    h1 { "Welcome to our test template!" }
                    p { "Hello, " (user_name) "!" }
                }
                footer {
                    p { "Copyright © 2017 - someone" }
                }
            }
        }
    };
    println!("{}", markup.into_string());
}

这个模板看起来很复杂,但它只包含了一个新网站应该有的基本信息。我们首先添加 doctype,确保它不会逃逸内容(这就是 PreEscaped 的作用),然后我们以两个部分开始 HTML 文档:headbody。在 head 中,我们添加必要的标题和 charset meta 元素,告诉浏览器我们将使用 UTF-8。

然后,body 包含了三个常用的部分,尽管当然可以对其进行修改。一个 header,一个 main 部分,和一个 footer。我在每个部分中添加了一些示例信息,并展示了如何在 main 部分内的段落中添加动态变量。

这里的有趣语法是,你可以创建带有属性的元素,例如 meta 元素,即使没有内容,也可以通过提前使用分号来结束。你可以使用任何 HTML 标签并添加变量。生成的代码将被转义,除非你要求非转义数据,并且它将被压缩,以便在传输时占用最少的空间。

在括号内,你可以调用任何返回实现 Display 特性的类型的功能或变量,如果你在它周围添加大括号,甚至可以添加任何 Rust 代码,最后一条语句将返回一个 Display 元素。这也适用于属性。

这将在编译时进行处理,因此运行时只需执行最小的工作量,使其非常高效。不仅如此;由于 Rust 的编译时保证,模板将是类型安全的,所以你不会忘记关闭一个标签或属性。可以在 maud.lambda.xyz/ 找到关于模板引擎的完整指南。

连接到数据库

如果我们想在 Rust 中使用 SQL/关系数据库,除了 Diesel 没有其他 crate 值得考虑。如果你需要访问 Redis 或 MongoDB 等非关系数据库,你也会找到合适的 crate,但由于最常用的数据库是关系数据库,我们在这里将检查 Diesel。

Diesel 通过提供出色的 ORM 和类型安全的查询构建器,使得与 MySQL/MariaDB、PostgreSQL 和 SQLite 一起工作变得非常容易。它在编译时防止所有潜在的 SQL 注入,但仍然非常快速。实际上,它通常比使用预处理语句更快,因为它管理数据库连接的方式。不深入技术细节,我们将检查这个稳定框架是如何工作的。

Diesel 的发展令人印象深刻,它已经在稳定的 Rust 中运行。它甚至有一个稳定的 1.x 版本,所以让我们看看我们如何映射一个简单的表。Diesel 附带一个命令行界面程序,这使得它更容易使用。要安装它,运行 cargo install diesel_cli。请注意,默认情况下,这将尝试为 PostgreSQL、MariaDB/MySQL 和 SQLite 安装它。

对于这个简短的教程,您需要安装 SQLite 3 开发文件,但如果您想避免安装所有 MariaDB/MySQL 或 PostgreSQL 文件,您应该运行以下命令:

cargo install --no-default-features --features sqlite diesel_cli

然后,由于我们将使用 SQLite 进行我们的短期测试,请在当前目录中添加一个名为 .env 的文件,内容如下:

DATABASE_URL=test.sqlite

我们现在可以运行 diesel setupdiesel migration generate initial_schema。这将创建 test.sqlite SQLite 数据库和一个 migrations 文件夹,以及第一个空的初始模式迁移。让我们将其添加到初始模式 up.sql 文件中:

CREATE TABLE 'users' (
  'username' TEXT NOT NULL PRIMARY KEY,
  'password' TEXT NOT NULL,
  'email' TEXT UNIQUE
);

在其对应的 down.sql 文件中,我们需要删除创建的表:

DROP TABLE `users`;

然后,我们可以执行 diesel migration run 并检查一切是否顺利。我们可以执行 diesel migration redo 来检查回滚和重新创建是否正常工作。我们现在可以开始使用 ORM。我们需要将 diesel、diesel_infer_schemadotenv 添加到我们的 Cargo.toml 中。dotenv crate 将读取 .env 文件以生成环境变量。如果您想避免使用所有 MariaDB/MySQL 或 PostgreSQL 功能,您需要为 diesel 进行配置:

[dependencies]
dotenv = "0.10.1"

[dependencies.diesel]
version = "1.1.1"
default-features = false
features = ["sqlite"]

[dependencies.diesel_infer_schema]
version = "1.1.0"
default-features = false
features = ["sqlite"]

现在让我们创建一个结构,我们将能够用它从数据库中检索数据。我们还需要一些样板代码来使一切正常工作:

#[macro_use]
extern crate diesel;
#[macro_use]
extern crate diesel_infer_schema;
extern crate dotenv;

use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;
use dotenv::dotenv;
use std::env;

#[derive(Debug, Queryable)]
struct User {
    username: String,
    password: String,
    email: Option<String>,
}

fn establish_connection() -> SqliteConnection {
 dotenv().ok();

 let database_url = env::var("DATABASE_URL")
 .expect("DATABASE_URL must be set");
 SqliteConnection::establish(&database_url)
 .expect(&format!("error connecting to {}", database_url))
}

mod schema {
 infer_schema!("dotenv:DATABASE_URL");
}

在这里,establish_connection() 函数将调用 dotenv(),以便 .env 文件中的变量进入环境,然后它使用该 DATABASE_URL 变量与 SQLite 数据库建立连接并返回句柄。

模式模块将包含数据库的模式。infer_schema!() 宏将获取 DATABASE_URL 变量并在编译时连接到数据库以生成模式。确保在编译前运行所有迁移。

我们现在可以开发一个简单的 main() 函数,使用基础功能来列出数据库中的所有用户:

fn main() {
    use schema::users::dsl::*;

    let connection = establish_connection();
    let all_users = users
       .load::<User>(&connection)
       .expect("error loading users");

    println!("{:?}", all_users);
}

这将只是将数据库中的所有用户加载到一个列表中。注意函数开始处的 use 语句。这从 users 表的模式中检索所需信息,以便我们随后可以调用 users.load()

正如你在diesel.rs指南中看到的那样,你还可以生成Insertable对象,这些对象可能没有一些具有默认值的字段,并且你可以通过以与编写SELECT语句相同的方式过滤结果来执行复杂查询。

创建一个完整的网络服务器

Rust 有多种网络框架。其中一些在稳定版 Rust 中工作,例如 Iron 和 Nickel 框架,而另一些则不行,例如 Rocket。我们将讨论后者,因为即使它迫使你使用最新的夜间分支,它也比其他框架强大得多,如果你有选择使用 Rust 夜间版本,那么使用其他任何框架都没有意义。

使用柴油与火箭结合,除了有趣的文字游戏玩笑外,工作起来无缝衔接。你可能经常会将这两个一起使用,但在这个部分,我们将学习如何创建一个没有额外复杂性的小型火箭服务器。有一些模板代码实现为网站添加数据库、缓存、OAuth、模板、响应压缩、JavaScript 压缩和 SASS 压缩,例如 GitHub 上的我的 Rust 网络模板(github.com/Razican/Rust-web-template),如果你需要开始开发一个真实的 Rust 网络应用程序。

Rocket 以夜间不稳定为代价,这会频繁地破坏你的代码,换取简单性和性能。开发 Rocket 应用程序非常容易,结果性能令人惊叹。它甚至比使用一些看似更简单的框架还要快,当然,它比大多数其他语言的框架快得多。那么,开发 Rocket 应用程序的感觉如何?

我们首先通过在Cargo.toml文件中添加最新的rocketrocket_codegencrate,并通过运行rustup override set nightly为当前目录添加夜间覆盖来开始。rocketcrate 包含运行服务器的所有代码,而rocket_codegencrate 实际上是一个编译器插件,它修改语言以适应 Web 开发。我们现在可以编写默认的Hello, world!Rocket 示例:

#![feature(plugin)]
#![plugin(rocket_codegen)]

extern crate rocket;

#[get("/")]
fn index() -> &'static str {
    "Hello, world!"
}

fn main() {
    rocket::ignite().mount("/", routes![index]).launch();
}

在这个示例中,我们可以看到我们如何请求 Rust 让我们使用插件,然后导入rocket_codegen插件。这将使我们能够在编译时使用#[get]#[post]等属性,这些属性将生成模板代码,使我们的代码在开发中保持相对简单。此外,请注意,此代码已与 Rocket 0.3 进行了检查,它可能在未来的版本中失败,因为该库尚不稳定。

在这种情况下,你可以看到index()函数将响应任何带有基本 URL 的GET请求。这可以被修改为只接受某些 URL,或者从 URL 中获取某个路径。你也可以有具有不同优先级的重叠路由,这样如果请求保护器没有选择一个,下一个将被尝试。

此外,谈到请求守卫,你可以创建在处理请求时可以生成的对象,这些对象只有在正确构建的情况下才会允许请求执行给定的函数。这意味着,例如,你可以创建一个User对象,该对象将通过检查请求中的 cookie 并在 Redis 数据库中进行比较来生成,仅允许登录用户执行函数。这可以轻松防止许多逻辑错误。

main()函数点燃火箭并将索引路由挂载到/。这意味着你可以有多个具有相同路径的路由挂载到不同的路由路径,它们不需要知道 URL 中的整个路径。最后,它将启动 Rocket 服务器,如果你使用cargo run运行它,它将显示以下内容:

如果你访问 URL,你会看到Hello, World!消息。Rocket 高度可配置。它有一个rocket_contrib crate,它提供模板和更多功能,你可以创建响应者以向响应添加 GZip 压缩。当发生错误时,你还可以创建自己的错误响应者。

你还可以通过使用Rocket.toml文件和环境变量来配置 Rocket 的行为。正如你可以在最后一个输出中看到的那样,它正在开发模式下运行,这添加了一些调试信息。你可以为预发布和生产模式配置不同的行为,并使它们运行得更快。此外,请确保在生产中用--release模式编译代码。

如果你想在 Rocket 中开发 Web 应用程序,请确保检查rocket.rs/以获取更多信息。未来的版本也很有希望。Rocket 将实现本机 CSRF 和 XSS 防护,理论上应在编译时防止所有 XSS 和 CSRF 攻击。它还将使对引擎的进一步定制成为可能。

摘要

在本章中,你了解了许多将使你编写 Rust 代码的生活更加轻松的 crate。你学习了它们不仅允许你编写更少的代码,而且可以帮助你编写更快的代码。我们还看到了如何轻松地使用来自crates.io/的 crate,这在使用他人编写的代码时赋予我们超级能力。

在下一章中,你将学习如何开发自己的宏,类似于这里看到的,你还将学习如何创建自己的过程宏和插件。

第九章:创建您的自己的宏

在前面的章节中,我们看到了宏和元编程如何使您的生活变得更加轻松。我们看到了两种宏,一种是减少所需样板代码的宏,另一种是能加快最终代码的宏。现在是时候学习如何创建自己的宏了。

在本章中,您将学习如何创建自己的标准宏,如何创建自己的过程宏和自定义的 derive,以及最后如何使用夜间功能来创建自己的插件。您还将了解新的声明式宏是如何工作的。

本章分为三个部分:

  • 宏系统:理解 macro_rules!{}

  • 过程宏:学习如何创建自己的自定义 derive

  • 夜间元编程:插件和声明式宏

创建您自己的标准宏

自 Rust 1.0 以来,我们有一个非常好的宏系统。宏允许我们将一些代码应用到多个类型或表达式中,因为它们通过在编译时展开自身来实现。这意味着当您使用宏时,您实际上在编译开始之前就写了很多代码。这有两个主要好处,首先,通过变得更小和重用代码,代码库可以更容易维护,其次,由于宏在开始创建目标代码之前展开,您可以在语法级别进行抽象。

例如,您可以有这样一个函数:

fn add_one(input: u32) -> u32 {
    input + 1
}

这个函数将输入限制为 u32 类型,并将返回类型限制为 u32。我们可以通过使用泛型添加一些其他可接受类型,如果我们使用 Add 特性,它可能接受 &u32。宏允许我们为任何可以写入 + 符号左侧的元素创建这种代码,并且它将为每种类型的元素生成不同的代码,为每种情况创建不同的代码。

要创建一个宏,您需要使用语言内置的宏,即 macro_rules!{} 宏。这个宏接受新宏的名称作为第一个参数,以及包含宏代码的代码块作为第二个元素。第一次看到这个语法时,它可能有点复杂,但可以很快学会。让我们从一个与之前看到的函数做同样事情的宏开始:

macro_rules! add_one {
    ($input:expr) => {
        $input + 1
    }
}

您现在可以从 main() 函数中调用该宏,通过调用 add_one!(integer);。请注意,即使在同一文件中,宏也需要在第一次调用之前定义。它将适用于任何整数,这是函数所做不到的。

让我们分析一下语法是如何工作的。在新宏(add_one)名称之后的代码块中,我们可以看到两个部分。在第一个部分,在 => 的左边,我们看到括号内的 $input:expr。然后,在右边,我们看到一个 Rust 代码块,我们在其中执行实际的加法操作。

左侧部分的工作方式(在某些方面)类似于模式匹配。你可以添加任何字符组合和一些变量,所有这些变量都以美元符号($)开头,并在冒号后显示变量的类型。在这种情况下,唯一的变量是 $input 变量,它是一个表达式。这意味着你可以在那里插入任何类型的表达式,它将被写入右侧的代码中,用表达式替换变量。

宏变体

如你所见,它并没有你想象的那么复杂。正如我写的,你可以在 macro_rules!{} 侧的左侧几乎有任意模式。不仅如此,你还可以有多个模式,就像是一个匹配语句,所以如果其中一个匹配,它将被展开。让我们通过创建一个宏来了解它是如何工作的,这个宏根据我们如何调用它,将给定的整数加一或加二:

macro_rules! add {
    {one to $input:expr} => ($input + 1);
    {two to $input:expr} => ($input + 2);
}

fn main() {
    println!("Add one: {}", add!(one to 25/5));
    println!("Add two: {}", add!(two to 25/5));
}

你可以看到宏的几个明显的变化。首先,我们在宏中交换了花括号和括号。这是因为在一个宏中,你可以使用可互换的花括号({})、方括号([])和括号(())。不仅如此,你还可以在调用宏时使用它们。你可能已经使用过 vec![] 宏和 format!() 宏,我们在上一章中看到了 lazy_static!{} 宏。我们在这里使用方括号和括号只是为了约定,但我们可以以相同的方式调用 vec!{}format![] 宏,因为我们可以在任何宏调用中使用花括号、方括号和括号。

第二次更改是在我们的左侧模式中添加一些额外的文本。我们现在通过编写文本 one totwo to 来调用我们的宏,所以我也将 one 的冗余从宏名称中移除,并称之为 add!()。这意味着我们现在用文字文本调用我们的宏。这不是有效的 Rust,但由于我们正在使用宏,我们在编译器试图理解实际的 Rust 代码之前修改了我们正在编写的代码,生成的代码是有效的。我们可以在模式中添加任何不结束模式的文本(例如括号或花括号)。

最后的更改是添加第二种可能的模式。现在我们可以添加一个或两个,唯一的区别是宏定义的右侧现在必须以每个模式的尾随分号结尾(最后一个可选),以分隔每个选项。

在示例中我还添加了一个小细节,那就是在main()函数中调用宏。如您所见,我本可以将onetwo加到5上,但我写25/5是有原因的。在编译这段代码时,这将展开为25/5 + 1(或者如果你使用第二个变体,是2)。这将在编译时进行优化,因为它将知道25/5 + 16,但编译器将接收到这个表达式,而不是最终结果。宏系统不会计算表达式的结果;它将简单地复制你给出的结果代码,并将其传递给下一个编译阶段。

当你创建的宏调用另一个宏时,你应该特别注意这一点。它们将会递归地展开,一层套一层,因此编译器将接收到一大堆最终的 Rust 代码,这些代码需要被优化。在上一章中我们看到的 CLAP crate 中发现了与此相关的问题,因为指数展开给它们的可执行文件添加了大量的冗余代码。一旦他们发现其他宏内部有太多的宏展开并且修复了这个问题,他们通过超过 50%的比例减少了他们的二进制贡献的大小。

宏允许额外的定制层。你可以重复参数多次。这在vec![]宏中很常见,例如,在编译时创建一个新向量。你可以写像vec![3, 4, 76, 87];这样的东西。vec![]宏是如何处理未指定数量的参数的?

复杂的宏

我们可以通过在宏定义的左侧模式中添加一个*来指定我们想要多个表达式,表示零个或多个匹配,或者添加一个+来表示一个或多个匹配。让我们看看我们如何通过简化的my_vec![]宏来实现这一点:

macro_rules! my_vec {
    ($($x: expr),*) => {{
        let mut vector = Vec::new();
        $(vector.push($x);)*
        vector
    }}
}

让我们看看这里发生了什么。首先,我们看到在左侧,我们有两个变量,由两个$符号表示。第一个指的是实际的重复。每个以逗号分隔的表达式将生成一个$x变量。然后,在右侧,我们使用各种重复来将$x向量为每个接收到的表达式推送一次。

右侧还有一个新事物。如您所见,宏展开开始和结束于一个双花括号,而不是只使用一个。这是因为一旦宏展开,它将用给定表达式替换一个新表达式:即生成的表达式。由于我们想要返回我们创建的向量,我们需要一个新的作用域,其中最后一句话将是作用域执行后的值。你将在下一个代码片段中更清楚地看到这一点。

我们可以用main()函数来调用这段代码:

fn main() {
    let my_vector = my_vec![4, 8, 15, 16, 23, 42];
    println!("Vector test: {:?}", my_vector);
}

它将被扩展到以下代码:

fn main() {
    let my_vector = {
        let mut vector = Vec::new();
        vector.push(4);
        vector.push(8);
        vector.push(15);
        vector.push(16);
        vector.push(23);
        vector.push(42);
        vector
    };
    println!("Vector test: {:?}", my_vector);
}

如您所见,我们需要这些额外的花括号来创建一个作用域,以便返回向量,这样它就会被分配给my_vector绑定。

你可以在左侧表达式中有多重重复模式,并且它们将根据需要重复使用。在官方 Rust 书的第一个版本中有一个很好的例子说明了这种行为,我在这里进行了改编:

macro_rules! add_to_vec {
    ($( $x:expr; [ $( $y:expr ),* ]);* ) => {
        &[ $($( $x + $y ),*),* ]
    }
}

在这个例子中,宏可以接收一个或多个$x; [$y1, $y2,...]输入。所以,对于每个输入,它将有一个表达式,然后是一个分号,然后是一个带有多个子表达式并用逗号分隔的括号,最后是另一个括号和一个分号。但宏对这个输入做了什么?让我们检查它的右侧。

如你所见,这将创建多个重复。我们可以看到它创建了一个切片(&[T]),无论我们给它什么,所以所有我们使用的表达式都必须是同一类型。然后,它将遍历所有的$x变量,每个输入组一个。所以如果我们只给它一个输入,它将为分号左侧的表达式迭代一次。然后,它将为与$x表达式关联的每个$y表达式迭代一次,将它们添加到+运算符中,并将结果包含在切片中。

如果这太复杂而难以理解,让我们看一个例子。假设我们用65; [22, 34]作为输入调用宏。在这种情况下,65将是$x,而2224等将是与65关联的$y变量。因此,结果将是一个这样的切片:&[65+22, 65+34]。或者,如果我们计算结果:&[87, 99]

如果我们使用65; [22, 34]; 23; [56, 35]作为输入提供两组变量,在第一次迭代中,$x将是65,而在第二次迭代中,它将是2364$y变量将是2234,如前所述,与23关联的变量将是5635。这意味着最终的切片将是&[87, 99, 79, 58],其中8799与之前相同,而7958是添加23562335的扩展。

这比函数提供了更多的灵活性,但请记住,所有这些都会在编译时展开,这可能会使你的编译时间更长,如果使用的宏重复了太多代码,最终的代码库会更大且运行得更慢。无论如何,它还有更多的灵活性。

到目前为止,所有变量都为expr类型。我们通过声明$x:expr$y:expr来使用它,但正如你可以想象的,还有其他类型的宏变量。列表如下:

  • expr:可以在等号后书写的表达式,例如76+4if a==1 {"something"} else {"other thing"}

  • ident:一个标识符或绑定名称,例如foobar

  • path:一个合格路径。这将是一个你可以在使用句子中写的路径,例如foo::bar::MyStructfoo::bar::my_func

  • ty:一种类型,例如u64MyStruct。它也可以是类型的路径。

  • pat:可以在等号左侧或匹配表达式中书写的模式,例如Some(t)(a, b, _)

  • stmt: 一个完整的语句,例如一个 let 绑定,如 let a = 43;

  • block: 一个可以包含多个语句并在花括号之间有可选表达式的块元素,例如 {vec.push(33); vec.len()}

  • item: Rust 所称的 items。例如,函数或类型声明、完整的模块或特质定义。

  • meta: 一个元元素,你可以将其写入属性(#[])中。例如,cfg(feature = "foo")

  • tt: 任何最终将被宏模式解析的标记树,这意味着几乎可以是任何东西。这对于创建递归宏非常有用。

如你所想,这些类型的宏变量中有些是重叠的,有些则比其他更具体。使用将在宏的右侧进行验证,在展开时,因为你可能会尝试在一个必须使用表达式的地方使用语句,即使你可能也会使用一个标识符,例如。

还有一些额外的规则,正如我们可以在 Rust 文档中看到的那样(doc.rust-lang.org/book/first-edition/macros.html#syntactic-requirements)。语句和表达式只能由 =>、逗号或分号跟随。类型和路径只能由 =>aswhere 关键字、或任何逗号、=|;:>[{ 跟随。最后,模式只能由 =>ifin 关键字、或任何逗号、=| 跟随。

让我们通过实现一个针对我们可以创建的货币类型的 Mul 特质来将这个概念付诸实践。这是一个我们在创建分形信用数字货币时所做的某些工作的改编示例。在这种情况下,我们将查看 Amount 类型(github.com/FractalGlobal/utils-rs/blob/49955ead9eef2d9373cc9386b90ac02b4d5745b4/src/amount.rs#L99-L102)的实现,它表示货币金额。让我们从基本类型定义开始:

#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Amount {
    value: u64,
}

这个金额可以被最多三位小数整除,但它始终是一个精确值。我们应该能够将一个 Amount 添加到当前的 Amount,或者从中减去。我不会解释这些简单的实现,但有一个实现中宏可以大有帮助。我们应该能够将金额乘以任何正整数,因此我们应该为 u8u16u32u64 类型实现 Mul 特质。不仅如此,我们还应该能够实现 DivRem 特质,但我会省略这些,因为它们稍微复杂一些。你可以在前面链接的实现中查看它们。

Amount 与整数的乘法所做的唯一事情就是将值乘以给定的整数。让我们看看 u8 的简单实现:

use std::ops::Mul;

impl Mul<u8> for Amount {
    type Output = Self;

    fn mul(self, rhs: u8) -> Self::Output {
        Self { value: self.value * rhs as u64 }
    }
}

impl Mul<Amount> for u8 {
    type Output = Amount;

    fn mul(self, rhs: Amount) -> Self::Output {
        Self::Output { value: self as u64 * rhs.value }
    }
}

如您所见,我两种方式都实现了,这样您可以将Amount放在乘法符号的左边或右边。如果我们必须对所有整数都这样做,那将是一种时间和代码的巨大浪费。而且,如果我们必须修改其中一个实现(特别是对于Rem函数),在多个代码点上进行修改将会很麻烦。让我们使用宏来帮助我们。

我们可以定义一个宏,impl_mul_int!{},它将接收一个整数类型的列表,然后在这些类型和Amount类型之间实现双向的Mul特质。让我们看看:

macro_rules! impl_mul_int {
    ($($t:ty)*) => ($(
        impl Mul<$t> for Amount {
            type Output = Self;

            fn mul(self, rhs: $t) -> Self::Output {
                Self { value: self.value * rhs as u64 }
            }
        }

        impl Mul<Amount> for $t {
            type Output = Amount;

            fn mul(self, rhs: Amount) -> Self::Output {
                Self::Output { value: self as u64 * rhs.value }
            }
        }
    )*)
}

impl_mul_int! { u8 u16 u32 u64 usize }

如您所见,我们特别要求给定的元素是类型,然后我们为所有这些元素实现特质。所以,对于您想要为多个类型实现的所有代码,您不妨尝试这种方法,因为它可以节省您大量的代码,并使其更易于维护。

创建过程宏

我们已经看到了标准宏可以为我们的 crate 做什么。我们可以创建复杂的编译时代码,既可以减少我们代码的冗长性,使其更易于维护,还可以通过在编译时而不是在运行时执行操作来提高最终可执行文件的性能。

然而,标准宏只能做到如此。使用它们,您只能修改一些 Rust 语法标记处理,但您仍然受限于macro_rules!{}宏所能理解的内容。这就是过程宏(也称为宏 1.1 或自定义派生)发挥作用的地方。

使用过程宏,您可以为编译器在结构体或枚举中派生其名称时调用的库创建库。您可以有效地创建一个自定义特质,derive

实现一个简单的特质

让我们看看如何通过为结构体或枚举实现一个简单的特质来实现这一点。我们将要实现的特质如下:

trait TypeName {
    fn type_name() -> &'static str;
}

这个trait应该返回当前结构体或枚举的名称作为字符串。手动实现它非常简单,但手动逐个为我们的类型实现它没有意义。最好的做法是使用过程宏来派生它。让我们看看我们如何做到这一点。

首先,我们需要创建一个库 crate。按照惯例,它应该有父 crate 的名称,然后是-derive后缀。在这种情况下,我们没有为 crate 命名,但让我们称这个库为type-name-derive

cargo new type-name-derive

这应该在src/文件夹旁边创建一个新的文件夹,命名为type-name-derive。现在我们可以将其添加到Cargo.toml文件中:

[dependencies]
type-name-derive = { path = "./type-name-derive" }

在我们的 crate 的main.rs文件中,我们需要添加 crate 并使用其宏:

#[macro_use]
extern crate type_name_derive;

trait TypeName {
    fn type_name() -> &'static str;
}

现在,让我们开始实际的派生开发。我们需要使用两个 crate——synquote。我们将把它们添加到type-name-derive目录内的Cargo.toml文件中:

[dependencies]
syn = "0.12.10"
quote = "0.4.2"

syn crate 为我们提供了一些有用的类型和函数,用于处理 Rust 的源树和标记流。我们需要这个,因为我们的所有宏都将看到有关我们结构体或枚举源代码的大量信息。我们需要解析它并从中获取信息。syn crate 是一个Nom解析器,它将 Rust 源代码转换为我们可以轻松使用的东西。

quote crate 为我们提供了quote!{}宏,我们将使用它来直接实现实现代码的源代码。它基本上允许我们编写几乎正常的 Rust 代码,而不是编译器标记来实现特性。

Cargo.toml文件中,我们还需要包含其他一些额外信息。我们需要通知 Cargo 和 Rust 编译器,这个 crate 是一个过程宏 crate。为此,我们需要在文件中添加以下内容:

[lib]
proc-macro = true

然后,我们需要从./type-name-derive/src/lib.rs文件的基本架构开始:

extern crate proc_macro;
extern crate syn;
#[macro_use]
extern crate quote;

use proc_macro::TokenStream;

#[proc_macro_derive(TypeName)]
pub fn type_name(input: TokenStream) -> TokenStream {
    // some code
}

如您所见,我们首先导入了所需的定义。proc_macro crate 是编译器内置的,为我们提供了TokenStream类型。此类型表示 Rust 标记(源文件中的字符)的流。然后我们导入了synquote crate。如我们之前所见,我们需要使用 quote crate 中的quote!{}宏,因此我们也导入了宏。

实现自定义 derive 的语法非常简单。我们需要定义一个具有proc_macro_derive属性值的函数,该值是我们想要派生的特性。该函数将获取标记流的拥有权,并返回另一个(或相同的)标记流,以便编译器可以稍后处理新生成的 Rust 代码。

为了实现这个特性,我更喜欢将标记解析和实际的特性实现分为两个函数。为此,让我们首先在我们的type_name()函数中编写代码:

    // Parse the input tokens into a syntax tree
    let input = syn::parse(input).unwrap();

    // Build the output
    let expanded = impl_type_name(&input);

    // Hand the output tokens back to the compiler
    expanded.into()

标记流首先被转换为DeriveInput结构。此结构包含从输入标记流正确解析的数据;反序列化到此处的信息,我们将添加#[derive]属性。在生产环境中,这些unwrap()函数可能需要更改为expect(),这样我们就可以在出错时添加一些文本。

之后,我们使用这些信息来调用我们尚未定义的impl_type_name()函数。该函数将接受有关结构体或枚举的信息,并返回一系列标记。由于我们将使用 quote crate 来创建这些标记,因此它们需要稍后转换为 Rust 编译器标记流并返回给编译器。

现在让我们实现impl_type_name()函数:

fn impl_type_name(ast: &syn::DeriveInput) -> quote::Tokens {
    let name = &ast.ident;
    quote! {
        impl TypeName for #name {
            fn type_name() -> &'static str {
                stringify!(#name)
            }
        }
    }
}

如您所见,这个实现非常简单。我们从高级源树AST)中获取结构的名称,然后在quote!{}宏中使用它,在那里我们为具有该名称的结构或枚举实现TypeName宏。stringify!()宏是一个本地的 Rust 宏,它获取一个标记并返回其编译时字符串表示形式。在这种情况下,是name

让我们通过在我们的父 crate 的main()函数中添加一些代码并添加几个将派生我们实现的类型来测试一下这行是否有效:

#[derive(TypeName)]
struct Alice;

#[derive(TypeName)]
enum Bob {}

fn main() {
    println!("Alice's name is {}", Alice::type_name());
    println!("Bob's name is {}", Bob::type_name());
}

如果您执行cargo run,您将看到以下输出,正如预期的那样:

Alice's name is Alice
Bob's name is Bob

实现复杂派生

synquote crate 允许进行非常复杂的派生。不仅如此,我们不一定需要派生一个特质;我们可以为给定的结构或枚举实现任何类型的代码。这意味着我们可以派生构建器模式,正如我们在上一章中看到的,这实际上会为我们的结构创建一个新的结构或GettersSetters

这就是我们接下来将要做的。我们将创建结构并给它们一些方法来getset结构的字段。在 Rust 中,惯例是Getters应该有字段的名字,没有前缀或后缀,如get。所以对于名为foo的字段,Getter应该是foo()。对于可变的Getters,函数应该有_mut后缀,所以在这种情况下它将是foo_mut()。设置器应该以set_前缀开头,所以对于名为bar的字段,它应该是set_bar()

让我们从创建这个新的getset-derive过程宏 crate 开始。和之前一样,记得在Cargo.toml文件的[lib]部分中将proc_macro变量设置为 true,并将synquote crate 作为依赖项添加。

实现获取器

我们将添加两个派生,GettersSetters。我们将从第一个开始,创建所需的模板代码:

extern crate proc_macro;
extern crate syn;
#[macro_use]
extern crate quote;

use proc_macro::TokenStream;

#[proc_macro_derive(Getters)]
pub fn derive_getters(input: TokenStream) -> TokenStream {
    // Parse the input tokens into a syntax tree
    let input = syn::parse(input).unwrap();

    // Build the output
    let expanded = impl_getters(&input);

    // Hand the output tokens back to the compiler
    expanded.into()
}

fn impl_getters(ast: &syn::DeriveInput) -> quote::Tokens {
    let name = &ast.ident;
    unimplemented!()
}

我们不仅需要结构的名称,还需要添加到结构中的任何进一步泛型以及where子句。在先前的例子中我们没有处理这些,但在更复杂的例子中我们应该添加它们。幸运的是,syn crate 为我们提供了所有需要的东西。

让我们在impl_getters()函数中编写下一部分代码:

fn impl_getters(ast: &syn::DeriveInput) -> quote::Tokens {
    use syn::{Data, Fields};

    let name = &ast.ident;
    let (impl_generics, ty_generics, where_clause) =
                            ast.generics.split_for_impl();

    match ast.data {
        Data::Struct(ref structure) => {
            if let Fields::Named(ref fields) = structure.fields {
                let getters: Vec<_> =
                                fields.named.iter()
                                            .map(generate_getter)
                                            .collect();

                quote! {
                    impl #impl_generics #name #ty_generics
                        #where_clause {
                        #(#getters)*
                    }
                }
            } else {
                panic!("you cannot implement getters for unit \
                        or tuple structs");
            }
        },
        Data::Union(ref _union) => {
            unimplemented!("sorry, getters are not implemented \
                            for unions yet");
        }
        Data::Enum(ref _enum) => {
            panic!("you cannot derive getters for enumerations");
        }
    }
}

fn generate_getter(field: &syn::Field) -> quote::Tokens {
    unimplemented!("getters not yet implemented")
}

这里有很多事情在进行。首先,如您所见,我们从ast.generics字段中获取泛型,并在quote!宏中使用它们。然后我们检查我们有什么类型的数据。我们不能为枚举、单元结构或没有命名字段的结构(如Foo(T))实现获取器或设置器,所以在这些情况下我们会 panic。尽管目前仍然无法为联合派生任何东西,但我们可以使用syn crate 特别过滤选项,所以我们只是添加它以供语言未来的潜在更改。

在具有命名字段的结构的情形下,我们得到一个字段列表,并为每个字段实现一个 getter。为此,我们将它们映射到在底部定义但尚未实现的generate_getter()函数。

一旦我们有了 getter 列表,我们就调用 quote 的!{}宏来生成令牌。正如你所见,我们为impl块添加了泛型,这样如果结构体中有任何泛型,例如Bar<T, F, G>,它们就会被添加到实现中。

要添加从向量中获取的所有 getter,我们使用#(#var)*语法,与macro_rules!{}宏类似,它会依次添加。我们可以使用这种语法与任何实现了IntoIterator特质的类型一起使用,在这种情况下,是Vec<quote::Tokens>

因此,现在我们必须实际实现一个 getter。我们有generate_getter()函数,它接收一个syn::Field,因此我们有了所需的所有信息。该函数将返回quote::Tokens,因此我们需要在内部使用quote!{}宏。如果你一直在跟随,你可以自己实现它,通过检查syn包的文档在docs.rs。让我们看看完全实现后的样子:

fn generate_getter(field: &syn::Field) -> quote::Tokens {
    let name = field.ident
                .expect("named fields must have a name");
    let ty = &field.ty;

    quote! {
        fn #name(&self) -> &#ty {
            &self.#name
        }
    }
}

如你所见,这真的很简单。我们获取属性或字段的标识符或名称,鉴于我们只为具有命名字段的结构体实现它,这个名称应该存在,然后获取字段的类型。然后我们创建 getter 并返回内部数据的引用。

我们可以通过添加对具有借用对应类型的异常处理来进一步改进,例如StringPathBuf,分别返回&strPath,但我认为这并不值得。

我们还可以将字段的文档添加到生成的 getter 中。为此,我们将使用field.attrs变量并获取名为doc的属性的值,这是包含文档文本的属性。然而,这并不那么容易,因为属性的名称被存储为路径,我们需要将其转换为字符串。但我邀请你尝试使用syn包的文档。

实现 setter

本练习的第二部分将是实现我们结构体的 setter。通常,这是通过为每个字段创建一个set_{field}()函数来完成的。此外,使用泛型是常见的做法,这样它们就可以与许多不同的类型一起使用。例如,对于String字段,如果我们不需要总是使用实际的String类型,而是可以使用&strCow<_, str>Box<str>,那就太好了。

我们只需要将输入声明为Into<String>。这使得事情变得稍微复杂一些,但我们的 API 看起来会更好。要实现新的 setter,只需稍微修改一下我们之前看到的代码,大部分代码都会被复制。

为了避免这种情况,我们将使用策略模式,这样我们只需简单地将generate_getter()函数改为generate_setter(),每个字段一个。我还将字段检索移动到了一个新的函数中。让我们看看它看起来如何:

use syn::{DeriveInput, Fields, Field, Data};
use syn::punctuated::Punctuated;
use syn::token::Comma;

fn get_fields(ast: &DeriveInput) -> &Punctuated<Field, Comma> {
    match ast.data {
        Data::Struct(ref structure) => {
            if let Fields::Named(ref fields) = structure.fields {
                &fields.named
            } else {
                panic!("you cannot implement setters or getters \
                        for unit or tuple structs");
            }
        },
        Data::Union(ref _union) => {
            unimplemented!("sorry, setters and getters are not \
                            implemented for unions yet");
        }
        Data::Enum(ref _enum) => {
            panic!("you cannot derive setters or getters for \
                    enumerations");
        }
    }
}

好多了,对吧?它返回一个字段迭代器,这正是我们函数所需要的。我们现在创建一个方法实现函数,它将接收一个函数作为参数,并将用于每个字段:

fn impl_methods<F>(ast: &DeriveInput, strategy: F) -> Tokens
where F: FnMut(&Field) -> Tokens {
    let methods: Vec<_> = get_fields(ast).iter()
                                         .map(strategy)
                                         .collect();
    let name = &ast.ident;
    let (impl_generics, ty_generics, where_clause) =
                            ast.generics.split_for_impl();

    quote! {
        impl #impl_generics #name #ty_generics #where_clause {
            #(#methods)*
        }
    }
}

如你所见,除了对绑定进行小的命名更改以使意义更清晰之外,唯一的重大变化是在函数的where签名中添加了一个FnMut,这将作为获取器或设置器的实现者。因此,为了调用这个新函数,我们需要更改derive_getters()方法并添加新的derive_setters()函数:

#[proc_macro_derive(Getters)]
pub fn derive_getters(input: TokenStream) -> TokenStream {
    // Parse the input tokens into a syntax tree
    let input = syn::parse(input).unwrap();

    // Build the output
    let expanded = impl_methods(&input, generate_getter);

    // Hand the output tokens back to the compiler
    expanded.into()
}

#[proc_macro_derive(Setters)]
pub fn derive_setters(input: TokenStream) -> TokenStream {
    // Parse the input tokens into a syntax tree
    let input = syn::parse(input).unwrap();

    // Build the output
    let expanded = impl_methods(&input, generate_setter);

    // Hand the output tokens back to the compiler
    expanded.into()
}

如你所见,这两种方法完全相同,只是在调用impl_methods()函数时,它们使用了不同的策略。第一个将生成获取器,第二个生成设置器。最后,让我们看看generate_setters()函数将是什么样子:

use syn::Ident;

fn generate_setter(field: &Field) -> Tokens {
    let name = field.ident
                .expect("named fields must have a name");
    let fn_name = Ident::from(format!("set_{}", name));
    let ty = &field.ty;

    quote! {
        fn #fn_name<T>(&mut self, value: T) where T: Into<#ty> {
            self.#name = value.into();
        }
    }
}

代码在大多数方面与generate_getter()函数类似,但有一些不同。首先,函数名与name不同,因为它需要set_前缀。为此,我们创建了一个带有字段名的字符串,并使用该名称创建了一个标识符。

我们通过使用新的函数名,使用可变的self,并向函数添加一个新的输入变量(值)来构建设置器。由于我们希望这个值是通用的,我们使用在 where 子句中定义的T类型,它是一个可以转换为我们字段类型的类型(Into<#ty>)。我们最终将转换后的值赋给我们的字段。

让我们通过在父 crate 的main.rs文件中创建一个简短的示例来查看这个获取器和设置器设置是如何工作的。我们在Cargo.toml文件中添加了过程宏依赖项,并定义了一个结构体:

#[macro_use]
extern crate getset_derive;

#[derive(Debug, Getters, Setters)]
struct Alice {
    x: String,
    y: u32,
}

没有什么特别的地方;除了GettersSetters派生。正如你所见,我们不需要派生一个实际的特质。现在,我们添加一个简单的main()函数来测试代码:

fn main() {
    let mut alice = Alice {
        x: "this is a name".to_owned(),
        y: 34
    };
    println!("Alice: {{ x: {}, y: {} }}",
             alice.x(),
             alice.y());

    alice.set_x("testing str");
    alice.set_y(15u8);
    println!("{:?}", alice);
}

我们创建了一个新的Alice结构体,并在其中设置了两个字段。当打印这个结构体时,我们可以看到可以直接使用Alice::x()Alice::y()获取器。注意,双大括号用于转义。

然后,由于我们有一个可变变量,我们使用设置器来改变xy字段的值。正如你所见,我们不必提供Stringu32;我们可以提供任何可以直接转换为这些类型而不会失败的类型。最后,由于我们为Alice实现了Debug特质,我们可以不使用获取器就打印其内容。执行cargo run后的结果应该是以下内容:

Alice: { x: this is a name, y: 34 }
Alice { x: "testing str", y: 15 }

过程宏或自定义派生允许进行非常复杂的代码生成,并且您甚至可以进一步自定义用户体验。正如我们在上一章中看到的,使用serde包,我们可以使用#[serde]属性。您可以通过在#[proc_macro_derive]属性中定义它们来向您的派生包添加自定义属性,如下所示:

#[proc_macro_derive(Setters, attributes(generic))]

然后,您可以通过检查结构体/枚举或其字段在FieldDeriveInputVariantFieldValue结构中的attrs字段来使用它们。例如,您可以允许开发者决定他们是否希望在设置器中使用泛型,或者微调应该泛型的属性。

更多信息可以在官方文档中找到,请参阅doc.rust-lang.org/book/first-edition/procedural-macros.html#custom-attributes

夜间 Rust 中的元编程

到目前为止,我们一直停留在稳定版 Rust 中,因为它允许向前兼容。尽管如此,也有一些夜间功能可以帮助我们提高对我们生成的代码的控制。尽管如此,它们都是实验性的,它们在稳定之前可能会改变或甚至被删除。

因此,您应该考虑到使用夜间功能可能会在未来破坏您的代码,并且为了与新的 Rust 版本兼容,维护它将需要更多的努力。尽管如此,我们将快速查看即将加入 Rust 的两个新功能。

理解编译器插件

Rust 夜间编译器接受加载扩展,称为插件。它们实际上改变了编译器的行为,因此它们可以修改语言本身。插件是一个 crate,类似于我们之前创建的过程宏 crate。

过程宏或标准宏与插件之间的区别在于,虽然前两者修改了它们所提供的 Rust 代码,但插件能够执行额外的计算,这可以大大提高 crate 的性能。

由于这些插件在编译器内部加载,它们将能够访问标准宏没有的大量信息。此外,这需要使用rustc编译器和libsyntax库作为外部 crate,这意味着在编译插件时,您将加载大量的编译器代码。

因此,不要将您的插件作为外部 crate 添加到您的二进制文件中,因为这将创建一个包含大量编译器代码的巨大可执行文件。要使用不作为库添加的插件,您需要夜间编译器,并且必须将#![feature(plugin)]#![plugin({plugin_name})]属性添加到您的 crate 中。

当开发一个新插件时,您需要在Cargo.toml文件中创建带有一些额外信息的 crate,就像我们为过程宏所做的那样:

[lib]
plugin = true

然后,在 lib.rs 文件中,你需要导入所需的库并定义插件注册器。插件正常工作的最低要求如下:

#![crate_type="dylib"]
#![feature(plugin_registrar, rustc_private)]

extern crate syntax;
extern crate syntax_pos;
extern crate rustc;
extern crate rustc_plugin;

use rustc_plugin::Registry;

#[plugin_registrar]
pub fn plugin_registrar(reg: &mut Registry) {
    unimplemented!()
}

尽管在这个例子中,rustcsyntaxsyntax_pos crate 没有被使用,但在开发插件时,你几乎肯定会需要它们,因为它们提供了你改变任何行为所需所需的类型。Registry 对象允许你注册多种新的语言项,例如宏或语法扩展。对于每一个,你都需要定义一个函数来接收编译器标记并修改它们以产生所需的结果。

#![crate_type = "dylib"] 属性告诉编译器使用 crate 创建动态库,而不是正常的静态库。这使得库可以被编译器加载。plugin_registrar 夜间功能允许我们创建实际的插件注册器函数,而 rustc_private 功能允许我们使用私有的 Rust 编译器类型,这样我们就可以使用编译器内部功能。

在撰写本文时,这些 crate 的唯一在线文档是由 Manish Goregaokar 提供的,但这种情况很快就会改变。在他的网站上,你可以找到 rustc_plugin 的 API 文档(manishearth.github.io/rust-internals-docs/rustc_plugin/index.html)、rustc 的 API 文档(manishearth.github.io/rust-internals-docs/rustc/index.html)、syntax 的 API 文档(manishearth.github.io/rust-internals-docs/syntax/index.html)和 syntax_pos 的 API 文档(manishearth.github.io/rust-internals-docs/syntax_pos/index.html)。我邀请你阅读这些 crate 的 API,并为编译器创建小型插件。不过,请记住,语法可能会改变,这会使维护变得更加困难。

声明式宏

接下来即将加入 Rust 的功能是声明式宏,即宏 2.0 或示例宏。确实,有些人也将标准宏称为声明式宏,因为它们基于相同的原则。但我希望让大家知道这种区别,以便了解这些新宏将为语言带来的改进。

这些新宏引入了 macro 关键字,其工作方式将与 macro_rules!{} 宏类似,但使用的语法更接近函数语法,而不是当前的语法。不仅如此,它还将模块化引入宏中,这样你就可以在同一个 crate 中拥有两个同名宏,只要它们位于不同的模块中。这种额外的模块化将使 crate 之间的集成变得更加容易。

很遗憾,这些宏还没有提出语法建议,当前 nightly 的实现也不过是未来将要到来的内容的占位符。我邀请你关注这些新宏的标准化进程,甚至通过为社区做出贡献来定义它们的未来语法。

摘要

在本章中,你学习了如何创建自己的宏。首先,我们了解了标准宏,并创建了一些可以帮助你更快地开发并编写更高效代码的宏。然后,你学习了过程宏以及如何为你自己的结构和枚举推导出代码。最后,你发现了两个可能在未来稳定版 Rust 中出现的特性,目前可以在 nightly Rust 中使用——插件和声明式宏。

在接下来的两个章节中,我们将讨论 Rust 中的并发。正如你将看到的,一旦我们的单线程代码足够快,向更快执行迈出的下一步就是在并行中计算它。

第十章:多线程

到目前为止,我们已经看到了如何通过优化我们编码的各个方面来使我们的代码越来越快,但还有一个优化点:让我们的代码并行工作。在本章中,您将通过使用线程来处理您的数据,了解 Rust 中如何实现无畏并发

在本章中,您将学习以下内容:

  • SendSync特质——Rust 是如何实现内存安全的?

  • Rust 中的基本线程——创建和管理线程

  • 在线程之间移动数据

  • 使多线程更容易更快的工具包

Rust 中的并发

很长时间以来,在计算机中按顺序执行所有任务都没有意义。当然,有时您需要在其他任务之前执行某些任务,但在大多数实际应用中,您将希望并行运行一些任务。

例如,您可能想响应 HTTP 请求。如果您一个接一个地处理,整体服务器将会很慢。特别是当您每秒收到很多请求,其中一些需要时间来完成时。您可能想在完成当前请求之前开始响应其他请求。

此外,现在几乎在每台计算机或服务器上,甚至在大多数手机上,我们都有多个处理器。这意味着我们不仅可以在主任务空闲时并行处理其他任务,而且我们可以通过使用线程为每个任务真正使用一个处理器。这是我们在开发高性能应用程序时必须利用的优势功能。

并发的主要问题是它很难。我们不习惯于并行思考,作为程序员,我们会犯错误。我们只需要检查一下我们最常用的系统中的某些安全漏洞或错误,这些系统是由最伟大的程序员开发的,就可以看到要正确地做到这一点是多么困难。

有时,我们试图更改一个变量,却忘记了另一个任务可能正在读取它,甚至同时更改它。想象一下 HTTP 示例中的请求计数器。如果我们把负载分开给两个处理器,并且每个处理器都收到一个请求,共享的计数器应该增加两个,对吧?

每个线程都想给计数器加 1。为此,它们在 CPU 中加载当前的计数器,给它加一,然后再将其保存到 RAM 中。这需要一些时间,尤其是从 RAM 中加载,这意味着如果它们同时加载计数器,它们都会在 CPU 中有当前的计数器。

如果两个处理器都给计数器加一并保存它,RAM 中的值只会增加一个请求,而不是两个,因为两个处理器都会将新的+1值保存到 RAM 中。这就是我们所说的数据竞争。有一些工具可以避免这种行为,例如原子变量、信号量和互斥锁,但我们有时会忘记使用它们。

Rust 中最知名的功能之一是无畏并发。这意味着只要我们使用安全的 Rust,我们就应该无法创建数据竞争。这解决了我们的问题,但他们是如何做到的呢?

理解 SendSync 特性

使这一切工作起来的秘密成分是 SendSync 特性。它们是编译器所知的特性,因此编译器会检查我们是否想要使用它们来实现类型,并相应地行动。你不能直接为你自己的类型实现 SendSync。在结构体或枚举体具有字段的情况下,编译器将通过检查包含的字段是否为 SyncSend 来知道你的类型是否是 SendSync

让我们了解它们是如何工作的。首先,你应该注意,SendSync 特性都不会向给定的类型添加方法。这意味着一旦编译,它们不会占用任何内存或给你的二进制文件添加任何额外的开销。它们只会在编译时进行检查,以确保多线程是安全的。除非你使用一个不安全的块,否则你不能直接为你自己的类型实现 SendSync,因此编译器会在适当的地方为你完成这项工作。

Send 特性

实现了 Send 特性的结构体可以在线程之间安全地移动。这意味着你可以在线程之间安全地转移 Send 类型的所有权。标准库为那些实际上可以跨越线程边界的类型实现了 Send,如果您的类型也可以在线程之间移动,编译器会自动为您实现它。如果一个类型仅由 Send 类型组成,它也将是一个 Send 类型。

标准库中的大多数类型都实现了 Send 特性。例如,你可以安全地将 u32 的所有权移动到另一个线程。这意味着之前的线程将无法再次使用它,而新的线程将负责在它超出作用域时将其丢弃。

尽管如此,也有一些例外。原始指针不能安全地移动到另一个线程,因为它们没有安全保护。你可以多次复制一个原始指针,可能会发生一个到达一个线程而另一个留在当前线程的情况。如果两个线程同时尝试操作同一内存,将会产生未定义的行为。

另一个例外是引用计数指针或 Rc 类型。这种类型可以轻松高效地创建指向给定内存位置的共享指针。由于该类型本身有一些内存保证,确保如果存在可变借用,则不能进行其他借用,并且如果存在一个或多个不可变借用,则不能进行可变借用,因此它是安全的。指针所指向的信息将在最后一个引用超出作用域时同时被丢弃。

这是通过有一个计数器来实现的,每次通过调用 clone() 方法创建引用时,计数器增加 1,一旦引用被丢弃,计数器减去 1。你可能已经意识到了在线程之间共享时可能出现的问题:如果两个线程同时丢弃引用,引用计数可能只会减去 1。这意味着当最后一个引用被丢弃时,计数器不会为零,它不会丢弃 Rc,从而造成内存泄漏。

由于 Rust 不允许内存泄漏,Rc 类型不是 Send。有一个等效的共享指针可以在线程之间共享,即原子引用计数的指针或 Arc。这种类型确保对引用计数的每次增加或减少都是在原子操作中完成的,因此如果新线程想要增加或减少一个引用,它将需要等待其他线程完成更新那个计数器。这使得它是线程安全的,但由于需要执行的检查,它将比 Rc 慢。所以,如果你不需要将引用发送到另一个线程,你应该使用 Rc

Sync 特征

与之相反,Sync 特征代表一种可以在线程之间共享的类型。这指的是实际上共享变量而不将其所有权转移到新线程。

Send 特征一样,原始指针和 Rc 不是 Sync,但还有一个类型家族实现了既不是 Send 也不是 SyncCell 可以安全地在线程之间传递,但不能共享。让我们回顾一下 Cell 的工作原理。

可以在 std::cell 模块中找到的单元格是一个将包含一些内部数据的容器。这些数据将是另一种类型。单元格用于内部可变性,但那是什么意思呢?内部可变性是更改变量内容而不使其可变的选择。这听起来可能有些反直觉,尤其是在 Rust 中,但这是可能的。

两种安全的单元格类型是 CellRefCell。前者通过在 Cell 中移动值来实现内部可变性。这意味着你将能够在单元格中插入新值,或者如果它是 Copy 类型,则获取当前单元格的值,但如果你使用的是复杂类型,如向量或 HashMap,则无法使用其可变方法。这对于像整数这样的小型类型很有用。Rc 将使用 Cell 来存储引用计数,这样你就可以在非可变的 Rc 上调用 clone() 方法,同时更新引用计数。让我们看一个例子:

use std::cell::Cell;

fn main() {
    let my_cell = Cell::new(0);
    println!("Initial cell value: {}", my_cell.get());

    my_cell.set(my_cell.get() + 1);
    println!("Final cell value: {}", my_cell.get());
}

注意,my_cell 变量是不可变的,但程序仍然可以编译,输出如下:

图片

RefCell 做类似的事情,但它可以与任何类型的类型一起使用,如果没有其他引用指向它,你可以获取其内部值的可变引用。当然,它内部使用不安全代码,因为 Rust 不允许这样做。为了使其工作,它有一个标志让 RefCell 知道它是否当前被借用。如果它被用于读取而借用,可以使用 borrow() 方法生成更多只读借用,但不能进行可变借用。如果使用 borrow_mut() 方法进行可变借用,你将无法以可变或不可变的方式借用它。

这两种方法将在运行时检查当前的借用状态,而不是在编译时,这是 Rust 规则的标准做法,如果当前状态不正确,它们将引发恐慌。它们有非恐慌的替代方法,名为 try_borrow()try_borrow_mut()。由于所有检查都是在运行时完成的,它们将比常规的 Rust 规则慢,但允许这种内部可变性。让我们看一个例子:

use std::cell::RefCell;
use std::collections::HashMap;

fn main() {
    let hm = HashMap::new();
    let my_cell = RefCell::new(hm);
    println!("Initial cell value: {:?}", my_cell.borrow());

    my_cell.borrow_mut().insert("test_key", "test_value");
    println!("Final cell value: {:?}", my_cell.borrow());
}

再次提醒,my_cell 变量是不可变的,但这段代码仍然可以编译,并且我们可以获得对其的可变借用,这允许我们在哈希表中插入一个新的键/值对。预期的输出如下:

另一方面,由于借用标志不是线程安全的,整个 RefCell 结构将不会是 Sync。你可以安全地将单元格的完整所有权发送到新线程,但不能有对其的共享引用。如果你想更好地理解 Rc、Cells 和 RefCells 的工作原理,我们已经在 第三章 中讨论了它们,Rust 的内存管理

存在着线程安全的替代方案,允许内部可变性,称为 Mutexes。Mutex 将守卫存储在一个实际的系统 Mutex 中,在访问数据之前同步线程。这使得它成为 Sync,但同时也更慢。我们将在本章中看到它们是如何工作的。

Rust 中的其他并发类型

在 Rust 以及许多其他语言中,还有其他实现并行计算的方法。在本章中,我们将讨论多线程,其中每个线程都可以访问共享内存,并创建自己的堆栈,以便它可以独立工作。理想情况下,你应该有大约与你的 PC/服务器中虚拟 CPU 数量相同的线程同时工作。

这通常是 CPU 核心数量的两倍,多亏了超线程技术,其中一个核心可以通过使用自己的硬件调度器来同时运行两个线程,以决定在给定时间点运行每个线程的哪些部分。

线程的主要问题在于,如果你不设置限制而运行太多线程,可能是因为其中一些线程是空闲的,而你的 CPU 应该能够运行其他线程,你将消耗大量的 RAM。这是因为每个线程都需要创建所有这些栈。一些 Web 服务器为每个请求创建一个线程并不罕见。当负载高时,这会使事情变得非常慢,因为它需要大量的 RAM。

并发性的另一种方法是异步编程。Rust 在这方面有很好的工具,我们将在下一章中看到它们。异步编程带来的最佳改进是允许一个线程在不会阻塞实际线程的情况下运行多个 I/O 请求。

不仅如此,如果线程空闲,它不需要等待一段时间然后再去轮询新的请求。当有新信息时,底层操作系统会唤醒线程。因此,这种方法将使用尽可能少的资源进行 I/O 操作。

但对于那些不需要 I/O 的程序呢?在这种情况下,事情可以比使用线程更并行地执行。如今的大多数处理器都允许向量化。向量化使用一些特殊的 CPU 指令和寄存器,你可以输入多个变量,同时在这些变量上执行相同的操作。这对于高性能计算非常有用,在那里你需要多次将某种算法应用于不同的数据集。采用这种方法,你可以同时执行多个加法、减法、乘法和除法。

用于向量化的特殊指令被称为SIMD系列,源自单指令多数据。你可以在夜间的 Rust 中直接通过asm!{}宏运行汇编,编译器将尝试自动向量化你的代码,尽管这通常不如专业人士手动操作的效果好。2018 年有多项提议稳定 SIMD 内建函数。这样,你将能够使用这些指令,同时从汇编中抽象出来。在fastercrate(crates.io/crates/faster)中正在进行一些努力。

理解多线程

现在我们已经了解了 Rust 中不同的并发方法,我们可以从最基本的方法开始:创建线程。如果你之前使用过 Java 或 C++等语言,你可能会熟悉前者的new Thread()语法或后者的std::thread。在两种情况下,你都需要指定新线程将运行的代码以及线程将拥有的额外信息。在两种情况下,你都可以启动线程并等待它们完成。

创建线程

在 Rust 中,与 C++ 方法类似,我们有一个带有 spawn() 函数的 std::thread 模块。这个函数将接收一个闭包或指向函数的指针并执行它。它将返回一个线程句柄,我们将能够从外部管理它。让我们看看它是如何工作的:

use std::thread;

fn main() {
    println!("Before the thread!");

    let handle = thread::spawn(|| {
        println!("Inside the thread!");
    });
    println!("After thread spawn!");

    handle.join().expect("the thread panicked");
    println!("After everything!");
}

这将输出类似以下内容:

线程内!线程启动后! 消息可以在理论上以任何顺序排列,尽管在这个简单的例子中很容易看出启动线程将比在屏幕缓冲区中打印花费更多时间。

然而,这个例子展示了如何与线程一起工作的宝贵信息。首先,当打印出 线程之前! 消息时,只有一个线程正在执行:主线程,运行 main() 函数。

然后,我们使用 std::thread::spawn() 函数启动一个新的线程,并向它传递一个简单的闭包。这个闭包将只会在控制台打印 线程内! 消息。这发生在 线程启动后! 消息打印的同时。实际上,在某些编程语言中,你可能会看到两个消息的字符混合在一起,最终的消息只是一堆难以理解的字符。

Rust 通过仅使用 Mutex 访问标准输出文件描述符来避免这种情况。println!() 宏会在写入消息时锁定 stdout,如果新的消息想要被写入,它将必须等待第一次写入完成。

这既有优点也有缺点。作为一个明显的优点,打印的消息清晰可读,因为线程之一(主线程或第二个线程)总是会先于另一个到达。另一方面,这意味着当第二个线程等待第一个线程在屏幕上完成打印时,它将被阻塞,无法进行任何计算。

你需要确保考虑到这一点,在执行计算时不要从许多线程中频繁打印。实际上,由于 Rust 是一个线程安全的语言,这将在任何共享资源上发生,因此你需要小心避免开销。

你可能会认为这是一个对性能不好的方法,因为它会使事情变慢,但实际上,如果需要保留数据的完整性,这是唯一可能的方法。在其他语言中,你可能需要自己实现解决方案,或者显式地使用现有解决方案来避免内存损坏。

在示例代码的末尾之前,我们可以看到我们调用了线程句柄中的 join() 方法。这将使当前线程等待另一个线程完成。你可能注意到我在它之后添加了对 expect() 方法的调用。这是因为 join() 方法返回一个 Result,因为它可能在完成之前崩溃。

Rust 中的恐慌

让我们首先了解什么是线程恐慌。你可能已经知道,你可以通过在OptionResult中调用unwrap()expect()方法,甚至直接调用panic!()来引发恐慌。有几种引发恐慌的方式:unimplemented!()宏会引发恐慌,让用户知道该功能尚未实现,assert!()宏家族如果条件不满足也会引发恐慌,并且超出范围的切片索引也会引发恐慌,但,什么是恐慌?

当谈论单线程应用程序时,你可能会认为恐慌就像带错误退出程序一样,类似于 C/C++中的exit()函数。可能对你来说听起来很新颖的是,恐慌是发生在线程级别上的事情。如果主线程发生恐慌,整个程序会退出,但如果非主线程发生恐慌,你可以从中恢复。

但是,恐慌真的只是简单的程序结束吗?实际上,它远不止于此。在 C/C++中,当你退出程序时,内存只是归还给内核,然后程序就结束了。然而,Rust 由于它的内存安全保证,确保它调用当前栈中的所有析构函数。这意味着所有变量都将优雅地释放。

这就是所谓的栈回溯,但这不是唯一的选择。正如我们在第一章中解释的,如何在Cargo.toml文件中配置行为,你也可以选择中止恐慌,这将模仿标准 C/C++的行为。

栈回溯恐慌的主要优点当然是,如果你事情搞砸了,你可以执行清理操作。例如,你可以通过在结构体中实现Drop特质来关闭文件、记录最后时刻的日志和更新一些数据库。

然而,正如我们在第一章“常见性能陷阱”中已经提到的,主要缺点是每次我们调用unwrap()expect()方法时,例如,就会出现一个新的分支。要么事情出错,线程发生恐慌,要么事情按预期进行。如果它们发生恐慌,编译器需要添加整个代码进行栈回溯,这使得可执行文件明显变大。

现在你已经了解了恐慌的工作原理,让我们看看我们如何从中恢复:

use std::thread;

fn main() {
    println!("Before the thread!");

    let handle = thread::Builder::new()
        .name("bad thread".to_owned())
        .spawn(|| {
            panic!("Panicking inside the thread!");
        })
        .expect("could not create the thread");
    println!("After thread spawn!");

    if handle.join().is_err() {
        println!("Something bad happened :(");
    }
    println!("After everything!");
}

正如你所见,我添加了一些更多的样板代码。例如,我为线程添加了一个名称,这是一个好的实践,这样我们就可以知道如果出现问题,每个线程叫什么。我将第二个线程内的控制台打印更改为显式的恐慌,然后检查在连接线程时是否有问题。这里重要的是,你绝不应该在连接线程时仅仅调用expect()unwrap(),因为这可能会使你的整个程序失败。

对于这个例子,输出应该类似于以下内容:

当与引发错误的线程一起工作时,有一个额外的技巧。如果你有一个实现了Drop特质的结构体,当引发错误或超出作用域时,将会调用drop()方法。

你可以通过调用std::thread::panicking()函数来找出当前线程是否正在引发错误。让我们看看它是如何工作的:

use std::thread;

struct MyStruct {
    name: String,
}

impl Drop for MyStruct {
    fn drop(&mut self) {
        if thread::panicking() {
            println!("The thread is panicking with the {} struct!", self.name);
        } else {
            println!("The {} struct is out of scope :(", self.name);
        }
    }
}

fn main() {
    let my_struct = MyStruct {
        name: "whole program".to_owned(),
    };

    {
        let scoped_struct = MyStruct {
            name: "scoped".to_owned(),
        };
    }

    let handle = thread::Builder::new()
        .name("bad thread".to_owned())
        .spawn(|| {
            let thread_struct = MyStruct {
                name: "thread".to_owned(),
            };
            panic!("Panicking inside the thread!");
        })
        .expect("could not create the thread");
    println!("After thread spawn!");

    if handle.join().is_err() {
        println!("Something bad happened :(");
    }
    println!("After everything!");
}

首先,让我们看看这段代码的作用。它添加了一个新的MyStruct结构体,其中包含一个名称并实现了Drop特质。然后,它使用whole program名称创建了这个结构体的一个实例。这个结构体将在main()函数的末尾被丢弃。

然后,在一个人工的作用域中,它添加了一个结构体的作用域实例,这个实例将在那个内部作用域的末尾被丢弃。最后,在线程内部,它创建了一个新的结构体,这个结构体应该在线程结束时被丢弃,并且将会被展开。

MyStruct结构的Drop实现使用了std::thread::panicking()函数来检查它是否在引发错误时被丢弃,或者仅仅是因为它超出了作用域。这里是我们这个示例的输出:

如我们所见,第一条信息是内部作用域绑定的丢弃。然后,新的线程被创建,它引发了错误,线程内部的绑定在展开栈时被丢弃。最后,在main()函数的最后一条信息之后,我们最初创建的第一个绑定被丢弃。

在线程间移动数据

我们通过SendSync特质看到了第一个特质允许变量在线程间传递,但它是如何工作的?我们能否在次要线程中使用在主线程中创建的变量?让我们试一试:

use std::thread;

fn main() {
    let my_vec = vec![10, 33, 54];

    let handle = thread::Builder::new()
        .name("my thread".to_owned())
        .spawn(|| {
            println!("This is my vector: {:?}", my_vec);
        })
        .expect("could not create the thread");

    if handle.join().is_err() {
        println!("Something bad happened :(");
    }
}

我们所做的是在线程外部创建一个向量,然后从内部使用它。但看起来它不起作用。让我们看看编译器告诉我们什么:

这很有趣。编译器注意到my_vec绑定将在main()函数的末尾被丢弃,而内部线程可以存活得更久。在我们的例子中并不是这样,因为我们join()了两个线程,在main()函数结束之前,但在一个场景中,一个线程创建更多的线程然后结束自己,这种情况可能会发生。这将使线程内部的引用无效,而 Rust 不允许这种情况发生。

移动关键字

尽管如此,它为我们能做的事情提供了一个很好的解释。我们有两种选择:在线程间共享绑定或将它发送到第二个线程。由于我们不会在主线程中使用它,我们可以在闭包之前添加move关键字并将向量发送到新的线程,因为它实现了Send特质。让我们看看我们如何让它工作:

use std::thread;

fn main() {
    let my_vec = vec![10, 33, 54];

    let handle = thread::Builder::new()
        .name("my thread".to_owned())
        .spawn(move || {
            println!("This is my vector: {:?}", my_vec);
        })
        .expect("could not create the thread");

    if handle.join().is_err() {
        println!("Something bad happened :(");
    }
}

现在代码可以编译,并在控制台显示了数字列表,太棒了!但是,如果我们想在主线程中也能看到它怎么办?在第二个线程启动后尝试打印向量是不行的,因为变量已经被移动到了新的线程,而且我们已经看到如果我们不移动向量,我们无法在线程中使用它。我们该怎么办?

线程间共享数据

我们已经提到过一个可以在线程间共享的特殊引用计数指针:std::sync::Arc。与 Rc 的主要区别在于,Arc 使用原子计数器来计数引用。这意味着内核将确保所有对引用计数的更新都会逐个发生,使其线程安全。让我们用一个例子来看看:

use std::thread;
use std::sync::Arc;

fn main() {
    let my_vec = vec![10, 33, 54];
    let pointer = Arc::new(my_vec);

    let t_pointer = pointer.clone();
    let handle = thread::Builder::new()
        .name("my thread".to_owned())
        .spawn(move || {
            println!("Vector in second thread: {:?}", t_pointer);
        })
        .expect("could not create the thread");

    println!("Vector in main thread: {:?}", pointer);

    if handle.join().is_err() {
        println!("Something bad happened :(");
    }
}

如你所见,向量在第二个线程和主线程中都被使用。你可能想知道指针中的 clone() 是什么意思。我们是克隆向量吗?嗯,那将是一个简单的解决方案,对吧?实际情况是,我们只是得到了向量的新引用。这是因为 Clone 特性在 Arc 中并不是一个普通的克隆。它将返回一个新的 Arc,是的,但它也会增加引用计数。由于两个 Arc 实例都将有相同的指针指向引用计数器和向量,我们将有效地共享这个向量。

如何简单地调试 Arc 内部的向量指针?这是一个有趣的技巧。Arc<T> 实现了 Deref<T>,这意味着在调用调试时,它将自动解引用到它所指向的向量。有趣的是,有两个特性允许这种自动解引用:DerefDerefMut。正如你可能猜到的,前者给你一个包含值的不可变借用,而后者给你一个可变借用。

Arc 只实现了 Deref,而没有实现 DerefMut,所以我们无法修改其内部的内容。但是等等,我们不是有可以保持不可变状态的同时进行修改的 cells 吗?嗯,它们确实存在一个问题。我们从 Arc 看到的能够在线程间共享的行为,仅仅是因为实现了 Sync 特性,而且它只有在内部值实现了 SyncSend 时才会实现它。Cells 可以在线程间传递,它们实现了 Send,但它们没有实现 Sync。另一方面,Vec 实现了内部值的任何实现,所以在这种情况下,它既是 Send 也是 Sync

那么,就这样了吗?我们能在 Arc 内部修改任何东西吗?正如你可能猜到的,情况并非如此。如果我们想在线程间共享的是整数或布尔值,我们可以使用任何 std::sync::atomic 整数和布尔值,即使其中一些还不稳定。它们实现了 Sync,并且它们通过 load()store() 方法具有内部可变性。你只需要指定操作的内存排序即可。让我们看看它是如何工作的:

use std::thread;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

fn main() {
    let my_val = AtomicUsize::new(0);
    let pointer = Arc::new(my_val);

    let t_pointer = pointer.clone();
    let handle = thread::Builder::new()
        .name("my thread".to_owned())
        .spawn(move || {
            for _ in 0..250_000 {
                let cur_value = t_pointer.load(Ordering::Relaxed);
                let sum = cur_value + 1;
                t_pointer.store(sum, Ordering::Relaxed);
            }
        })
        .expect("could not create the thread");

    for _ in 0..250_000 {
        let cur_value = pointer.load(Ordering::Relaxed);
        let sum = cur_value + 1;
        pointer.store(sum, Ordering::Relaxed);
    }

    if handle.join().is_err() {
        println!("Something bad happened :(");
    }

    let a_int = Arc::try_unwrap(pointer).unwrap();
    println!("Final number: {}", a_int.into_inner());
}

如果你多次运行这个程序,你会看到最终数字每次都会不同,而且它们都不会是 500,000(可能会发生,但几乎不可能)。我们有的类似于数据竞争:

但等等,Rust 不能防止所有数据竞争吗?嗯,这并不完全是一个数据竞争。当我们保存整数时,我们没有检查它是否已更改,所以我们覆盖了那里写下的任何内容。我们没有使用 Rust 给予我们的优势。它将确保变量的状态是一致的,但它不会防止逻辑错误。

问题在于当我们存储它回去时,那个值已经改变了。为了避免这种情况,原子操作有伟大的fetch_add()函数及其朋友fetch_sub()fetch_and()fetch_or()fetch_xor()。它们将完整操作原子化。它们还有伟大的compare_and_swap()compare_exchange()函数,可以用来创建锁。让我们看看这将如何工作:

use std::thread;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

fn main() {
    let my_val = AtomicUsize::new(0);
    let pointer = Arc::new(my_val);

    let t_pointer = pointer.clone();
    let handle = thread::Builder::new()
        .name("my thread".to_owned())
        .spawn(move || {
            for _ in 0..250_000 {
                t_pointer.fetch_add(1, Ordering::Relaxed);
            }
        })
        .expect("could not create the thread");

    for _ in 0..250_000 {
        pointer.fetch_add(1, Ordering::Relaxed);
    }

    if handle.join().is_err() {
        println!("Something bad happened :(");
    }

    let a_int = Arc::try_unwrap(pointer).unwrap();
    println!("Final number: {}", a_int.into_inner());
}

正如你现在所看到的,每次运行的结果都是 500,000。如果你想执行更复杂的操作,你需要一个锁。你可以用AtomicBool来做,例如,你可以等待它变为false,然后将其交换为true并执行操作。你需要确保所有线程只在锁被它们设置为true时更改值,通过使用某种内存排序。让我们看看一个例子:

use std::thread;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};

fn main() {
    let my_val = AtomicUsize::new(0);
    let pointer = Arc::new(my_val);
    let lock = Arc::new(AtomicBool::new(false));

    let t_pointer = pointer.clone();
    let t_lock = lock.clone();
    let handle = thread::Builder::new()
        .name("my thread".to_owned())
        .spawn(move || {
            for _ in 0..250_000 {
                while t_lock.compare_and_swap(
                        false, true, Ordering::Relaxed) {}
                let cur_value = t_pointer.load(Ordering::Relaxed);
                let sum = cur_value + 1;
                t_pointer.store(sum, Ordering::Relaxed);
                t_lock.store(false, Ordering::Relaxed);
            }
        })
        .expect("could not create the thread");

    for _ in 0..250_000 {
        while lock.compare_and_swap(
            false, true, Ordering::Relaxed) {}
        let cur_value = pointer.load(Ordering::Relaxed);
        let sum = cur_value + 1;
        pointer.store(sum, Ordering::Relaxed);
        lock.store(false, Ordering::Relaxed);
    }

    if handle.join().is_err() {
        println!("Something bad happened :(");
    }

    let a_int = Arc::try_unwrap(pointer).unwrap();
    println!("Final number: {}", a_int.into_inner());
}

如果你运行它,你会看到它工作得非常完美。但这只因为在这两个线程中,我们只在锁定和释放锁之间改变值。事实上,这是如此安全,以至于我们可以完全避免使用原子整数,尽管 Rust 在安全代码中不允许我们这样做。

现在我们已经看到了如何在线程间共享的整数进行变异的方法,你可能想知道是否可以对其他类型的绑定做类似的事情。正如你可能猜到的,是可以的。你需要使用std::sync::Mutex,在性能方面这会比使用原子操作要昂贵得多,所以请谨慎使用。让我们看看它们是如何工作的:

use std::thread;
use std::sync::{Arc, Mutex};

fn main() {
    let my_vec = Arc::new(Mutex::new(Vec::new()));

    let t_vec = my_vec.clone();
    let handle = thread::Builder::new()
        .name("my thread".to_owned())
        .spawn(move || {
            for i in 0..50 {
                t_vec.lock().unwrap().push(i);
            }
        })
        .expect("could not create the thread");

    for i in 0..50 {
        my_vec.lock().unwrap().push(i);
    }

    if handle.join().is_err() {
        println!("Something bad happened :(");
    }

    let vec_mutex = Arc::try_unwrap(my_vec).unwrap();
    let f_vec = vec_mutex.into_inner().unwrap();
    println!("Final vector: {:?}", f_vec);
}

它将输出类似的内容:

如果你仔细分析输出,你会看到它首先将 0 到 49 的所有数字相加,然后再次执行相同的操作。如果两个线程都在并行运行,所有数字不应该是随机分布的吗?也许先有两个 1,然后是两个 2,以此类推?

在线程间共享信息的主要问题是,当Mutex锁定时,它需要来自两个线程的同步。这是完全正常和安全的,但它需要花费很多时间从一个线程切换到另一个线程来写入向量。这就是为什么内核调度器允许在锁定Mutex之前让其中一个线程工作一段时间。如果它每次迭代都锁定和解锁Mutex,那么完成它将需要很长时间。

这意味着如果你的循环超过 50 次迭代,也许每个循环 1 百万次,你会在一段时间后看到,其中一个线程会停止,以便优先考虑第二个线程。然而,在迭代次数较少的情况下,你会看到它们一个接一个地运行。

当你调用lock()时,互斥锁被锁定,当它超出作用域时解锁。在这种情况下,由于没有与之绑定,它将在调用push(i)后超出作用域,因此我们可以在它之后添加更多计算,而无需在线程之间进行同步。有时,如果我们的工作涉及多行并且我们需要绑定,创建人工作用域以尽快解锁互斥锁可能甚至是有用的。

当我们使用互斥锁时,必须考虑的一个额外问题是线程崩溃。如果你的线程在互斥锁锁定时崩溃,另一个线程中的lock()函数将返回Result::Err(_),所以如果我们每次lock()互斥锁时都调用unwrap(),我们可能会遇到大麻烦,因为所有线程都会崩溃。这被称为Mutex中毒,并且有方法可以避免它。

当互斥锁因为一个线程在锁定时崩溃而中毒时,调用lock()方法的错误结果将返回中毒错误。我们可以通过调用into_inner()方法从中恢复。让我们看看一个例子,看看这是如何工作的:

use std::thread;
use std::sync::{Arc, Mutex};
use std::time::Duration;

fn main() {
    let my_vec = Arc::new(Mutex::new(Vec::new()));

    let t_vec = my_vec.clone();
    let handle = thread::Builder::new()
        .name("my thread".to_owned())
        .spawn(move || {
            for i in 0..10 {
                let mut vec = t_vec.lock().unwrap();
                vec.push(i);
                panic!("Panicking the secondary thread");
            }
        })
        .expect("could not create the thread");

    thread::sleep(Duration::from_secs(1));

    for i in 0..10 {
        let mut vec = match my_vec.lock() {
            Ok(g) => g,
            Err(e) => {
                println!("The secondary thread panicked, recovering…");
                e.into_inner()
            }
        };
        vec.push(i);
    }

    if handle.join().is_err() {
        println!("Something bad happened :(");
    }

    let vec_mutex = Arc::try_unwrap(my_vec).unwrap();
    let f_vec = match vec_mutex.into_inner() {
        Ok(g) => g,
        Err(e) => {
            println!("The secondary thread panicked, recovering…");
            e.into_inner()
        }
    };
    println!("Final vector: {:?}", f_vec);
}

如你在代码中所见,第二个线程在向向量插入第一个数字后会崩溃。我在主线程中添加了一个短暂的 1 秒休眠,以确保在主线程之前执行副线程。如果你运行它,你会得到类似以下的结果:

图片

如你所见,一旦Mutex被中毒,它将一直保持中毒状态。因此,你应该尽量避免任何可能导致在Mutex中获取锁后崩溃的行为。无论如何,你仍然可以使用它,并且如你所见,最终的向量将包含来自两个线程的值;只有来自副线程的0,直到崩溃,然后是主线程的其余部分。确保不要在关键应用程序中unwrap()互斥锁,因为这将在第一次崩溃后使所有线程崩溃。

线程间的通道

在两个或多个线程之间发送信息有另一种方式。它们被称为通道,或者更具体地说,是多生产者、单消费者 FIFO 通信原语。

首先,我们来分析一下这意味着什么。由于它们是多生产者通道,你可以同时从多个线程向接收者发送数据。同样,由于它们是单消费者,只有一个接收者会从通道中所有相关的发送者那里接收数据。最后,FIFO 来源于先入先出,这意味着通道中的消息将按照它们的创建时间戳进行排序,你只能在读取接收器中的第一个消息之后才能读取第二个消息

这些通道位于std::sync::mpsc模块中,它们对于日志记录或遥测等用途非常有用。一个线程可以管理与通信或日志机制的 I/O 接口,而其他线程可以向这个线程发送它们想要记录或通信的信息。对于用 Rust 编写的对流层气球控制软件,正在研究使用这些通道的方法。

通道由一个Sender和一个Receiver组成。Sender实现了Clone,这样就可以克隆发送者,多个线程可以向相关的接收者发送信息。有两种类型的发送者:SenderSyncSender。前者会将消息直接发送到接收者而不会检查任何额外的内容,而后者只有在接收者的缓冲区有足够空间时才会发送消息。它将阻塞当前线程,直到消息发送完成。

通道是通过std::sync::mpsc模块中的channel()sync_channel()函数创建的。它们将分别返回一个包含SenderSyncSender(分别作为第一个元素)和一个Receiver(作为第二个元素)的元组。由于SenderReceiver实现了Send,它们可以安全地使用move关键字发送到另一个线程。在同步通道的情况下,sync_channel()将需要一个usize来设置缓冲区大小。如果缓冲区已满,Sender将阻塞。另一方面,异步通道就像它们有一个无限缓冲区一样工作,它们总是会接受发送新数据。

每个通道只能发送或接收一种特定的数据类型,所以如果一个通道被配置为发送u32,则每条消息只能发送一个u32。不过,你也可以配置它发送你自己的类型,例如一个包含你可能想要发送的所有信息的自定义Frame类型。让我们看看通道是如何工作的:

use std::thread;
use std::sync::mpsc::*;
use std::time::Duration;

fn main() {
    let (sender, receiver) = channel();

    let handles: Vec<_> = (1..6)
        .map(|i| {
            let t_sender = sender.clone();
            thread::Builder::new()
                .name(format!("sender-{}", i))
                .spawn(move || {
                    t_sender.send(
                        format!("Hello from sender {}!", i)
                    ).unwrap();
                })
                .expect("could not create the thread")
        })
        .collect();

    while let Ok(message) = receiver.recv_timeout(Duration::from_secs(1)) {
        println!("{}", message);
    }
    for handle in handles {
        handle.join().unwrap();
    }
    println!("Finished");
}

如代码所示,使用迭代器创建了五个线程,并将它们的句柄收集到一个向量中。这些线程将有一个包含线程编号的名称,并将Hello from sender {}!消息发送给接收者。对于每个线程,发送者都会被克隆,以便克隆可以被移动到线程闭包中。

然后,一个 while 循环将检查消息,超时时间为 1 秒。这应该足够了,因为消息会在线程启动后立即发送。如果一秒钟内没有收到消息(或者所有发送者都超出作用域),while 循环将停止打印消息,并将线程连接起来。最后,将打印一条完成消息。

如果你运行这个示例,你将看到类似以下输出的内容:

图片

如您所见,线程的执行顺序并不特定。它们会将消息发送给接收者,接收者将按接收顺序读取它们。由于这个示例是异步的,我们不需要等待接收者清空缓冲区以发送新消息,所以它非常轻量级。实际上,我们可以在读取接收者的任何消息之前就连接线程。

多线程 crate

到目前为止,我们只使用标准库来操作线程,但多亏了伟大的 crates.io 生态系统,我们可以利用更多的方法来提高我们的开发速度以及代码的性能。

非阻塞数据结构

我们之前看到的一个问题是,如果我们想在线程之间共享比整数或布尔值更复杂的东西,并且我们想修改它,我们需要使用 Mutex。这并不完全正确,因为有一个 crate,Crossbeam,允许我们使用不需要锁定 Mutex 的大数据结构。因此,它们要快得多,效率也更高。

通常,当我们想在线程之间共享信息时,我们通常想要合作处理的任务列表。有时,我们想在多个线程中创建信息并将其添加到信息列表中。因此,多个线程通常不会使用完全相同的变量,因为我们已经看到,这需要同步,并且会变慢。

这就是 Crossbeam 展示其全部潜力的地方。Crossbeam 为我们提供了一些多线程队列和栈,我们可以从不同的线程中插入和消费数据。实际上,我们可以让一些线程进行数据的初步处理,而其他线程则执行处理的第二阶段。让我们看看我们如何使用这些功能。首先,将 crossbeam 添加到 Cargo.toml 文件中 crate 的依赖项。然后,我们从简单的示例开始:

extern crate crossbeam;

use std::thread;
use std::sync::Arc;

use crossbeam::sync::MsQueue;

fn main() {
    let queue = Arc::new(MsQueue::new());

    let handles: Vec<_> = (1..6)
        .map(|_| {
            let t_queue = queue.clone();
            thread::spawn(move || {
                for _ in 0..1_000_000 {
                    t_queue.push(10);
                }
            })
        })
        .collect();

    for handle in handles {
        handle.join().unwrap();
    }

    let final_queue = Arc::try_unwrap(queue).unwrap();
    let mut sum = 0;
    while let Some(i) = final_queue.try_pop() {
        sum += i;
    }

    println!("Final sum: {}", sum);
}

让我们先了解这个示例做了什么。它将在 5 个不同的线程中迭代 1,000,000 次,每次都会将一个 10 推入队列。队列是 FIFO 列表,先入先出。这意味着第一个输入的数字将是第一个 pop() 的,最后一个输入的将是最后一个 pop() 的。在这种情况下,所有数字都是 10,所以这并不重要。

一旦线程完成队列的填充,我们就遍历它并将所有数字添加进去。一个简单的计算应该让你能够猜出,如果一切顺利,最终的数字应该是 50,000,000。如果你运行它,那将是结果,而且不仅如此。如果你通过执行cargo run --release来运行它,它将运行得非常快。在我的电脑上,它大约需要一秒钟来完成。如果你想,尝试使用标准库Mutex和向量实现这段代码,你将看到性能差异惊人。

如你所见,我们仍然需要使用Arc来控制对队列的多个引用。这是因为队列本身不能被复制和共享,它没有引用计数。

Crossbeam 不仅为我们提供了 FIFO 队列。我们还有 LIFO 栈。LIFO 来源于后进先出,这意味着你最后插入栈中的元素将是第一个被pop()的。让我们通过几个线程来看看它们的区别:

extern crate crossbeam;

use std::thread;
use std::sync::Arc;
use std::time::Duration;

use crossbeam::sync::{MsQueue, TreiberStack};

fn main() {
    let queue = Arc::new(MsQueue::new());
    let stack = Arc::new(TreiberStack::new());

    let in_queue = queue.clone();
    let in_stack = stack.clone();
    let in_handle = thread::spawn(move || {
        for i in 0..5 {
            in_queue.push(i);
            in_stack.push(i);
            println!("Pushed :D");
            thread::sleep(Duration::from_millis(50));
        }
    });

    let mut final_queue = Vec::new();
    let mut final_stack = Vec::new();

    let mut last_q_failed = 0;
    let mut last_s_failed = 0;

    loop {
        // Get the queue
        match queue.try_pop() {
            Some(i) => {
                final_queue.push(i);
                last_q_failed = 0;
                println!("Something in the queue! :)");
            }
            None => {
                println!("Nothing in the queue :(");
                last_q_failed += 1;
            }
        }

        // Get the stack
        match stack.try_pop() {
            Some(i) => {
                final_stack.push(i);
                last_s_failed = 0;
                println!("Something in the stack! :)");
            }
            None => {
                println!("Nothing in the stack :(");
                last_s_failed += 1;
            }
        }

        // Check if we finished
        if last_q_failed > 1 && last_s_failed > 1 {
            break;
        } else if last_q_failed > 0 || last_s_failed > 0 {
            thread::sleep(Duration::from_millis(100));
        }
    }

    in_handle.join().unwrap();

    println!("Queue: {:?}", final_queue);
    println!("Stack: {:?}", final_stack);
}

如代码所示,我们有两个共享变量:一个队列和一个栈。次要线程将按相同的顺序将新值推送到它们中,从 0 到 4。然后,主线程将尝试取回它们。它将无限循环并使用try_pop()方法。可以使用pop()方法,但如果队列或栈为空,它将阻塞线程。一旦所有值都被弹出,这将在任何情况下发生,因为没有新的值被添加,所以try_pop()方法将帮助不会阻塞主线程并优雅地结束。

它通过计算尝试弹出新值失败的次数来检查是否所有值都被弹出。每次失败,它将等待 100 毫秒,而推送线程只在推送之间等待 50 毫秒。这意味着如果它尝试弹出新值两次而没有新值,推送线程已经完成。

它将弹出的值添加到两个向量中,然后打印结果。同时,它将打印有关推送和弹出新值的消息。通过查看输出,你会更好地理解这一点:

注意,由于线程不需要按任何特定顺序执行,所以你的输出可能会有所不同。

在这个示例输出中,如你所见,它首先尝试从队列和栈中获取一些东西,但那里没有,所以它休眠。然后,第二个线程开始推送东西,实际上是两个数字。之后,队列和栈将变为[0, 1]。然后,它从每个中弹出第一个项目。从队列中,它将弹出0,从栈中弹出1(最后一个),使队列变为[1],栈变为[0]

然后,它将再次进入休眠状态,次要线程将每个变量的值增加 2,使队列变为 [1, 2],堆栈变为 [0, 2]。然后,主线程将从每个队列和堆栈中弹出两个元素。从队列中,它将弹出 12,而从堆栈中,它将弹出 2 然后是 0,使它们都为空。

主线程随后进入休眠状态,在接下来的两次尝试中,次要线程将推送一个元素,而主线程将弹出它,两次。

虽然这可能看起来有点复杂,但想法是这些队列和堆栈可以在线程之间高效地使用,而无需使用 Mutex,并且它们接受任何 Send 类型。这意味着它们非常适合复杂计算,甚至适合多阶段复杂计算。

Crossbeam crate 还提供了一些辅助工具来处理时代和提到的类型的某些变体。对于多线程,Crossbeam 还增加了一个非常有用的功能:作用域线程。

作用域线程

在我们所有的例子中,我们都使用了标准库线程。正如我们讨论的那样,这些线程有自己的堆栈,所以如果我们想在主线程中使用的变量,我们需要将它们 发送 到线程。这意味着我们需要使用像 Arc 这样的东西来共享不可变数据。不仅如此,由于它们有自己的堆栈,它们也会消耗更多的内存,如果使用过多,最终会使系统变慢。

Crossbeam 给我们一些特殊的线程,允许它们之间共享堆栈。它们被称为作用域线程。使用它们相当简单,crate 文档解释得非常完美;你只需要通过调用 crossbeam::scope() 创建一个 Scope。你需要传递一个接收作用域的闭包。你可以在该范围内调用 spawn(),就像在 std::thread 中做的那样,但有一个区别,如果你在作用域内创建或将其移动到作用域中,你可以在线程之间共享不可变变量。

这意味着对于我们刚才提到的队列或堆栈,或者对于原子数据,你可以简单地调用它们的方法,而无需使用 Arc!这将进一步提高性能。让我们看看它如何通过一个简单的例子来工作:

extern crate crossbeam;

fn main() {
    let all_nums: Vec<_> = (0..1_000_u64).into_iter().collect();
    let mut results = Vec::new();

    crossbeam::scope(|scope| {
        for num in &all_nums {
            results.push(scope.spawn(move || num * num + num * 5 + 250));
        }
    });

    let final_result: u64 = results.into_iter().map(|res| res.join()).sum();
    println!("Final result: {}", final_result);
}

让我们看看这段代码做了什么。它首先将创建一个包含从 0 到 1000 的所有数字的向量。然后,对于每一个数字,在一个 crossbeam 范围内,它将为每个数字运行一个作用域线程并执行一个假设的复杂计算。这只是一个例子,因为它将只返回一个简单二阶函数的结果。

然而,有趣的是,scope.spawn() 方法允许返回任何类型的结果,这在我们的情况下是非常好的。代码将把每个结果添加到一个向量中。由于它将在并行执行,所以它不会直接添加结果数字,而是添加一个结果保护器,我们可以在范围外检查它。

然后,在所有线程运行并返回结果后,作用域将结束。我们现在可以检查所有结果,它们保证对我们来说是准备好的。对于每一个,我们只需要调用 join(),我们就会得到结果。然后,我们将它们加起来以检查它们是否是实际的计算结果。

这个 join() 方法也可以在作用域内调用并获取结果,但这意味着如果您在 for 循环内这样做,例如,您将阻塞循环直到生成结果,这并不高效。最好的做法是至少先运行所有计算,然后再开始检查结果。如果您想在它们之后进行更多计算,您可能会发现将新的计算在 crossbeam 作用域内的另一个循环或迭代器中运行很有用。

但是,crossbeam 是如何让您在作用域外自由使用变量的呢?不会出现数据竞争吗?这就是魔法发生的地方。作用域将在退出之前将所有内部线程连接起来,这意味着在所有作用域线程完成之前,主线程将不会执行任何进一步的代码。这意味着我们可以使用主线程的变量,也称为 父栈,因为在这种情况下,主线程是作用域的父线程,而没有任何问题。

我们实际上可以使用 println!() 宏来检查正在发生的事情。如果我们从之前的例子中记得,在生成一些线程后打印到控制台通常会先于生成的线程运行,这是因为设置它们所需的时间。在这种情况下,由于 crossbeam 阻止了它,我们不会看到它。让我们检查以下示例:

extern crate crossbeam;

fn main() {
    let all_nums: Vec<_> = (0..10).into_iter().collect();

    crossbeam::scope(|scope| {
        for num in all_nums {
            scope.spawn(move || {
                println!("Next number is {}", num);
            });
        }
    });

    println!("Main thread continues :)");
}

如果您运行此代码,您将看到以下类似输出:

如您所见,作用域线程将以没有任何特定顺序的方式运行。在这种情况下,它将首先运行 1,然后是 0,然后是 2,依此类推。您的输出可能不同。但有趣的是,主线程不会继续执行,直到所有线程都完成。因此,在主线程中读取和修改变量是绝对安全的。

采用这种方法有两个主要性能优势;Arc 将需要调用 malloc() 来在堆中分配内存,如果这是一个大结构且内存有点满,这将花费时间。有趣的是,这些数据已经在我们的栈中了,所以如果可能的话,我们应该尽量避免在堆中重复它。此外,Arc 将有一个引用计数器,正如我们所看到的。它甚至将是一个原子引用计数器,这意味着每次我们克隆引用时,我们都需要原子地增加计数。这需要时间,甚至比增加简单的整数还要多。

大多数时候,我们可能正在等待一些昂贵的计算运行,如果它们完成时能直接给出所有结果那就太好了。我们仍然可以添加一些链式计算,使用作用域线程,这些计算将在第一个计算完成后执行,所以如果可能的话,我们应该比正常线程更频繁地使用作用域线程。

线程池

到目前为止,我们已经看到了创建新线程和它们之间共享信息的好几种方法。然而,正如我们在本章开头看到的,我们应该生成的理想线程数应该接近系统虚拟处理器的数量。这意味着我们不应该为每一块工作生成一个线程。尽管如此,控制每个线程执行的工作可能很复杂,因为你必须确保在任何给定时间点所有线程都有工作可做。

正是在这里,线程池派上了用场。Threadpool crate 将允许你遍历所有的工作,并且对于你的每一个小块工作,你可以调用类似于 thread::spawn() 的操作。有趣的是,每个任务将被分配给一个空闲线程,并且不会为每个任务创建新的线程。线程的数量是可以配置的,你可以使用其他 crate 来获取 CPU 的数量。不仅如此,如果其中一个线程发生恐慌,它将自动向池中添加一个新的线程。

为了看到示例,首先,让我们在我们的 Cargo.toml 文件中将 threadpoolnum_cpus 添加为依赖项。然后,让我们看看一个示例代码:

extern crate num_cpus;
extern crate threadpool;

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

use threadpool::ThreadPool;

fn main() {
    let pool = ThreadPool::with_name("my worker".to_owned(), num_cpus::get());
    println!("Pool threads: {}", pool.max_count());

    let result = Arc::new(AtomicUsize::new(0));

    for i in 0..1_0000_000 {
        let t_result = result.clone();
        pool.execute(move || {
            t_result.fetch_add(i, Ordering::Relaxed);
        });
    }

    pool.join();

    let final_res = Arc::try_unwrap(result).unwrap().into_inner();
    println!("Final result: {}", final_res);
}

这段代码将创建一个具有计算机逻辑 CPU 数量的线程池。然后,它将一个从 0 到 1,000,000 的数字添加到一个原子的 usize,只是为了测试并行处理。每个加法操作将由一个线程执行。如果每个操作使用一个线程(1,000,000 个线程)将会非常低效。然而,在这种情况下,它将使用适当的线程数量,执行将会非常快。还有一个 crate 提供了线程池一个更有趣的并行处理功能:Rayon。

并行迭代器

如果你能在这些代码示例中看到整体情况,你就会意识到大多数并行工作都有一个长循环,将工作分配给不同的线程。这发生在简单的线程上,在作用域线程和线程池中发生得更多。在现实生活中通常也是这样。你可能有一堆数据要处理,你可能会将这个处理分成几块,遍历它们,并将它们交给不同的线程去完成工作。

这种方法的主要问题是,如果你需要使用多个阶段来处理给定数据,你可能会得到很多样板代码,这可能会使维护变得困难。不仅如此,你可能会发现自己有时不使用并行处理,因为必须编写所有这些代码的麻烦。

幸运的是,Rayon 围绕迭代器提供了多个数据并行原语,你可以使用这些原语并行化任何迭代计算。你几乎可以忘记Iterator特性,并使用 Rayon 的ParallelIterator替代品,它和标准库特性一样容易使用!

Rayon 使用一种称为工作窃取的并行迭代技术。对于并行迭代器的每次迭代,新的值或值被添加到待处理工作的队列中。然后,当一个线程完成其工作后,它会检查是否有待处理的工作要做,如果有,它就开始处理。在大多数语言中,这是一个明显的数据竞争来源,但多亏了 Rust,这不再是问题,你的算法可以运行得非常快,并且并行运行。

让我们看看如何使用它来处理本章中我们看到的类似示例。首先,将rayon添加到你的Cargo.toml文件中,然后让我们从代码开始:

extern crate rayon;

use rayon::prelude::*;

fn main() {
    let result = (0..1_000_000_u64)
        .into_par_iter()
        .map(|e| e * 2)
        .sum::<u64>();

    println!("Result: {}", result);
}

正如你所见,这就像你在顺序迭代器中编写的那样工作,然而,它是在并行运行的。当然,由于编译器的优化,顺序运行此示例会比并行运行快,但当你需要从文件中处理数据,例如,或执行非常复杂的数学计算时,并行化输入可以带来巨大的性能提升。

Rayon 将这些并行迭代特性实现到了所有标准库迭代器和范围中。不仅如此,它还可以与标准库集合一起工作,例如HashMapVec。在大多数情况下,如果你在代码中使用标准库的iter()into_iter()方法,你可以在这些调用中简单地使用par_iter()into_par_iter(),并且你的代码现在应该是并行并且运行得很好的。

但是,要注意,有时并行化某物并不会自动提高其性能。考虑到如果你需要在线程之间更新一些共享信息,它们将需要以某种方式进行同步,这将导致性能损失。因此,只有在工作负载完全独立并且你可以独立执行而无需依赖其他任何内容时,多线程才是伟大的。

摘要

在本章中,我们看到了我们的顺序算法如何通过并行运行来轻松提高性能。这种并行性可以通过多种方式获得,在本章中,我们学习了多线程。我们看到了在 Rust 中多线程是如何真正安全的,以及我们如何利用 crate 生态系统进一步提高我们的性能。

我们了解了一些我们可以为我们的多线程代码开发的性能增强,以及如何使用所有可用的工具来发挥我们的优势。你现在可以使用多个线程在 Rust 中开发高性能并发应用程序。

在下一章中,我们将探讨异步编程。我们将探讨的原始程序使我们能够编写并发程序,即使我们在等待某些计算时,也不会锁定我们的线程,甚至不需要我们生成新的线程!

第十一章:异步编程

到目前为止,我们在 Rust 中实现并发的方式只有创建多个线程,无论哪种方式,都是为了分担工作。然而,这些线程有时需要停下来寻找某些东西,比如文件或网络响应。在这些情况下,整个线程将会被阻塞,并需要等待响应。

这意味着,如果我们想为像 HTTP 服务器这样的东西实现低延迟,一种方法是为每个请求创建一个线程,这样每个请求都可以尽可能快地被服务,即使其他请求被阻塞。

正如我们所看到的,创建数百个线程是不可扩展的,因为每个线程都将有自己的内存,即使在阻塞的情况下也会消耗资源。在本章中,你将通过使用异步编程学习一种新的做事方式。

在本章中,你将学习以下内容:

  • 使用 mio 的异步原语

  • 使用 futures

  • 新的 async/await 语法和生成器

  • 使用 tokiowebsockets 进行异步 I/O

异步编程简介

如果你想在计算中实现高性能,你需要并发运行任务。无论你是运行需要几天时间才能完成的复杂计算,比如机器学习训练,还是运行需要每秒响应数千个请求的 Web 服务器,你都需要同时做很多事情。

幸运的是,正如我们之前所看到的,我们的处理器和操作系统已经为并发做好了准备,实际上,多线程是实现并发的绝佳方式。主要问题是,正如我们在上一章中看到的,我们不应该在我们的计算机中使用比逻辑 CPU 更多的线程。

我们当然可以,但一些线程将等待其他线程执行,内核将协调每个线程在 CPU 中获得多少时间。这将消耗更多资源,并使整个过程变慢。尽管如此,有时拥有比核心数量更多的线程可能是有用的。也许其中一些每几秒钟才会醒来执行小任务,或者我们知道它们中的大多数会因为某些 I/O 操作而阻塞。

当一个线程阻塞时,执行将停止。直到它被解除阻塞,CPU 不会运行任何进一步的指令。例如,当我们读取文件时,这可能会发生。当然,这取决于我们如何读取文件。

但在后一种情况下,我们不必创建更多的线程,而可以做得更好——使用异步编程。在异步编程时,我们让代码在等待某个结果的同时继续执行。这样就可以避免阻塞线程,让你在完成相同任务的同时使用更少的线程,同时保持并发。你也可以为与 I/O 无关的任务使用异步编程,但如果它们是 CPU 密集型的(瓶颈在 CPU 上),你不会获得速度上的提升,因为 CPU 总是以最佳状态运行。要了解 Rust 中异步 I/O 是如何工作的,让我们首先深入了解 CPU 是如何处理 I/O 的。

理解 CPU 中的 I/O

Rust 中的std::io模块处理所有输入/输出操作。这些操作可能包括键盘/鼠标输入、读取文件,或者使用 TCP/IP 套接字到命令行工具(stdio/stderr)。但它是如何内部工作的呢?

我们不会深入理解 Rust 标准库是如何实现的,而是要深入到 CPU 级别来理解它是如何工作的。我们稍后会回到如何通过内核将此功能提供给 Rust。这主要基于 x86_64 平台和 Linux 内核,但其他平台处理这些事情的方式类似。

I/O 架构主要有两种类型:基于通道的 I/O 和内存映射 I/O。基于通道的 I/O 非常小众,在现代 PC 或大多数服务器上并不使用。在 x86/x86_64(大多数现代的 Intel 和 AMD CPU)这样的 CPU 架构中,使用的是内存映射 I/O。但这是什么意思呢?

如你所知,CPU 从 RAM 内存中获取所有工作所需的信息。正如我们在前面的章节中看到的,这些信息最终会被缓存到 CPU 缓存中,直到它们到达 CPU 寄存器才会被使用,但这对现在来说并不那么重要。因此,如果 CPU 想要获取有关键盘上按下的哪个键或你访问的网站正在发送的 TCP 帧的信息,它需要要么有一些额外的硬件通道到这些输入/输出接口,要么这些接口需要在 RAM 中做出一些改变。

第一种选择是基于通道的 I/O。使用基于通道的 I/O 的 CPU 有专门的通道和硬件用于 I/O 操作。这通常会使 CPU 的价格大幅增加。另一方面,在内存映射 I/O 中,使用的是第二种选择——当发生 I/O 操作时,内存会以某种方式被修改。

在这里,我们需要稍微停顿一下,更好地理解这一点。尽管我们可能认为所有的内存都在我们的 RAM 条上,但事实并非如此。内存被分为虚拟内存和物理内存。每个程序都有一个虚拟内存地址,用于每个可寻址的字节,其大小与 CPU 字的大小相同。这意味着 32 位 CPU 将为每个程序提供 2³²个虚拟内存地址,64 位 CPU 将提供 2⁶⁴个地址。这意味着 32 位计算机将有 4 GiB 的 RAM,而 64 位 CPU 将有 16 EiB 的 RAM。EiBsexbibytes,或 1,014 PiBpebibytes)。每个 PiB 是 1024 GiBgibibytes)。记住,gibibytes 是gigabytesGB)的两进制版本。所有这些对 CPU 中的每个进程都适用。

这个问题有一些问题。首先,如果我们有两个进程,我们需要双倍的内存,对吧?但是内核只能处理那么多的内存(它本身就是一个进程)。所以我们需要转换表TLBs),告诉每个进程它们的内存在哪里。但是尽管我们可能为 32 位 CPU 配备了 4 GiB 的 RAM,我们并没有 16 EiB 的 RAM。不仅如此,32 位 CPU 在我们能够制造出 4 GiB RAM 的 PC 之前就已经存在了。一个进程如何能够访问比我们安装的内存更多的 RAM 呢?

解决方案很简单——我们将这个地址空间称为虚拟内存空间,而真正的 RAM 称为物理内存空间。如果一个进程需要的内存比可用的物理内存多,可能会发生两件事——要么我们的内核可以将一些内存地址从 RAM 移动到磁盘,并为这个进程分配更多的 RAM,要么它将收到一个内存不足的错误。第一种选择被称为页面交换,这在 Unix 系统中非常常见,有时你甚至可以决定为它提供多少磁盘空间。

将信息从 RAM 移动到磁盘会大大减慢速度,因为与 RAM 相比,磁盘本身非常慢(即使是现代的 SSD 也比 RAM 慢得多)。尽管如此,我们在这里发现,确实有一些 I/O 操作将内存信息交换到磁盘,对吧?这是如何发生的?

好吧,我们说过虚拟内存空间是针对每个进程的,我们也说过内核是另一个进程。这意味着内核也有整个内存空间可供使用。这就是内存映射 I/O 出现的地方。CPU 将决定将新的设备映射到某些地址。这意味着内核只需读取其虚拟地址空间中的某些具体位置,就能读取有关 I/O 接口的信息。

在这方面,关于如何读取这些信息有一些变体。主要有两种方式——端口映射 I/O 和直接内存访问或 DMA。端口映射 I/O 当然用于 TCP/IP、串行和其他类型的外围通信。它将分配一些特定的地址给它。这些地址将是一个缓冲区,这意味着当输入到来时,它将逐个写入下一个内存地址。一旦到达末尾,它将重新从开始,因此内核必须足够快,以便在信息被重写之前读取信息。它还可以阻塞端口,停止通信。

在 DMA 的情况下,设备的内存空间将直接映射到虚拟内存中。这使得我们可以像访问当前 PC 的虚拟地址空间的一部分那样访问该内存。所采用的方法取决于任务和我们要与之通信的设备。你现在可能想知道内核是如何为你的程序处理所有这些的。

控制内核的 I/O

当一个新的 TCP/IP 连接建立,或者当键盘上按下新的键时,内核必须知道这一点,以便它可以相应地采取行动。有两种方法可以做到这一点——内核可以一次又一次地查看那些端口或内存地址以寻找变化,这会使 CPU 大部分时间都在做无用功,或者内核可以通过 CPU 中断来通知。

如你所想,大多数内核决定选择第二种方案。它们是空闲的,让其他进程使用 CPU,直到某个 I/O 端口或地址发生变化。这使得 CPU 在硬件级别中断,并将控制权交给内核。内核将检查发生了什么,并相应地决定。如果有进程正在等待那个中断,它将唤醒那个进程,并让它知道有一些新信息供它处理。

然而,等待信息的进程可能已经唤醒了。这种情况发生在异步编程中。当进程仍在等待 I/O 事务时,它将继续执行一些计算。在这种情况下,进程将在内核中注册一些回调函数,以便内核知道一旦 I/O 操作准备好后应该调用什么。

这意味着在 I/O 操作执行期间,进程正在做有用的事情,而不是被阻塞并等待内核返回 I/O 操作的结果。这使得你几乎可以始终使用 CPU,而无需暂停执行,从而使你的代码性能更佳。

从程序员的角度看异步编程

到目前为止,我们已经从硬件和软件的角度了解了 I/O 的工作原理。我们提到,在等待 I/O 的同时,我们的进程可以继续工作,但我们应该如何实现呢?

内核有一些东西可以帮助我们完成这项工作。在 Linux 的情况下,它有epoll()系统调用,它让内核知道我们的代码想要从 I/O 接口接收一些信息,但不需要锁定自己直到信息可用。内核将知道当信息准备好时应该运行哪个回调,同时,我们的程序可以执行大量的计算。

这非常有用,例如,如果我们正在处理一些数据,并且我们知道将来我们需要从文件中获取一些信息。我们可以要求内核在我们继续计算的同时从文件中获取信息,一旦我们需要从文件中获取信息,我们就不需要等待文件读取操作——信息就会在那里。这大大减少了磁盘读取延迟,因为它几乎和从 RAM 中读取一样快,而不是从磁盘读取。

我们可以使用这种方法来处理 TCP/IP 连接、串行连接,以及一般需要 I/O 访问的任何东西。这个epoll()系统调用直接来自 Linux C API,但在 Rust 中,我们有很好的封装器,使得所有这些操作都变得容易,而且没有开销。让我们来看看它们。

理解未来

如果我们在代码中使用std::io::Readstd::io::Write特性,我们将能够轻松地从 I/O 接口读取和写入数据,但每次我们这样做时,执行调用的线程都会阻塞,直到数据接收完成。幸运的是,Rust 的优秀的 crate 生态系统为我们带来了改善这种情况的绝佳机会。

在许多编程语言中,你可以找到“尚未可用数据”的概念。例如,在 JavaScript 中,它们被称为 promises,而在 Rust 中,我们称它们为 futures。Future 表示将来某个时刻将可用的任何数据,但目前可能尚未可用。你可以在任何时间检查 future 是否有值,如果有,就可以获取它。如果没有,你可以在其间执行一些计算,或者阻塞当前线程,直到值到达。

Rust 的 futures 不仅为我们提供了这个特性,而且还提供了大量的有用 API,我们可以使用它们来提高代码的可读性并减少代码量。futures crate 通过零成本抽象来实现这一切。这意味着它不会需要额外的分配,代码将尽可能接近你为了实现所有这些功能所能编写的最佳汇编代码。

Futures 不仅适用于 I/O,还可以与任何类型的计算或数据一起使用。在这些示例中,我们将使用 futures crate 的 0.2.x 版本。在撰写本文时,该版本仍在 alpha 开发阶段,但预计很快就会发布。让我们看看 futures 是如何工作的。我们首先需要在Cargo.toml文件中将futures crate 添加为依赖项,然后我们就可以在我们的项目的main.rs文件中开始编写一些代码:

extern crate futures;

use futures::prelude::*;
use futures::future::{self, FutureResult};
use futures::executor::block_on;

fn main() {
    let final_result = some_complex_computation().map(|res| (res - 10) / 7);

    println!("Doing some other things while our result gets generated");

    match block_on(final_result) {
        Ok(res) => println!("The result is {}", res),
        Err(e) => println!("Error: {}", e),
    }
}

fn some_complex_computation() -> FutureResult<u32, String> {
    use std::thread;
    use std::time::Duration;

    thread::sleep(Duration::from_secs(5));

    future::ok(150)
}

在这个例子中,我们有一个模拟的复杂计算,大约需要 5 秒钟。这个计算返回一个Future,因此我们可以使用有用的方法在结果生成后修改它。这些方法来自FutureExt特性。

然后,block_on()函数将等待直到给定的未来不再挂起。您可能会认为这和我们在使用线程时完全一样,但有趣的是,我们这里只使用了一个线程。未来将在主线程有额外时间或我们调用block_on()函数时被计算。

当然,对于计算密集型应用程序来说,这并没有太多意义,因为我们无论如何都不得不在主线程中进行计算,但对于 I/O 访问来说,这非常有意义。我们可以将Future视为Result的异步版本。

如您在docs.rs/futures/0.2.0-alpha/futures/trait.FutureExt.htmlFutureExt特性文档中所见,我们有许多组合器可以使用。在这种情况下,我们使用了map()方法,但也可以使用其他方法,例如and_then()map_err()or_else(),甚至是在未来之间的连接。所有这些方法都会依次异步运行。一旦您调用block_on()函数,您将得到最终未来的Result

未来组合器

既然我们提到了连接,实际上是有可能存在两个相互依赖的未来。也许我们有两个文件的信息,我们为每个文件生成一个读取未来的操作,然后我们想要结合它们的信息。我们不需要阻塞线程来做这件事;我们可以使用join()方法,它背后的逻辑将确保一旦我们编写的闭包被调用,两个未来都将接收到最终值。

这在创建并发依赖图时非常有用。如果您有很多小计算想要并行化,您可以为每个部分创建一个闭包或函数,然后使用join()和其他方法,例如and_then(),来决定哪些计算需要并行运行,同时仍然接收每个步骤所需的所有数据。join()方法有五种变体,取决于您下一次计算需要多少个未来。

但简单的未来并不是这个 crate 给我们提供的唯一东西。我们还可以使用Stream特性,它的工作方式类似于Iterator特性,但异步。这对于逐个到来且不仅仅是单次值输入的情况非常有用。例如,这发生在 TCP、串行或任何使用字节流的连接中。

使用这个特质,尤其是使用StreamExt特质,我们几乎有与迭代器相同的 API,我们可以创建一个完整的迭代器,例如,可以逐字节异步地从 TCP 连接中检索 HTTP 数据。这在 Web 服务器中有许多应用,我们已经在社区中看到了迁移到异步 API 的 crate。

这个 crate 还提供了一个Write特质的异步版本。通过SinkSinkExt特质,你可以向任何输出对象发送数据。这可能是一个文件、一个连接,甚至是一种流计算。SinkStream配合得很好,因为SinkExt特质中的send_all()方法允许你将整个Stream发送到Sink。例如,你可以异步地逐字节读取文件,对每个字节或块进行一些计算,然后只需使用这些组合器将这些结果写入另一个文件。

让我们看看一个例子。我们将使用futures-timer crate,不幸的是它目前还不适用于 futures 0.2.0。所以,让我们用以下[dependencies]部分更新我们的Cargo.toml文件:

[dependencies]
futures = "0.1"
futures-timer = "0.1"

然后,让我们在我们的main.rs文件中编写以下代码:

extern crate futures;
extern crate futures_timer;

use std::time::Duration;

use futures::prelude::*;
use futures_timer::Interval;
use futures::future::ok;

fn main() {
    Interval::new(Duration::from_secs(1))
        .take(5)
        .for_each(|_| {
            println!("New interval");
            ok(())
        })
        .wait()
        .unwrap();
}

如果你为这个示例执行 cargo run,它将生成五条新的New interval文本行,每秒一条。Interval 每次配置的间隔超时时都会返回一个()。然后我们只取前五个,并在for_each循环中运行闭包。正如你所看到的,StreamStreamExt特质几乎与Iterator特质以相同的方式工作。

Rust 中的异步 I/O

当涉及到 I/O 操作时,有一个首选的 crate。它被称为tokio,它可以无缝地处理异步输入和输出操作。这个 crate 基于 MIO。MIO,即 Metal IO,是一个基础 crate,它提供了一个非常底层的异步编程接口。它生成一个事件队列,你可以使用循环逐个收集所有事件,异步地。

如我们之前所见,这些事件可以是任何东西,从接收到的 TCP 消息你请求的文件部分准备好。MIO 中有创建小型 TCP 服务器的教程,例如,但 MIO 的理念不是直接使用 crate,而是使用一个外观。最知名且最有用的外观是tokio crate。这个 crate 本身只提供了一些小的原语,但它打开了通往许多异步接口的大门。例如,你有tokio-serialtokio-jsonrpctokio-http2tokio-imap以及许多许多更多。

不仅如此,你还有像 tokio-retry 这样的实用工具,如果发生错误,它会自动重试 I/O 操作。Tokio 非常容易使用,它具有极低的内存占用,并且通过其异步操作,你可以创建出极快的服务。正如你可能已经注意到的,它主要围绕通信展开。这是由于它为这些情况提供了所有这些辅助功能和能力。核心包也具有文件读取功能,因此对于任何 I/O 密集型操作,你应该已经有所覆盖,正如我们将看到的。

我们首先将看到如何使用 Tokio 开发一个小型的 TCP 回显服务器。你可以在 Tokio 网站上找到类似的教程(tokio.rs/),并且值得跟随所有的教程。因此,让我们首先将 tokio 添加为 Cargo.toml 文件中的依赖项。然后,我们将使用 tokio 包中的 TcpListener 来创建一个小型服务器。这个结构将 TCP 套接字监听器绑定到指定的地址,并且它将为每个传入的连接异步执行一个给定的函数。在这个函数中,我们将异步读取套接字中可能存在的任何数据并将其返回,执行一个 echo。让我们看看它是什么样子:

extern crate tokio;

use tokio::prelude::*;
use tokio::net::TcpListener;
use tokio::io;

fn main() {
    let address = "127.0.0.1:8000".parse().unwrap();
    let listener = TcpListener::bind(&address).unwrap();

    let server = listener
        .incoming()
        .map_err(|e| eprintln!("Error accepting connection: {:?}", e))
        .for_each(|socket| {
            let (reader, writer) = socket.split();
            let copied = io::copy(reader, writer);

            let handler = copied
                .map(|(count, _reader, _writer)| println!("{} bytes 
                  received", count))
                .map_err(|e| eprintln!("Error: {:?}", e));

            tokio::spawn(handler)
        });

    tokio::run(server);
}

让我们分析一下代码。监听器使用 incoming() 方法创建一个异步的传入连接流。对于每一个,我们检查它是否是一个错误,并相应地打印一条消息,然后,对于正确的连接,我们通过使用 split() 方法获取套接字,并获取一个写入器和读取器。然后,Tokio 给我们一个由 tokio::io::copy() 函数创建的 Copy 未来。这个未来表示异步地从读取器复制到写入器的数据。

我们可以使用 AsyncReadAsyncWrite 特性自己编写这个未来,但看到 Tokio 已经有了这个示例未来是非常好的。由于我们想要的行为是返回连接发送的任何内容,这将完美地工作。然后,我们添加了一些额外的代码,这些代码将在读取器返回 End of FileEOF(当连接关闭时)后执行。它将打印复制的字节数,并处理可能出现的任何潜在错误。

然后,为了使未来能够执行其任务,需要有一些东西来执行它。这就是 Tokio 执行器的用武之地——我们调用 tokio::spawn(),它将在默认执行器中执行未来。我们刚刚创建了一个在连接到来时要做的事情的流,但现在我们需要实际运行代码。为此,Tokio 有 tokio::run() 函数,它启动整个 Tokio 运行时进程并开始接受连接。

我们创建的主要未来,即传入连接的流,将在那个点执行并将阻塞主线程。由于服务器始终在等待新的连接,它将无限期地阻塞。尽管如此,这并不意味着未来的执行是同步的。线程将空闲下来而不消耗 CPU,当连接到来时,线程将被唤醒并执行未来。在未来的本身,在发送接收到的数据回传时,如果没有更多数据,它将不会阻塞执行。这使在一个线程中运行多个连接成为可能。在生产环境中,你可能希望在多个线程中拥有类似的行为,这样每个线程就可以处理多个连接。

现在是时候测试它了。你可以通过运行 cargo run 来启动服务器,并且可以使用如 Telnet 这样的 TCP 工具来连接它。在 Telnet 的情况下,它会逐行缓冲发送的数据,因此你需要发送一整行才能接收到回显。Tokio 在另一个领域特别有用——解析帧。如果你想创建自己的通信协议,例如,你可能希望将那些 TCP 字节分成帧,然后将它们转换为你的数据类型。

创建 Tokio 编解码器

在 Tokio 中,我们有编解码器的概念。编解码器是一种类型,它将字节数组分割成帧。每个帧将包含从字节数据流中解析出的某些信息。在我们的例子中,我们将读取 TCP 连接的输入,并在找到字母 a 时将其分割成块。一个生产就绪的编解码器可能更复杂,但这个例子将为我们提供一个足够好的基础来实现自己的编解码器。我们需要实现 tokio-io crate 中的两个特性,因此我们需要将其添加到 Cargo.toml 文件的 [dependencies] 部分并使用 extern crate tokio_io; 来导入它。我们还需要对 bytes crate 做同样的事情。现在,让我们开始编写代码:

extern crate bytes;
extern crate tokio;
extern crate tokio_io;

use std::io;

use tokio_io::codec::{Decoder, Encoder};
use bytes::BytesMut;

#[derive(Debug, Default)]
struct ADividerCodec {
    next_index: usize,
}

impl Decoder for ADividerCodec {
    type Item = String;
    type Error = io::Error;

    fn decode(&mut self, buf: &mut BytesMut)
    -> Result<Option<Self::Item>, Self::Error> {
        if let Some(new_offset) = 
          buf[self.next_index..].iter().position(|b| *b == b'a') {
            let new_index = new_offset + self.next_index;
            let res = buf.split_to(new_index + 1);
            let res = &res[..res.len() - 1];
            let res: Vec<_> = res.into_iter()
                .cloned()
                .filter(|b| *b != b'\r' && *b != b'\n')
                .collect();
            let res = String::from_utf8(res).map_err(|_| {
                io::Error::new(
                    io::ErrorKind::InvalidData,
                    "Unable to decode input as UTF8"
                )
            })?;
            self.next_index = 0;
            Ok(Some(res))
        } else {
            self.next_index = buf.len();
            Ok(None)
        }
    }

    fn decode_eof(&mut self, buf: &mut BytesMut)
    -> Result<Option<String>, io::Error> {
        Ok(match self.decode(buf)? {
            Some(frame) => Some(frame),
            None => {
                // No terminating 'a' - return remaining data, if any
                if buf.is_empty() {
                    None
                } else {
                    let res = buf.take();
                    let res: Vec<_> = res.into_iter()
                        .filter(|b| *b != b'\r' && *b != b'\n')
                        .collect();
                    let res = String::from_utf8(res).map_err(|_| {
                        io::Error::new(
                            io::ErrorKind::InvalidData,
                            "Unable to decode input as UTF8"
                        )
                    })?;
                    self.next_index = 0;
                    Some(res)
                }
            }
        })
    }
}

这段代码有很多;让我们仔细分析一下。我们创建了一个结构,命名为 ADividerCodec,并为它实现了 Decode 特性。这段代码有两个方法。第一个也是最重要的方法是 decode() 方法。它接收一个包含来自连接的数据的缓冲区,并需要返回一些数据或没有数据。在这种情况下,它将尝试找到小写字母 a 的位置。如果找到了,它将返回读取到的所有字节。它还删除了换行符,以便打印更加清晰。

它创建了一个包含这些字节的字符串,所以如果我们发送非 UTF-8 字节,它将失败。一旦我们从缓冲区的开头取出字节,下一个索引应该指向缓冲区中的第一个元素。如果没有a在缓冲区中,它将只更新索引到最后读取的元素,并返回None,因为没有准备好完整的帧。当连接关闭时,decode_eof()方法将执行类似操作。我们使用字符串作为编解码器的输出,但你也可以使用任何结构或枚举来表示你的数据或命令,例如。

我们还需要实现Encode特质,这样我们就可以使用 Tokio 的framed()方法。这仅仅表示如果我们想再次使用字节,数据将如何编码在一个新的字节数组中。我们只需获取字符串的字节并将一个a附加到它。不过,我们会丢失换行信息。让我们看看它是什么样子:

impl Encoder for ADividerCodec {
    type Item = String;
    type Error = io::Error;

    fn encode(&mut self, chunk: Self::Item, buf: &mut BytesMut)
    -> Result<(), io::Error> {
        use bytes::BufMut;

        buf.reserve(chunk.len() + 1);
        buf.put(chunk);
        buf.put_u8(b'a');
        Ok(())
    }
}

为了了解它是如何工作的,让我们实现一个简单的main()函数,并使用 Telnet 发送包含a字母的文本:

use tokio::prelude::*;
use tokio::net::TcpListener;

fn main() {
    let address = "127.0.0.1:8000".parse().unwrap();
    let listener = TcpListener::bind(&address).unwrap();

    let server = listener
        .incoming()
        .map_err(|e| eprintln!("Error accepting connection: {:?}", e))
        .for_each(|socket| {
            tokio::spawn(
                socket
                    .framed(ADividerCodec::default())
                    .for_each(|chunk| {
                        println!("{}", chunk);
                        Ok(())
                    })
                    .map_err(|e| eprintln!("Error: {:?}", e)),
            )
        });

    println!("Running Tokio server...");
    tokio::run(server);
}

我们可以发送这样的文本,例如:

图片

服务器中的输出将类似于这个:

图片

注意,我没有关闭连接,所以最后一句话的最后部分仍然在缓冲区中。

Rust 中的 WebSocket

如果你从事 Web 开发,你知道 WebSocket 是加速与客户端通信的最有用的协议之一。使用它们允许你的服务器在客户端请求之前向客户端发送信息,从而避免一个额外的请求。Rust 有一个非常好的 crate,允许实现 WebSocket,名为websocket

我们将分析一个小的、异步的 WebSocket 回声服务器示例,以了解它是如何工作的。我们需要将websocketfuturestokio-core添加到我们的Cargo.toml文件的[dependencies]部分。以下示例是从websocket crate 中的异步服务器示例检索并改编的。它使用 Tokio 反应器核心,这意味着它需要一个核心对象及其句柄。WebSocket 需要这种行为,因为它不是一个简单的 I/O 操作,这意味着它需要一些包装器,例如连接升级到 WebSocket。让我们看看它是如何工作的:

extern crate futures;
extern crate tokio_core;
extern crate websocket;

use websocket::message::OwnedMessage;
use websocket::server::InvalidConnection;
use websocket::async::Server;

use tokio_core::reactor::Core;
use futures::{Future, Sink, Stream};

fn main() {
    let mut core = Core::new().unwrap();
    let handle = core.handle();
    let server = Server::bind("127.0.0.1:2794", &handle).unwrap();

    let task = server
        .incoming()
        .map_err(|InvalidConnection { error, .. }| error)
        .for_each(|(upgrade, addr)| {
            println!("Got a connection from: {}", addr);

            if !upgrade.protocols().iter().any(|s| s == "rust-websocket") {
                handle.spawn(
                    upgrade
                        .reject()
                        .map_err(|e| println!("Error: '{:?}'", e))
                        .map(|_| {}),
                );
                return Ok(());
            }

            let fut = upgrade
                .use_protocol("rust-websocket")
                .accept()
                .and_then(|(client, _)| {
                    let (sink, stream) = client.split();

                    stream
                        .take_while(|m| Ok(!m.is_close()))
                        .filter_map(|m| match m {
                            OwnedMessage::Ping(p) => {
                                Some(OwnedMessage::Pong(p))
                            }
                            OwnedMessage::Pong(_) => None,
                            _ => Some(m),
                        })
                        .forward(sink)
                        .and_then(|(_, sink)| {
                            sink.send(OwnedMessage::Close(None))
                        })
                });

            handle.spawn(
                fut.map_err(|e| {
                    println!("Error: {:?}", e)
                }).map(|_| {}));
            Ok(())
        });

    core.run(task).unwrap();
}

如你所见,大部分代码与之前示例中使用的代码非常相似。我们首先看到的变化是,对于每个连接,在实际上接受连接之前,它会检查套接字是否可以升级到rust-websocket协议。然后,它将连接协议升级到该协议并接受连接。对于每个连接,它将接收客户端句柄和一些头信息。当然,所有这些操作都是异步完成的。

我们丢弃了头部信息,并将客户端分为一个 sink 和一个 stream。在futures术语中,sink 是同步写者的异步等价物。它从流中开始读取字节直到关闭,并且对每个字节,它都会回复相同的信息。然后它会调用forward()方法,该方法消耗流中的所有消息,然后发送一个连接关闭消息。我们刚刚创建的 future 将使用我们从核心中获取的处理程序来启动。这意味着对于每个连接,这个整个 future 都将被执行。然后 Tokio 核心运行整个服务器任务。

如果你从 crate 的 Git 仓库中获取示例客户端实现(github.com/cyderize/rust-websocket/blob/master/examples/async-client.rs),你将能够看到服务器如何回应客户端发送的内容。一旦你理解了这段代码,你将能够创建任何你需要的 WebSocket 服务器。

理解新的生成器

2018 年,Rust 将迎来一个新特性——异步生成器。生成器是可以在返回函数之前产生元素并在稍后继续执行的功能。这对于我们在本章中看到的循环来说非常棒。有了生成器,我们可以直接用新的async/await语法替换许多回调。

这仍然是一个不稳定的功能,只能在 nightly 中使用,所以你写的代码在稳定之前可能会变得过时。让我们看看一个简单的生成器示例:

#![feature(generators, generator_trait)]

use std::ops::{Generator, GeneratorState};

fn main() {
    let mut generator = || {
        for i in 0..10 {
            yield i;
        }
        return "Finished!";
    };

    loop {
        match generator.resume() {
            GeneratorState::Yielded(num) => println!("Yielded {}", num),
            GeneratorState::Complete(text) => {
                println!("{}", text);
                break;
            }
        }
    }
}

你需要执行rustup override add nightly来运行示例。如果你运行它,你将看到以下输出:

这里有趣的是,生成器函数可以执行任何计算,一旦部分结果被产生,你就可以恢复计算,而不需要缓冲区。你可以通过以下方式来测试这一点——不是从生成器中产生任何东西,而是用它来在控制台打印。让我们看一个例子:

#![feature(generators, generator_trait)]

use std::ops::Generator;

fn main() {
    let mut generator = || {
        println!("Before yield");
        yield;
        println!("After yield");
    };

    println!("Starting generator...");
    generator.resume();
    println!("Generator started");
    generator.resume();
    println!("Generator finished");
}

如果你运行这个示例,你将看到以下输出:

如你所见,当函数执行到yield语句时,它会暂停其执行。如果在yield语句中有任何数据,调用者将能够检索它。一旦生成器被恢复,函数的其余部分将继续执行,直到遇到yieldreturn语句。

当然,这对我们之前看到的futures非常有优势。这就是为什么创建了futures-awaitcrate。这个 crate 使用生成器使异步futures的实现变得容易得多。让我们用这个 crate 重写我们之前创建的 TCP 回显服务器。我们需要将futures-await0.2.0版本添加到我们的Cargo.toml文件的[dependencies]部分,然后开始使用一些 nightly 特性。让我们看看一些示例代码:

#![feature(proc_macro, conservative_impl_trait, generators)]

extern crate futures_await as futures;

use futures::prelude::*;
use futures::executor::block_on;

#[async]
fn retrieve_data_1() -> Result<i32, i32> {
    Ok(1)
}

#[async]
fn retrieve_data_2() -> Result<i32, i32> {
    Ok(2)
}

#[async_move]
fn add_data() -> Result<i32, i32> {
    Ok(await!(retrieve_data_1())? + await!(retrieve_data_2())?)
}

fn main() {
    println!("Result: {:?}", block_on(add_data()));
}

这个例子将包含两个异步函数,例如,它们可以从网络上检索信息。它们由add_data()函数调用,该函数将在将它们相加并返回结果之前等待它们返回。如果你运行它,你会看到结果是Ok(3)。导入futures_await crate 作为futures的行是有意义的,因为futures-await crate 只是 futures crate 的一个小包装,所有常用的结构、函数和特性都是可用的。

整个生成器和async/await语法仍在积极开发中,但 Rust 2018 路线图表示它应该在年底前稳定下来。

摘要

在这本书的最后一章中,你学习了如何使用异步编程来避免创建过多的线程。现在你可以使用恰好数量的线程,同时在网络应用程序中并行且高效地运行工作负载。为了能够做到这一点,你首先学习了关于 futures crate 的知识,它为我们提供了在 Rust 中进行异步编程时所需的最基本原语。然后,你了解了基于 MIO 的 Tokio 是如何工作的,并创建了你的第一个服务器。

在了解外部 crate 之前,你学习了 WebSockets 并掌握了 Tokio 核心反应器语法。最后,你学习了新的生成器语法以及futures crate 是如何适应这种新语法的。确保关注关于这个伟大的编译器特性何时稳定的最新消息。

现在书已经结束,我们可以看到在 Rust 中可以通过多种互补的方式来实现高性能。我们可以首先从改善我们在第一章中看到的顺序代码开始。这些改进来自各种技术,从适当的编译器配置开始,到代码中的小技巧和窍门结束。正如我们所见,一些工具将帮助我们完成这项工作。

然后,我们可以使用元编程来提高代码的可维护性和性能,通过减少软件在运行时必须执行的工作量。我们看到今年有新的元编程方式进入 Rust。

最后,使事物更快的一步是并发运行任务,正如我们在最后两章中看到的。根据我们项目的需求,我们将使用多线程或/和异步编程。

现在,你应该能够提高你的 Rust 应用程序的性能,甚至开始学习高性能编程的更深入概念。我很高兴能引导你通过 Rust 编程语言中的这些主题,希望你喜欢阅读。

posted @ 2025-09-06 13:42  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报