在 Figma,性能是我们最重要的特性之一。我们努力使团队能够以思考的速度工作,而我们的多人同步引擎是这一愿景的关键部分。每个人都应该实时看到对 Figma 文档所做的每一项更改。
我们两年前推出的多人服务器是用 TypeScript 编写的,它出人意料地很好地为我们服务,但 Figma 越来越受欢迎,那个服务器将无法跟上。我们决定通过用 Rust 重写它来解决这个问题。
Rust 是 Mozilla 推出的一种新编程语言,Mozilla 是制作 Firefox 的公司。他们使用它来构建一个名为 Servo 的下一代浏览器原型,这表明浏览器可以比今天快得多。Rust 在性能和低级能力上与 C++ 类似,但它具有一个类型系统,可以自动防止在 C++ 程序中常见的整类讨厌的 bug。
我们选择 Rust 来进行这次重写,是因为它结合了一流的速度和低资源使用,同时还提供了标准服务器语言的安全性。低资源使用对我们来说尤其重要,因为旧服务器的一些性能问题是由垃圾收集器引起的。
我们认为这是一个使用 Rust 进行生产环境的有趣案例研究,我们希望分享我们遇到的问题和我们获得的好处,希望对考虑类似重写的其他人有用。
在 Rust 中扩展我们的服务
我们的多人服务在固定数量的机器上运行,每台机器有固定数量的工作进程,每个文档完全位于一个特定的工作进程上。这意味着每个工作进程负责一些当前打开的 Figma 文档的一部分。它看起来像这样:
旧服务器的主要问题是在同步期间不可预测的延迟峰值。服务器是用 TypeScript 编写的,而且是单线程的,无法并行处理操作。这意味着一个缓慢的操作会锁定整个工作进程,直到它完成。一个常见的操作是对文档进行编码,而 Figma 文档可能会变得非常大,所以操作会花费任意长的时间。与此同时,连接到该工作进程的用户将无法同步他们的更改。
仅仅增加更多硬件并不能解决这个问题,因为一个缓慢的操作仍然会锁定与该工作进程关联的所有文件的工作进程。我们也不能为每个文档创建一个单独的 node.js 进程,因为 JavaScript VM 的内存开销会太高。实际上,只有少数文档足够大,可能会引起问题,但它们影响了每个人的服务质量。我们的临时解决方案是将疯狂的文档隔离到一个完全独立的“重型”工作进程池中:
这保持了服务的运行,但意味着我们必须不断寻找疯狂的文档,并手动将它们移动到重型工作进程池。这为我们赢得了足够的时间来真正解决这些问题,我们通过将多人服务器的性能敏感部分移动到一个单独的子进程中来实现这一点。那个子进程是用 Rust 编写的,并通过 stdin 和 stdout 与其主机进程通信。与旧系统相比,它使用的内存如此之少,以至于我们可以通过仅为每个文档使用一个单独的子进程来完全并行化所有文档。而且序列化时间现在比以前快了10倍以上,因此即使在最坏的情况下,服务现在也是可接受的快速。新的架构看起来像这样:
服务器端性能提升
性能提升是惊人的。以下图表显示了在逐步推出前后一周的各种指标。中间的巨大下降是逐步推出达到100%的地方。请记住,这些改进是在服务器端性能上,而不是客户端性能上,所以它们主要意味着服务将为每个人继续顺畅地运行,没有任何问题。
与旧服务器相比,峰值指标的数字变化如下:
Rust 的优点和缺点
虽然 Rust 帮助我们编写了一个高性能的服务器,但事实证明,这门语言并不像我们想象的那样准备好。它比标准的服务器端语言更新,仍然有很多粗糙的边缘(下面描述)。
因此,我们放弃了最初计划用 Rust 重写整个服务器的计划,选择只关注性能敏感的部分。以下是我们在那次重写中遇到的优点和缺点:
优点
- 低内存使用
Rust 结合了对内存布局的细粒度控制,没有 GC,并且有一个非常小的标准库。它使用的内存如此之少,以至于实际上可以为每个文档启动一个单独的 Rust 进程。
- 卓越的性能
Rust 确实实现了其对最优性能的承诺,这既是因为它可以利用 LLVM 的所有优化,也是因为语言本身旨在性能。Rust 的切片使得在解析期间轻松、符合人体工程学且安全地传递原始指针变得容易,我们在这方面使用了很多。HashMap API 使用线性探测和罗宾汉哈希实现,因此与 C++ 的 unordered_map API 不同,内容可以存储在单个分配中,并且更加缓存高效。
- 坚固的工具链
Rust 内置了 cargo,这是一个构建工具、包管理器、测试运行器和文档生成器。这对于大多数现代语言来说是一个标准添加,但对于从过时的 C++ 世界来的我们来说,这是一个非常受欢迎的改进。Cargo 文档齐全,易于使用,并且有有用的默认值。
- 友好的错误消息
Rust 比其他语言更复杂,因为它有一个额外的部分,借用检查器,它有自己的独特规则需要学习。人们已经投入了很多努力使错误消息易于阅读,这确实显示出来了。它们使学习 Rust 更加愉快。
缺点
- 生命周期令人困惑
在 Rust 中,将一个指针存储在一个变量中可以阻止你修改它指向的东西,只要那个变量在作用域内。这保证了安全性,但过于限制性,因为变量在突变发生时可能不再需要了。即使是作为一个从一开始就关注 Rust 的人,一个为了乐趣编写编译器的人,并且知道如何像借用检查器一样思考的人,仍然令人沮丧的是不得不暂停你的工作来解决经常出现的小小的不必要的借用检查器难题。你可以在这个博客文章中找到这个问题造成的好例子。
我们如何应对: 我们将程序简化为一个事件循环,它从 stdin 读取数据并写入 stdout(stderr 用于日志记录)。数据要么永远存在,要么只存在于事件循环的持续时间内。这消除了几乎所有借用检查器的复杂性。
如何修复这个问题: Rust 社区计划通过非词法生命周期来解决这个问题。这个功能缩短了变量的生命周期,使其在最后一次使用后停止。然后,指针将不再阻止它指向的东西的突变,这将消除许多借用检查器误报。
- 错误难以调试
Rust 的错误处理旨在通过返回一个名为“Result”的值来完成,该值可以表示成功或失败。与异常不同,创建一个错误值在 Rust 中不会捕获堆栈跟踪,所以你得到的任何堆栈跟踪都是报告错误的代码而不是引起错误的代码的堆栈跟踪。
我们如何应对: 我们最终立即将所有错误转换为字符串,然后使用一个宏,该宏在字符串中包含失败的行和列。这是冗长的,但完成了工作。
如何修复这个问题: Rust 社区显然已经为这个问题提出了几种变通方法。其中之一叫做 error-chain,另一个叫做 failure。我们没有意识到这些存在,我们不确定是否有标准方法。
- 许多库仍然是早期的
Figma 的文档格式是压缩的,所以我们的服务器需要能够处理压缩数据。我们尝试使用两个单独的 Rust 压缩库,这两个库都被 Servo 使用,Mozilla 的下一代浏览器原型,但两者都有微妙的正确性问题,将导致数据丢失。
我们如何应对: 我们最终只是使用了一个经过验证的 C 库。Rust 建立在 LLVM 上,所以从 Rust 调用 C 代码非常容易。一切都是 LLVM 位码!最终!
_如何修复这个问题:_ 受影响的库中的错误已经被报告并已修复。
- 异步 Rust 是困难的
我们的多人服务器通过 WebSockets 通信,并且不时地进行 HTTP 请求。我们尝试用 Rust 编写这些请求处理程序,但遇到了一些关于 futures API(Rust 对异步编程的回答)的令人担忧的人体工程学问题。由于 futures API 非常高效,因此相当复杂。
例如,将操作链在一起是通过构建一个代表整个操作链的巨大嵌套类型来完成的。这意味着该链的所有内容都可以在单个分配中分配,但这意味着错误消息会产生长的不可读错误,让人想起 C++ 中模板错误(一个例子在这里)。这与其他问题结合,如需要适应不同类型的错误,并且必须解决复杂的生命周期问题,使我们决定放弃这种方法。
_我们如何应对:_ 而不是全力以赴地使用 Rust,我们决定暂时保持网络处理在 node.js 中。node.js 进程为每个文档创建一个单独的 Rust 子进程,并使用基于消息的协议通过 stdin 和 stdout 与其通信。所有网络流量都通过这些消息在进程之间传递。
_如何修复这个问题:_ Rust 团队正在努力将 async/await 添加到 Rust 中,这应该通过将 futures 的复杂性隐藏在语言本身下面来解决许多这些问题。这将允许“?”错误处理操作符,当前仅适用于同步代码,也适用于异步代码,这将减少样板。
Rust 和未来
虽然我们遇到了一些障碍,但我想强调,我们对 Rust 的总体体验是非常积极的。它是一个非常有前景的项目,拥有坚实的核心和健康的社区。我相信这些问题最终会随着时间的推移得到解决。
我们的多人服务器是一小段性能关键代码,依赖性最小,所以即使出现了问题,用 Rust 重写它对我们来说是一个好权衡。它使我们能够将服务器端多人编辑性能提高一个数量级,并为 Figma 的多人服务器设置了一个长期的扩展。