< 返回版块

2019-05-02 21:00    责任编辑:tk

标签:rust,figma,production

来源:https://www.figma.com/blog/rust-in-production-at-figma/

喜欢构建先进的web应用?来Figma工作吧

在Figma,性能是我们最重要的功能之一。我们努力使团队的工作赶上思考的速度,我们多用户同步引擎就是实现这一远景的一个关键部分。每个人都应该能实时的看到Figma文档的每一个变更。

我们在两年前推出的多用户服务是用TypeScript编写的,并为我们提供了令人惊讶的良好服务,但Figma正迅速变得越来越受欢迎,以至于该服务的能力将没办法赶上需求。我们决定用Rust来重写它。

Rust是Mozilla开发的一种新的编程语言,Mozilla也就是开发了Firefox的公司。他们用它来建立下一代浏览器原型Servo,而且证明了它可以比现在的浏览器快得多。Rust在性能和底层能力方面类似于C++,但是它有一个能自动防止C++程序中常见错误的类型系统。

我们选择Rust进行重写,因为它不但结合了一流的速度和较低的资源使用率,同时还提供了标准的服务器语言要有的安全性。低资源使用率对我们来说尤其重要,因为旧服务的一些性能问题就是由垃圾收集器引起的。

我们认为这是在生产中使用Rust的一个有趣的案例,并希望与您分享我们遇到的问题和我们所得到的好处,希望它对也考虑进行类似重写的其它人有用。

用Rust来扩展我们的服务

我们的多用户服务运行在固定数量的机器上,每台机器上都有固定数量的worker(工作进程),并且每个文档只存在于一个特定的工作进程上(译注:应该是特定的机器上吧?)。这意味着每个工作进程负责当前打开的Figma文档的一部分。它看起来是这样的:

rust image one

旧服务的主要问题是同步过程中不可预测的延迟峰值。这个服务是用TypeScript编写的,由于是单线程的,无法并行处理操作。这意味着一次缓慢的操作会让个工作节点阻塞,直到完成为止。一种常见的操作是对文档进行编码,注意Figma文档可能会变得非常大,因此该操作将花费任意长的时间。与该工作线程连接的用户将无法同步他们的变更。

投入更多的硬件并不能解决这个问题,因为一个这个worker节点关联的任何文件缓慢的操作仍然会阻塞。我们不能简单的为每个文档单独创建node.js进程,因为这样的话JavaScript VM的内存开销就太高了。实际上,只有很少量的文档大到足以引起问题,但是它们却影响了对每个人的服务质量。我们的临时解决方案是将这些超级大的文档隔离到一个完全独立的“重量级”工作进程池中:

rust image two

这使得服务能继续运行,但也意味着我们必须不断地识别那些超大的文档,并将它们手工转移到重量级的工作进程池中。我们通过将多人服务的性能敏感部分移到单独的子进程中实现,这为我们赢得了足够的时间来解决真正地问题。这里子进程用Rust编写,并使用stdin和stdout与它的主进程通信。与旧的系统相比,它占用的内存非常少,因此我们可以承受让每个文档都使用单独的子进程来全面的并行处理所有文档。而且序列化时间现在快了10倍以上,所以即使在最坏的情况下,服务的速度也是可以接受的。新的架构是这样的:

rust image three

服务端性能提升

性能的提升简直难以置信。下面的图表显示了渐进发布新服务之前、期间和之后一周的各种指标。中间的巨大下降是渐进发布达到100%的地方。请记住,这些性能改进是在服务器端而不是在客户端上的,因此基本只意味着服务将对所有的人来说继续平稳地运行。

network traffic mem usage cpu usage avg time worst case

以下是与旧服务相比,峰值指标的数字变化:

metrics

Rust的优缺点

虽然Rust帮助我们编写了一个高性能的服务,但事实证明该语言并没有我们想象的那么成熟。它相较与标准的服务器端语言还是太新了,仍然有很多需要打磨的地方(如下所述)。

因此,我们放弃了原定用Rust重写整个服务的计划,而选择专注性能敏感部分。以下是我们在重写过程中遇到的优缺点:

优点

  • 低内存使用率

Rust结合了对内存布局的细粒度控制和无GC设计,并且拥有一个非常小的标准库。它占用的内存非常少,所以为每个文档单独启动一个独立的Rust进程也是很实际的。

  • 很棒的性能

Rust确实实现了其高性能的承诺,这是因为它可以利用LLVM的所有优化,而且该语言本身的设计考也虑到了性能。Rust的切片使传递原始指针变得简单、符合工程设计且安全,我们经常使用它来避免在解析过程中复制数据。HashMap API是通过线性探测和Robin Hood哈希实现的,因此与C++的unordered_map API不同,它的内容可以内联存储在单块内存中,能使缓存效率更高。

  • 可靠的工具链

