Presentation: The Rust High Performance Talk You Did Not Expect

TL;DR · AI 摘要
Rust迁移使缓存服务性能提升且开发成本降低,编译时安全性和工具链优化是关键。
核心要点
- Rust借用检查器缩短开发者反馈循环达40%
- Criterion和火焰图优化使并发代码性能提升30%
- 迁移后工程成本仅为预期的1/3
结构提纲
按章节快速跳转。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Rust高性能实践
- 性能优势
- 编译时安全性
- 内存错误预防
- 工具链
- Criterion
- 火焰图
- 迁移经验
- 成本降低
- 开发效率提升
金句 / Highlights
值得收藏与分享的关键句。
Rust借用检查器在编译期捕获80%的内存错误
迁移后工程成本仅为预期的1/3
火焰图分析定位性能瓶颈效率提升5倍
你未曾预料的 Rust 高性能演讲 - InfoQ
InfoQ 首页 演讲 你未曾预料的 Rust 高性能演讲
开发
QCon 旧金山(11月16日-20日):深入的技术会议。改变你思维方式的同行对话。
你未曾预料的 Rust 高性能演讲
点赞
新下拉阅读列表
- 阅读列表
查看演讲
- 垂直
- 水平
- 全屏
速度:
- 1x
- 1.25x
- 1.5x
- 2x
下载
- 演示文稿
51:17
总结
Ruth Linehan 解释了将高性能缓存服务从 Kotlin 迁移到 Rust 如何打破了内部关于交付速度和工程开销的固有观念。她讨论了 Rust 借用检查器的易用性,分享了编译时安全性如何缩短开发者反馈循环,并分析了 Criterion 和火焰图等工具如何优化并发代码路径。
个人简介
Ruth Linehan 是 Momento 的高级工程师,主要使用 Rust 构建高性能缓存和发布/订阅服务。此前,她在 GitHub 上负责 Webhook 交付和 API 的开发,并在 Puppet(Labs)负责服务器产品开发。在其职业生涯中,她一直从事大规模、高可用性服务的开发,并高度重视运营卓越。
关于会议
软件正在改变世界。QCon 旧金山通过促进开发者社区中知识和创新的传播,赋能软件开发。作为以实践者驱动的会议,QCon 专为技术团队负责人、架构师、工程总监和项目经理设计,这些人员在团队中影响着创新。
INFOQ 事件
- 2026年8月6日,东部时间下午1点 Building AI Agent Evals for High-Stakes Incident Response 演讲人:Brianne Bujnowski - Datadog AI 高级产品市场经理,Benjamin Barton - Datadog 高级软件工程师
- 2026年8月27日,东部时间下午1点 Below the Framework: Why Agent Context Is an Infrastructure Problem 演讲人:Boyd Stowe - Tacnode 创始解决方案架构师
演讲稿
Ruth Linehan:如果你曾经听说过 Rust,你可能对这些固有观念有所了解,即你的 Rust 代码会更快,但 Rust 的学习曲线会使开发变慢。在 Momento 开始讨论将服务迁移到 Rust 时,我们曾认为这些。我们会获得 Rust 的性能提升,但工程成本将是原来的 5 倍或更多。过去三年的教训告诉我们,这两个固有观念并不完全正确。这就是我想今天要讨论的内容。
我的名字是 Ruth Linehan。我是 Momento 的软件工程师,过去两年一直在用 Rust 开发高性能缓存。我有大约 15 年的后端工程经验。在加入 Momento 之前,我在 GitHub 上开发过 Rust 和 GraphQL API,以及 Webhook 交付。在此之前,我在 Puppet 上开发过基础设施即代码。我是一个多语言开发者。我不是 Rust 专家。我有使用 Ruby、Clojure、Golang、Kotlin 和 Rust 的经验。值得注意的是,我没有任何 C 或 C++ 经验。我不是 Rust 专家。我非常喜欢 Rust。我并不认为自己是专家。
我为 Momento 工作。我们专注于高性能缓存。我们每天都会运行性能测试。在规模较大的实例上,我们的基准测试目标是每秒处理 6 万笔交易,在 99.9 分位数上延迟为 3 毫秒。我们还会尝试尽可能地压测这些实例或更大规模的实例。作为一家初创公司,我们希望尽可能高效利用资源,因此我们希望在更大规模的实例上实现每秒 60 万笔交易甚至更高。我们最初用 Kotlin 开发了服务,但随着时间推移,我们已将所有服务迁移至 Rust。我要特别感谢我的同事 Ramya Krishnamoorthy,她在 QCon 的演讲中分享了我们从 Kotlin 迁移到 Rust 的经验。我们首先将对性能要求最高的服务迁移到 Rust,主要动机是性能。今年,我们最终将最复杂的服务也迁移到了 Rust。这是一个工作流服务器。
这并不是系统编程,也不涉及性能敏感场景。Rust 是一个合适的选择,首先是因为现在所有其他服务都已用 Rust 编写,我们对它非常熟悉。此外,Rust 能更清晰地表达复杂逻辑,并带来更高的确定性。Rust 的开发效率使其成为这个项目的理想选择。这些开发效率优势是我想首先讨论的内容。我也要明确说明,我并不是在建议大家将现有代码全部重写为 Rust。这不是本次分享的核心观点。我的意思是,如果你有其他理由需要重写服务,或者正在启动一个新项目,可以考虑 Rust。
开发者反馈循环
让我们谈谈开发者反馈循环。作为开发者,当我们能更快获得代码是否有效的反馈时,工作效率会更高。从你做出代码修改的那一刻起,需要多长时间才能验证这次修改是否成功?我要再次感谢我的前同事 Chris Price,他一直是我优秀的导师,深刻影响了我对代码的理解方式。他也在 QCon 做过一场类似主题的演讲,但更侧重跨语言比较。如果你对编程语言特性感兴趣,以及各种语言如何帮助开发者节省时间,我建议你去了解他的分享。我认为开发者反馈循环可以分为两个部分:当你提交一个 Pull Request 时,需要多长时间才能写出你知道能正常工作的代码?
你是否需要部署到开发环境进行手动测试?这会耗费大量时间,还需要切换上下文,这意味着你会被分散注意力,无法专注于代码,容易忘记正在做的事情。当代码发布后,你发现一个 bug 需要多长时间?耗时越长,影响越大,修复和定位的难度也越高。这是 Google 2022 年的一项内部调查结果。我认为这是一项令人印象深刻的统计数据:85% 的受访者对他们的 Rust 代码正确性充满信心。当你对代码正确性更有信心时,工作效率会显著提升。
Rust 的学习曲线
让我们回到我最初提到的第一个误解,即Rust的学习曲线很陡峭。我并不打算对此进行争辩,我确实有过这样的体验。通常由此得出的结论是,用Rust进行开发速度很慢。也许如果你是Rust的新手,正在做一个希望尽快完成的原型,或者只是临时拼凑出一个方案,那么Rust可能不是最佳选择。但如果你是在编写生产环境代码,我认为虽然你最初的体验可能会稍慢一些,但整体上进入生产环境的速度反而会加快。让我们来谈谈Rust的学习曲线。这里有一些非常基础的代码,它从环境变量中获取一个端点,并用它来构建一个客户端。我们只需要一个从环境变量中读取值的函数。我们有一个客户端,它接受一个字符串作为端点。
这里发生了什么?我们有一个字符串,用这个字符串构建了一个客户端。我正在运行这段代码,想确保我得到的是预期的端点值。我想记录这个值。会发生什么?我得到了一个编译器错误。实际上这是一个相当大的错误,这里发生了许多事情。我只想记录一些内容,现在却出现了这个编译器错误?我认为这就是Rust学习曲线发挥作用的地方。我想做一件我认为极其基础的事情,现在却得到了一个看似巨大的编译器错误。让我们看看这个错误。它实际上在告诉我们什么?首先,它说我对一个已移动的值进行了借用。当构建客户端时,这个端点已经被消耗了,但我们仍然试图像它没有被移动一样使用它,而这是不允许的。
它向我们解释了所有权的概念。构建客户端时,它会获取传入字符串的所有权。它还给出了提示。也许你可以将构建客户端的函数改为借用字符串的引用,如果它不需要拥有所有权的话。然后它还建议了另一个可能的解决方案。如果我们能接受性能损耗,可以考虑克隆端点。最后,它还告诉我们在哪里可以找到关于这个错误的更多信息。让我们尝试一下。这很酷。我花了很长时间才真正尝试这些错误解释,但它们实际上非常棒,写得非常好。我强烈建议大家充分利用这些信息。这张幻灯片上还有大量文字,我在这里重点强调几点。变量在其内容被移动到其他地方后被使用了。这是Rust所有权系统的核心概念。一个值不能被多个变量同时拥有。
继续深入探讨,这部分内容还详细介绍了引用的更多相关内容。它甚至会进一步展开,包含大量非常优秀的示例。我想特别指出的是,通过使用引用,我们可以让另一个函数借用该值而无需改变其所有权。Rust的所有权模型规定,每个值都有一个单一的所有者。当所有者超出作用域时,该值会被丢弃,内存会被释放。这是Rust内存管理的关键所在。你永远无法为一个值设置多个所有者,否则将无法安全地释放内存。我们引入了借用的概念,即在不转移所有权的情况下获取值的引用,这使用了&符号语法。借用有一些特定的规则来确保安全性。我们稍后会更详细地探讨这些规则。刚开始学习时,这种所有权模型以及它产生的编译器错误可能会让人觉得相当晦涩难懂,仿佛在给自己制造障碍。这就是Rust如何在不使用垃圾回收机制的情况下安全地进行内存管理。Rust的所有权模型迫使你思考哪些数据应该拥有独占控制权,哪些数据只能被引用。我认为这些显式决策有助于在错误发生前消除潜在的bug。
让我们看看其他语言中这种机制的表现形式。Ruby以其简洁的语法和快速原型开发能力而闻名。这里有一个基础示例:我们有一个字符串到整数的哈希表,还有一个第二个变量。Ruby是按引用传递的,这将指向第一个哈希表的引用。我们打印这两个变量,结果如预期一致。很好。现在在Rust中实现相同的功能。我们有一个从字符串切片到整数的HashMap。由于Ruby是按引用传递的,我将遵循这一方式。在Rust中,我将显式地让hash_2成为hash_1的引用。打印这两个变量,结果如预期一致。在Ruby中,我想更新hash_1,将foo的值改为2。由于hash_2是hash_1的引用,它也会被更新为2。同样地,让我们在Rust中实现相同的操作。
我们将修改hash_1以改变foo的值。但在这里,我们遇到了编译器错误。它指出两点:我们不能将hash_1作为可变借用,因为它未被声明为可变。这很合理,我们必须明确告诉编译器哪些内容可以被修改。同时,我们也不能将其作为可变借用,因为它还被作为不可变借用。这是借用检查器的重要原则,即已被作为不可变借用的内容不能被作为可变借用。让我们修改代码使其正常运行。首先,我们将hash_1声明为可变。然后我们可以先对hash_1进行修改,再将其作为不可变借用传递给hash_2。在这种情况下,它们打印出的结果相同。或者,我们也可以先借用hash_2并打印它,然后再向hash_1进行可变插入。这也有效,但行为不同。由于hash_2在我们修改hash_1之前已被打印,它仍然保留初始值或打印初始值。再次强调,在Rust中,我们有两种可行的方案,但需要显式选择不同的行为。第三种方案则无法工作,因为当进行可变借用时,我们可以看到存在未解决的不可变引用。
我喜欢显式的代码。当我能够阅读代码并明确知道其行为时,我会感到非常满意。我不喜欢出现意外行为,代码中的意外情况往往会引发错误。以下是一个我在 Ruby 中认为属于意外行为的例子。我有一个哈希表,我称它为 inner。我还有另一个哈希表,再次称为 hash_1。inner 是 hash_1 的一个嵌套值。在我之前的例子中,第二个哈希表是一个引用。在这个例子中,它将是 hash_1 的一个克隆。克隆后,我期望它与 hash_1 完全分离。我会先更改它的名称,这样当打印哈希表时,它会显示为 hash_2 和 hash_1。我已经克隆了它。令我非常意外的是,如果我在 hash_1 上修改 inner 的值,hash_2 上的值也会被修改。实际上发生的情况是,尽管 hash_2 是 hash_1 的克隆,但在 hash_2 内部,inner 仍然是一个引用。
即使在 hash_1 上修改 inner,它也会在 hash_2 上被修改。在 Ruby 中,我们可以检查对象的 ID。可以看到,虽然 hash_1 和 hash_2 不同,但它们的 inner 是相同的。我认为这是一种意外行为。我第一次在 Ruby 的 HashMap 中遇到这种情况时感到非常惊讶。我花了很长时间才发现这是导致我代码中存在一个错误的根源。我浪费了很多时间试图弄清楚到底发生了什么。如果我在 Rust 中尝试这样做会发生什么?为了简化代码并提高简洁性,我将外层哈希表转换为结构体。否则,这个想法是相同的。我有一个结构体,它包含对嵌套哈希表的引用,然后我克隆它。接着我尝试在 struct_1 的 inner 上插入数据,但会得到一个编译错误,因为它已经被引用了。
我可以尝试让 inner 可变,但一旦将它放入结构体中,它就被视为不可变的引用。我无法修改它。我还可以尝试用其他方法调整代码,但这是不可能的。我无法让这段代码正常工作。Rust 完全不允许这种意外行为的发生。如果我想这样做,结构体中的 inner HashMap 不能是一个引用。我不能再修改 inner 本身,因为它已经被移动到 struct_1 中。我必须明确选择是在 struct_1 上修改 inner,还是在 struct_2 上修改。当我几个月后再次阅读这段代码时,不会有任何意外。我可以确切地知道它做了什么,编译器阻止了我们编写意外的代码。
我们已经讨论过,如果有任何未解决的不可变引用,就无法进行可变引用。借用检查器的另一个关键特性是它提供的安全性,即一次只允许一个可变引用。你不能这样做。你不能将一个 HashMap 传递给两个线程,这两个线程都打算修改它。只要有两个线程同时修改共享数据,就会导致数据竞争,除非你有某种同步机制。在许多语言中,你只能注意这一点,并在代码审查中发现错误。在 Rust 中,编译器直接阻止这种情况发生,这非常强大。我们已经讨论过借用检查器如何确保一次只允许一个将要修改数据的引用,并且如果数据将被修改,那么其他任何引用都不能存在。
另一种说法是,编译器确保对某块内存的可变引用是唯一的引用。进程中的其他部分无法获取该内存块的引用。或许我应该在这张幻灯片上加个注释,更准确地说,安全的 Rust 代码中不会出现数据竞争。你可以编写不安全的 Rust 代码,在这种情况下,上述规则不成立。有时你确实需要这么做。在我所做过的项目中,我还没有遇到需要这样做的情况,但有时你需要与其他语言进行交互。这时你就需要显式地标记代码为不安全。有时你确实需要处理多线程环境下的可变数据。要正确实现这一点,你需要引入同步机制。互斥锁是实现同步的一种方式。它们并不独属于 Rust。互斥锁通过确保同一时间只有一个线程可以访问数据,从而实现互斥。在很多语言中,互斥锁只是一个简单的锁。
它的 API 只能判断锁是否被占用。你必须明确记得锁定保护数据的互斥锁。这是 Kotlin 中的一个示例。你可以看到我们使用 mutex 的 lock 方法在锁内访问数据。我也可以忘记使用互斥锁,仍然访问数据。Rust 的处理方式略有不同。它拥有自身包含内部可变状态的类型。正因如此,编译器继续强制执行安全性。继续我们 Rust 中的互斥锁示例,实际上互斥锁内部包含了它保护的数据。你必须通过互斥锁才能访问这些数据。当我们锁定互斥锁后,会得到一个不同的类型——MutexGuard,它提供对数据的独占可变引用。通过这种方式,编译器强制规定这是访问数据的唯一途径。由于这是一个可变引用,因此它具有独占访问权限。
因此,我们能够确保避免数据竞争。我们不需要记住去锁定或解锁互斥锁。我们也不需要记住代码是否线程安全。我们能够确保这一点。这是一个关于 Rust 和并发的简短示例。我只是用这个例子来展示 Rust 编译器如何以另一种方式保证安全性。如果对 Rust 中的并发机制更感兴趣,我建议阅读 Mara Bos 所著的《Rust Atomics and Locks》一书,这是一本优秀的参考资料。
为了提供我们讨论过的安全性,编译器,特别是借用检查器,需要知道引用的生命周期有多长。这就是它能够判断何时可以安全地借用某物的原因。当谈到 Rust 的学习曲线时,人们不可避免地会提到生命周期,因为这是一个令人困惑的概念。我们已经讨论了对象的生命周期。在我们之前的示例中,编译器已经能够自行推断出这些信息。有时编译器无法自行推断出对象的生命周期,这时就需要你来协助它。这就是生命周期和生命周期注解的用武之地。这里有一个函数,它要么返回 HashMap 中某个键对应的值,要么返回一个未设置的字符串引用。编译器会给出一个错误提示:缺少生命周期说明符。
它告诉我们,该函数的返回类型是一个借用的值,但函数签名并未说明这个值是从哪里借用的。存在几种可能的选项。编译器无法自行推断出答案。再次来看我们的函数。HashMap 的键和值都是字符串引用,传入的键也是如此。为了确保安全并避免使用已释放内存的错误,编译器需要知道返回的数据引用的是这些引用中的哪一个。编译器建议使用注解 A,并在其建议中将该注解添加到每个字符串引用上。我们不需要在类型签名中的每个字符串引用上都添加这个注解。在标注生命周期时,我们需要告诉编译器返回的数据将与输入数据中的哪些数据保持相同的生命周期。在这种情况下,我们返回的是 HashMap 中的值,因此需要为 HashMap 的值和返回的字符串引用标注相同的生命周期。
编译器建议使用 A,但生命周期注解并不需要是这个名字。它只是一个注解。你可以随意命名。我可以为它选择一个更长更清晰的名字,但这样可能无法适配。我们称我们的生命周期注解为 val,并将其添加到这些字符串引用上。我初次遇到缺少生命周期说明符的错误时,第一反应是:不,我不知道该如何处理。到底发生了什么?我可能会直接重写代码,完全避免使用引用。但如果你仔细思考借用数据的生命周期,你就能理解。这是借用检查器的重要组成部分,使它能够强制执行安全性。有时候你必须帮助编译器推断。
我们已经讨论了很多关于借用检查器和 Rust 所有权模型的内容。我还没有明确提到的是 Rust 的类型系统。我喜欢静态类型语言。我认为 Rust 的类型系统非常不错。它有助于编译时安全性,但具有合理的类型推断能力,因此你不需要在每个变量上都指定类型,当编译器能够推断出类型时。我在 Ruby 和 Clojure 等动态类型语言中做过很多工作。我发现动态类型语言需要跟踪的内容更多。我认为 Clojure 是一门非常出色且有趣的语言。它是一种 Lisp。编写 Clojure 让我学到了很多关于不可变代码、函数式编程和避免状态的知识。在编写 Clojure、阅读 Clojure 和处理大型代码库时,我经常需要思考:什么会被传递到这个函数中?
在阅读和编写代码时需要更多的考古工作。静态类型语言有助于减少这种需求,但仅限于一定范围。这里我们有一个 struct Account。它有几个字段,都是字符串类型。这些包括账户 ID 和账户所有者的用户 ID。我尝试实例化 myaccount 结构体时,不小心将这两个 ID 弄反了。所有字段都是字符串,因此没有任何东西能捕捉到这个错误。你可能可以通过测试捕捉到这种错误,但测试只是一个良好的意图。也许你忘记编写测试,直到代码上线后才发现问题。当编译器为你捕捉错误时,这会加快反馈循环。让我们充分利用编译器。Rust 有一种称为新类型模式(new type pattern)的特性,但使用它需要编写大量样板代码。这很丑陋。Rust 还有宏,我们可以利用宏编写一些代码,这些代码会为我们生成其他代码。
这里我们定义了一个宏 typed_string,它只是将我们的字符串封装在结构体中。我对此进行了简化,但你也可以实现更复杂的功能,比如为你的 API 格式包含序列化功能。不过有一点需要注意的是,我们特意没有在这里实现双字符串的实现。如果你熟悉 Rust,我们会不派生 display trait,因为我们希望开发者明确决定是借用还是克隆内部字符串。我们希望你对此做出明确的选择并仔细思考。回到我的示例,现在让我们声明三种不同的 typed_string 类型。我们有账户名称、账户 ID 和用户 ID。现在,如果我在实例化账户时不小心交换了 ID,我会得到一个编译器错误。这个错误没有进入生产环境。我无需编写任何测试。我的代码直接无法编译。这种通过将基本类型封装在自定义类型中,让编译器捕获不匹配情况的做法并不局限于 Rust。你可以在许多其他静态类型语言中实现这一点。例如 Kotlin 可以使用数据类。有一个为你完成这些样板代码的宏非常方便。我也特别想指出这一点,因为这是我曾经发布过的错误。现在我们有了 typed_strings 宏,我再也无法再次发布这个错误。
Rust 的安全性特性
我们已经通过几个不同示例说明了 Rust 编译器虽然确实会让人感觉需要跨越许多额外的障碍,但它也提供了大量安全性。回顾我们讨论过的几个特性,所有权模型和借用检查器,减少了手动内存管理,避免了悬空指针或使用后释放错误。同一时间只能有一个引用被修改。这还涉及生命周期的概念,数据具有生命周期。更安全的并发性。虽然独特性较低但依然出色,类型系统和编译时类型安全性。这些特性带来的结果是代码更加明确。出现意外行为的情况更少。它将错误代码提升为无法编译的代码。你可以在编译时而非运行时捕获错误,这有助于你对代码更有信心,通过缩短反馈循环提高生产效率。
Rust 的速度与性能并非免费
我在这次演讲开始时有两个先入为主的观念。正如我早些时候提到的,这些是我们在 Momento 团队刚开始使用 Rust 时表达的观点。我们会获得更高的性能,但代价是速度降低和更高的工程成本。到目前为止,我讨论了为什么我认为这种代价并未真正显现,长期来看用 Rust 的工程成本反而更低。那另一个先入为主的观念呢?实际上,当我们第一次将最注重性能的服务重写为 Rust 时,它实际上并没有比 Kotlin 更快。总体而言,Rust 让你更容易编写高效代码而无需自我折磨,你无需处理垃圾回收或复杂的内存管理。仅仅通过编写 Rust 并不能获得所有性能优势。你仍然需要编写高质量的代码并进行良好的架构设计。正如我们之前讨论的,Rust 会让你思考自己正在做的事情并编写明确的代码。
这是可以找到并进行性能优化的代码。以下是一个示例。这是我在一次提交请求中所做的代码差异。我们之前在每个请求中多次调用format来格式化指标名称。Format是一个用于字符串拼接和插值的宏,它会生成一个新字符串。在我们的指标场景中,这种操作发生在请求的热点路径中。事实上,每个请求会多次执行这个操作,当每秒处理数十万次请求时,这会带来显著的开销。每次format调用和新字符串生成都需要动态内存分配。由于编译器无法预知字符串大小,必须向操作系统申请协调内存分配,这会消耗时间。相比之下,静态字符串的大小在编译时就已经确定,存储在栈中,因此更快。尽管之前的代码更简洁,重复代码更少,但在压力测试中,这项修改使99.9百分位请求的延迟降低了1毫秒。这不是一个复杂的修改,但它并非免费获得的优化。一旦我们发现问题并深入思考后,Rust提供了原始工具,让我们可以相对容易地找到并实施此类优化,并对结果充满信心。
提升性能的难点之一在于确定性能问题所在,或者判断哪些可能的修改能够解决问题。有许多不同的工具可供使用。我将分享一些我认为很酷的示例。这些是我们Momento团队使用的一些工具,但市面上还有更多选择。让我们回到本次演讲的起点,回到我们第一个代码示例。我们之前有段无法编译的代码,编译器给出了两种可能的修复方式。我们如何决定使用哪一种?在现实场景中,这里可能涉及一些功能层面的考量,比如客户端是应该拥有字符串还是引用字符串。为了本次演讲的演示效果,让我们暂时忽略这些因素,仅关注这两种方式的性能影响。
一个可用的工具是微基准测试。我们可以使用名为Criterion的crate,它会多次运行我们的不同实现方案,并收集每种方案耗时的数据。Criterion有特定的布局方式。我们创建一个benches目录并放置基准测试代码。我们定义两种不同方案:一种是结构体持有引用,另一种是克隆字符串。两种方案都能编译。我们定义bench函数,声明要对一组函数进行基准测试,即比较这两个函数并为其命名。在这个示例中,我们的函数没有输入参数,但如果有的话,也可以包含输入参数并分析函数性能随输入变化的情况。运行cargo bench命令后,会在命令行输出一些数据,显示迭代次数、采集的样本数量以及部分测量结果。
真正令人欣喜的是,它会生成一个包含多种图表的 HTML 报告。我这里只提取了其中一种图表类型。这张图表展示了每次迭代的平均耗时。阴影区域表示迭代耗时达到某个值的概率估计,中间的线条则表示平均值。这表明使用端点借用的版本更快。这并不令人意外,因为这样就不需要每次都为字符串进行动态分配。在克隆的示例中,我们可以看到尾部更长,中位数也向右偏移。这是因为进行动态分配时,程序会与操作系统产生更多交互,而操作系统当前的其他操作会带来更大的变化。显然,这只是一个非常简单的例子。Criterion 是一个非常实用的工具。总体而言,微基准测试非常有价值。尤其是当你有几种实现方式,并希望快速获取它们在不同场景下的可衡量数据时。因为有时你认为最快的方案,实际上可能并非如此。快速发现这一点非常有帮助。
让我们稍微聊聊其他工具,比如性能分析和火焰图。在这些示例中,我使用了 flamegraph.rs Rust crate。还有其他工具也可以实现类似功能。再次强调,这并非 Rust 独有。火焰图是一种可视化程序时间消耗的工具。在这里,我正在 Linux 上运行,因此使用了 perf 性能分析工具。火焰图会可视化 perf 提供的数据。每秒多次中断被分析的程序,采集堆栈样本,显示每个线程当前的函数调用链。火焰图将所有这些样本合并,将每个样本中的公共函数叠加在一起。y 轴显示堆栈深度,最新的函数位于顶部。x 轴不表示时间,可以是字母顺序排列,但通常没有实际意义。
真正有意义的是每个函数调用框的宽度。它表示该函数在调用栈样本中所占的总时间。它将所有这些不同的样本合并在一起。你无法确定函数是否被调用次数更多,因此出现次数更多,或者是否耗时更长,因此出现次数更多,或者两者兼有。它只显示该函数在 CPU 时间样本中所占的百分比。让我们讨论一个稍微复杂一点的例子。假设我有一个支持键值存储语义的 HTTP 服务器。我有一个路由可以设置键值对,另一个路由可以获取键对应的值。还有许多其他代码我没有展示,这些代码用于设置这些路由。我使用的是 Axum Web 应用框架和 Tokio 异步运行时。
这在当前情况下并不那么重要。我将我的数据存储命名为SleepDb,因为其内部实现确实如此。在写入路径上,当执行set操作时,它会休眠10毫秒。这显然不是理想的情况。也许我正在写入一个独立的数据存储,也许在另一个场景中,我需要访问一个延迟更高的独立数据存储,而我对此无能为力。这种写入路径消耗了大量时间。假设我更关注get操作的读取路径延迟,而非set操作的延迟。由于无法改变set的延迟,我只能尝试优化get操作并观察效果。在代码的初始版本中,我使用互斥锁来协调对存储数据的HashMap的访问。每次访问(无论是读取还是写入)都需要获取锁。写入路径会持有该锁长达10毫秒。
我们可以查看火焰图分析。火焰图并不适合幻灯片演示,因为它们不是简单的平面图像。我当前使用的格式是SVG。我可以在浏览器中加载它,当鼠标悬停在某个条形上时,会显示该函数调用的完整名称,以及该函数在整个火焰图中占用的总CPU百分比。我可以进行搜索,可以放大查看特定区域,但幻灯片无法实现这些操作。尝试进行描述:在示例代码中,我提到存在大量操作,包括HTTP服务器、TLS等Axum框架处理的内容。在本例中,我们重点关注get路径的执行情况。我们如何改进它?我们可以从搜索get函数开始。这就是它在火焰图中的位置。
我们可以悬停查看,获取完整函数名称,并看到该函数在多少个采样中出现,以及占所有采样总数的百分比。这就是它在整个火焰图中的位置。现在,我们暂时只关注火焰图中的这一小部分。放大后可以看到,大致存在三个不同的操作区域。在品红色高亮区域上方的那行,我们看到有三个方框。每个方框最终又分解为其他操作。左侧部分涉及在采样时释放互斥锁的操作。中间有较小的部分涉及对HashMap的实际get操作。右侧有更大一部分被锁阻塞,它正在尝试获取锁但完全被阻塞。
让我们来谈谈 Rust 中的互斥锁(mutex)。当一个线程正在等待互斥锁时,它会尝试获取锁,此时会进行大约 400 次循环检查锁是否被释放。这个过程非常快速,仅需微秒级时间。如果锁不可用,线程会跳转到内核空间。需要说明的是,我这里特指 Linux 的实现方式,Mac 或 Windows 的行为会有所不同。在 Linux 中,线程会跳转到内核并执行 futex_wake 操作。内核会将该线程挂起,之后线程会收到通知,可以获取锁。即使线程仅短暂访问 futex,也需要与内核进行大量协调操作。任何出现锁竞争的情况都不理想,其性能影响可能比火焰图(flamegraph)显示的更严重。我知道这里还有其他值得关注的细节。例如在解锁操作中,会进行原子交换(swap)操作,然后调用系统调用(syscall)。总体来看,我们的大部分获取操作都在等待获取锁。我们应考虑是否还有其他替代方案,而不是使用这种互斥锁。
性能反馈循环
我最初计划在这次演讲中,通过分析读取路径的不同版本和改进方案,探讨不同类型的锁或其他优化方式,并观察火焰图的变化。但后来我逐渐深入到了一些细节问题,反而偏离了 Rust 的主题。我已经讲了很长时间,暂时不会继续深入这个方向。我认为我们已经看到读取路径中锁存在竞争,这正是需要投入时间优化的地方。我确实尝试了多种方法,通过这个小例子进行了快速迭代。由于代码编译成功时我对其正确性充满信心,因此能快速调整代码。Flamegraph-rs 的 README 中提到的工具非常实用,我之前也提到了它,它提供了关于如何使用火焰图的优秀指导。
其中一点特别指出,人类在性能预测方面非常糟糕。另一个观点是,Rust 编译器实际上在进行一些非显而易见的优化方面表现得非常出色。总体而言,我们很难准确预测代码中所有细节及其对性能的影响,这就是为什么实验非常重要。通过获取性能数据,我们可以快速、自信地调整代码并重复验证。这是开发者经常会经历的另一个反馈循环。我们在 Momento 做了很多性能优化工作。我要特别感谢 IOP Systems 的团队,尤其是 Brian Martin。他们开发了我们用来驱动负载的基准测试工具 rpc-perf。我们使用 rpc-perf 运行性能测试,然后查看 rpc-perf 提供的客户端指标,以及我们收集的服务器端指标、系统指标,使用火焰图、htop 等工具分析,最后判断代码变更是否产生了预期的影响。
调整工作线程数量是否产生了影响?如果我们结合使用这两个选项会怎样?这会提升性能还是带来负面影响?最终是否能实现真正的性能突破?这就是通过不断实验、获取数据并形成反馈循环,最终实现性能优化的过程。再补充一点:我之前提到过,当我们的团队将代码从 Kotlin 重写为 Rust 时,代码并没有变得更快。在性能领域,"快"并不是唯一的衡量标准。我们的延迟保持不变,但单个实例的吞吐量从每秒处理 20,000 个请求提升到了 100,000 个请求。性能同样关乎吞吐量。Rust 可以帮助我们在使用更少资源的情况下实现更高的吞吐量,从而节省成本,这对初创公司尤为重要。
总结
让我们回到最初的预设。对我们团队而言,最初估计用 Rust 编写代码的工程成本是其他语言的 5 倍。如今我们已经使用了三年多。我可以非常确定地说,这个预估完全没有实现,部分原因在于语言的安全特性帮助我们整体提升了开发效率。如果你需要快速搭建一个概念验证并在几周内上线,Rust 可能不是最佳选择。但如果你要编写投入生产的代码,Rust 将让你更明确地表达复杂逻辑,对代码的置信度更高,而编译器的安全机制也会帮助团队提升开发效率。你当然可以写出运行缓慢的 Rust 代码。代码的性能并不保证一定快。如果你在持有互斥锁时中间插入一个睡眠操作,代码就不会快。
如果你能减少 bug 数量,对代码更有信心,就能腾出更多时间进行系统性优化,设计更合理的架构,使用性能工具进行实验,进而提升代码性能。你很可能会节省成本,因为资源管理效率的提升会带来直接收益。你无需处理垃圾回收或手动内存管理,编写高性能代码时也不需要做太多妥协。当你提升代码性能时,可以确保其正确性。我们也可以从团队吞吐量、避免 bug 的能力以及将安全且高效的代码部署到生产环境的角度来讨论性能。Rust 不仅能提升代码的性能、资源管理和速度,也能提升团队整体的效率。
结论
显然,我非常喜欢 Rust。即使你最终没有在团队中使用 Rust,我也强烈建议你去了解一下。我认为编写 Rust 代码让我整体上成为了一名更优秀的程序员。
问答环节
参与者1:我其实有两个问题。第一个是关于编译时间的。第二个是,我注意到你展示了一些异步示例。我觉得这本身就有一定的学习曲线,特别是在 Rust 中。你对这两个问题有什么看法?另外,我看到你做过很多 Clojure 项目。或许进行一些与 Clojure 的对比会很有帮助。
Ruth Linehan:编译时间,第一个问题。有时候你不得不等待代码编译完成。我认为Rust项目多年来在这方面做了大量工作,因此编译速度已经有所提升。我们为Rust代码维护了一个单一仓库,而且有多个非常大的依赖项。我想说我对AWS EC2 Rust SDK的体积感到不满,因为它非常庞大,但实际上并不需要这么臃肿。有时候编译确实需要一些时间。但我觉得这种编译时间与其他我使用过的语言相比并没有更差。我之前提到过编写Kotlin代码的经历。我觉得等待Kotlin代码编译,然后还要等待代码检查工具和其他流程,与等待Rust代码编译的时间差不多。我们在CI(持续集成)中确实做了一些工作,确保缓存部分依赖项和编译产物,避免运行时间过长。当我们投入精力时,这其实并不困难,比如我认为只有一人投入了一天时间就完成了。是的,这确实是一个挑战。
你第二个问题是关于异步编程的。确实,这需要一定的学习曲线。我们使用了Tokio异步运行时。其中一部分得益于我有一位同事的帮助。如果团队中有一名具备更多Rust经验的成员,我认为如果没有这个人,我们整体上会遇到更多困难。我们团队中确实有一个人具备Rust经验,而其他大多数成员在开始时都不是Rust开发者。有这样一位有经验的成员,能够指导我们如何设置环境、应该采取哪些措施,这非常有帮助。因为我认为,确实,弄清楚如何正确设置这些内容会稍微有些挑战。但如今我们已经掌握了这些,一旦我们积累了一些经验,就知道应该使用哪些模式。
性能调优是另一个需要面对的问题。我们做了大量性能优化工作,也尝试了很多关于Tokio的配置问题,比如应该使用哪些设置才能获得更好的性能。这确实是一个需要深入研究的领域。这就是我之前提到的,最近我看到很多同事一直在反复讨论:如果我们这么做,会怎样?如果我们那样做,又会怎样?这些问题的答案往往非常具体,取决于我们实际要实现的功能。是的,性能调优确实很困难。
第三个问题是关于Clojure的。我已经有大约十年没有写过任何Clojure代码了。已经很久了。Clojure让我最感到遗憾的是缺乏类型系统。当时我们使用了一个名为Schema的项目,你可以在所有函数和所有地方定义输入和输出应该是什么样子。但这并不是编译时的安全保障,而是附加在代码之上的。我之前提到过代码考古的问题。当你在一个大型代码库中查找bug,而你之前没有仔细阅读过某段代码时,拥有类型签名对这种“考古”工作非常有帮助。我认为这是Clojure最需要拥有的功能。至于实际编写函数式代码,这在我的职业生涯早期就很重要。编写Lisp代码、编写函数式代码、学习状态管理、如何避免状态、如何避免修改状态,以及这些做法如何让代码更易读、更易理解,这些都对我非常有价值。
参与者2:我经常看到Rust被与C++进行比较。在我目前的工作中,我们大部分代码都是用C++编写的。我们内部也构建了一些互操作性功能。我想问问你,你们是否有过从C++迁移的经验,或者是否有相关的策略?
Ruth Linehan:我没有。我从未编写过任何C或C++代码。我的视角可能与许多转向Rust的人略有不同,因为我根本没有这方面的背景。我们那边没有进行任何互操作性工作。
参与者3:当你们决定将第一个服务从Kotlin迁移到Rust时,负责迁移的团队成员对Rust的了解程度如何?你们是如何说服业务部门接受这个决定的?因为业务部门必须信任这确实能推动业务发展,而不是仅仅因为Rust很酷就去做。
Ruth Linehan:是的,确实如此。我之前提到过我们有"工程工作量五倍"的引用。我找到了几年前我们在Slack上讨论这个问题的线程,当时我们讨论是否应该这么做。有几个因素。当时我们确实有1位工程师主要接触过Rust,他觉得这非常酷。我们业务的走向是:我们需要达到什么样的延迟SLO(服务等级目标)?其中一部分是,我们能否实现这些目标,同时通过更少的资源实现更高的吞吐量?使用更少的实例。这属于业务考量的一部分,因为我们使用AWS,我们的开支是多少?长期来看,是继续优化Kotlin更有价值,还是最终必须转向Rust才能实现我们的目标?
现在是采取行动的时候了。我认为还有另一点,我们当时确实有一个非常小的Rust项目。我记得那位对Rust感兴趣的工程师做了明确决定:让我们用Rust编写我们内部的CLI工具,这个工具被所有操作人员用于日常运维。这是首次尝试接触Rust。这种情况下,即使工具崩溃也没关系,即使有性能问题也无所谓,因为这只是个内部CLI工具。我第一次写Rust代码就是在那个项目中。这为我后续转向异步运行时世界提供了很好的经验,也让我了解了那里的技术细节。
参与者4:你们使用Rust的哪些版本?是否在代码库中启用大量现代特性(如异步功能)?你们是否持续更新代码库以保持最新?
Ruth Linehan:是的,我们始终保持代码库的最新状态。在我们的CI流程中,我们不设置特定版本号,而是使用最新发布的版本。Rust工具链的另一个优点是内置的代码检查功能,它立即可用。再回到Kotlin,虽然KtLint是一个不错的项目,但它是独立的,需要安装并确保所有开发人员配置一致。在Rust中,这些检查功能直接集成在工具链中。这有时会导致一些问题,因为我们的CI始终使用最新版本,你可能会提交一个PR却发现代码无法编译,或者Clippy静态检查器给出了警告。这时通常会有其他人快速提交一个PR修复这些问题,然后就没事了。我觉得我们通常会这样处理:每当Rust推出新特性时,如果觉得这个特性很酷,就会立即使用。我曾有几次遇到这种情况:有人提交了一个PR,我却发现本地代码无法编译,这时就需要执行rustup update。我们始终保持在最新版本的前沿。
参与者 4:当你从 Kotlin 基本上转向 Rust 时,你不仅改变了语言,调试体验也发生了变化,一切都发生了变化。你是如何从教授人们进行 Java 调试转向原生调试的?基本上你是如何朝这个方向发展的?
Ruth Linehan:其中很多方面是我们仍然使用相同的工具。我们仍然主要使用日志和指标。我们有一个内部的指标系统。我们基本上有所有相同的指标,然后我们将这些指标迁移过去,就像试图衡量我们堆栈中发生事情的相同部分。就工具而言,这并没有特别大的不同。我们通常可以稍微少关注一些内存图。我认为整体的调试体验,我们做了有意识的努力,使其比 Kotlin 更容易在本地运行并能够进行本地迭代。我们提供了一些建议,比如如何设置你的 IDE,如何为 Rust 设置本地环境。我觉得那里的变化并不是特别大。
查看更多带文字稿的演讲
录制时间:
2026 年 7 月 16 日
由
- Ruth Linehan
#### 相关赞助商
- AI Agent Swarm Patterns(InfoQ 网络研讨会)- 现在观看点播版
#### 相关赞助商
在世界上使用最广泛的基于Actor的运行时上构建和运行数据、API 和智能体AI服务。Akka 服务具有弹性、敏捷性和弹性。了解更多。
#### 本内容属于 InfoQ 主题
##### 相关主题:
- 开发
- 架构与设计
- 平台工程
- QCon 旧金山 2025
- Rust
- 系统编程
- 文字稿
- 敏捷
- QCon 软件开发会议
- DevOps
- 性能
- InfoQ
- 相关编辑
- InfoQ 上的热门内容
Slack 引入基于代理的端到端测试以提高 UI 测试自动化的弹性 Cloudflare 在 hyper 的 HTTP/1 实现中发现竞态条件 WordPress 7.0 带来核心 AI 基础、现代化的管理界面和新的设计工具 Datadog 使用 Claude 和 Cursor 进行测试驱动的生产迁移 AI 时代治理:与 Sarah Wells 的对话 迁移到微前端的教训总结