Rust自带cargo,它是一个构建工具、包管理、测试运行和文档生成工具。对于大多数现代语言来说,这是一个标准的附属组件,但是对于C++这样一个古老的世界来说,这却是一个非常受欢迎的改进,因为C++是我们考虑过用于重写的另一种语言。Cargo有良好的文档,易于使用,并且提供好用的缺省配置。

  • 友好的错误信息

Rust比其他语言更复杂,因为它还有一个额外的部分,即borrow checker(借用检查),它有自己独特的规则,需要学习。人们花了很多精力使错误消息可读,而Rust做到了,它使学习Rust容易多了。

缺点

  • 生命周期令人困惑

在Rust中,将指针存储在变量中,只要该变量在作用域就可以防止您对它指向的值进行修改。这保证了安全性,但限制过于严格,因为在需要发生值改变时可能不再需要这个变量了。即使那些从一开始就跟随Rust的人,或者那些写编译器为乐趣的人,亦或者那些懂得如何像借用检查器一样思考的人,他们仍然会因为要时不时的停下来检查出现的借用检查谜题而感到沮丧。在这篇博客文章中有一些很好的例子可以说明这一点。

我们做了什么:我们将程序简化为一个事件循环,它从stdin读取数据并将数据写入stdout (stderr用于日志记录)。数据要么永久存在,要么只在事件循环期间存在,这基本上消除了借用检查器的复杂性。

如何解决这个问题:Rust社区计划使用非词法生命周期来解决这个问题。该特性将借用变量的生命周期调整到最后一次使用之后结束(而不是整个作用域)。这样在后面的作用域中,指针将不再阻止对象的变更,这将消除许多借用检查的误报。

  • 错误很难调试

Rust中的错误处理是通过返回一个名为“Result”的值来完成的,该值可以表示成功或失败。与异常不同的是,在Rust中创建的错误值不会捕获堆栈跟踪信息,因此您得到的任何堆栈跟踪都是针对报告错误的代码,而不是针对导致错误的代码。

我们做了什么:我们立即将所有错误转换为字符串,然后使用一个包含失败的行和列的字符串宏。这看起来是很冗长的,但是可以工作。

如何解决这个问题:Rust社区显然已经为这个问题提出了几个解决方案。其中一个称为错误链,另一个称为failure。之前我们没有意识到它们的存在,现在我们也不确定是否有一个标准的方法。

  • 许多库还不成熟

Figma的文档是需要被压缩的,因此我们的服务器需要能够处理数据压缩。我们尝试使用两个单独的Rust压缩库,它们都是被Mozilla下一代浏览器原型Servo使用的,但都存在一些细微的正确性问题,可能会导致数据丢失。

我们做了什么:我们最终只能使用一个可靠的C库。因为Rust是在LLVM上构建的,所以从Rust调用C代码非常简单。最后一切都只是LLVM字节码!

如何解决这个问题:报告了受影响库中的bug,并且已经修复。

  • Rust异步编程比较困难

我们的多用户服务器通过WebSockets进行通信,并经常发出HTTP请求。我们尝试在Rust中编写这些请求处理程序,但是遇到了一些与future API (Rust对异步编程的方案)有关的工程设计问题。futures API非常高效,但这也或多或少的带来复杂性。

例如,通过构造一个表示整个链式调用的巨大嵌套类型来将操作链接在一起。这意味着该链的所有内容都可以在单个分配中分配完成,但这也使得错误消息会很长而且不可读,这让人想起C++中的模板错误(这里有一个例子)。再加上其他可能的问题,例如需要在不同的错误类型之间进行适配,以及必须解决的复杂的生命周期问题,这使得我们决定放弃这种方法。

我们做了什么:我们决定暂时将网络处理部分还是保留在node.js中,而不是所有都用Rust。js进程为每个文档创建一个单独的Rust进程,并使用基于消息的协议通过stdin和stdout与之通信。所有网络流量都使用这些消息在进程之间传递。

如何解决这个问题:Rust团队正在努力添加async/await,这应该能够通过在语言层面隐藏future的复杂性来解决许多问题。这将允许目前只与同步代码一起工作的错误处理操作符“?”也能与异步代码一起工作,这将减少一些重复的结构。

Rust和未来

虽然我们遇到了一些减速带,但我想强调的是,整体来说我们使用Rust的经验是非常积极正面的。这是一个非常有前途的项目,有着坚实的核心和健康的社区。我相信,随着时间的推移,这些问题终将得到解决。

我们的多用户服务只有少量的性能关键代码,并且具有最小的依赖性,因此即使用Rust重写它碰到很多问题,这对我们来说还是一个很好的择中。它使我们能够将服务器端多用户编辑性能提高一个数量级,并将能在未来很长一段时间保持扩展能力。