[关闭]
@levinzhang 2020-02-07T16:15:06.000000Z 字数 4482 阅读 753

Discord为何从Go切换到了Rust

摘要

本文阐述了Discord从Go切换至Rust的深层原因,并分析了在内存管理中Go面临的一些固有问题,作者同时对比了Go和Rust在Discord Read States服务中的性能。


本文最初发表于Discord博客站点,经原作者Jesse Howarth
许可,由InfoQ中文站翻译分享。

在各个领域,Rust都已经成为了一流的语言。在Discord,我们看到了Rust在客户端和服务端的成功。举例来说,我们在客户端使用它实现了Go Live的视频编码管道,在服务端,它则被用于Elixir NIFs。最近,我们通过将服务的实现从Go切换到Rust,极大地提升了该服务的性能。本文阐述了重新实现服务为何是有价值的、该过程是如何实现的以及由此带来的性能提升。

Read States服务

Discord是一家以产品为中心的公司,所以我们先介绍一下产品的背景信息。我们从Go切换到Rust的服务叫做“Read States”服务。它的唯一目的是跟踪用户阅读了哪些频道和信息。每当用户连接Discord的时候,每当消息发送的时候,每当消息被读取的时候,都会访问Read States。简而言之,Read States处于最关键的位置。我们希望能够保证Discord始终让人感觉快捷无比,所以必须要确保Read States是非常快速的。

在Go的实现中,Read States无法支持产品的需求。在大多数情况下,它都是很快速的,但是每几分钟我们就会看到很大的延迟峰值,这对于用户体验来说是很糟糕的。经过调查,我们确定峰值是由Go的核心特性引起的,也就是其内存模型和垃圾收集器(GC)。

为何Go无法满足我们的性能目标

为了阐述Go为什么无法满足我们的需求,我们首先需要讨论数据结构、规模、访问模式以及服务架构。

我们用来存储读取状态信息的数据结构被简便地称为“Read State”。Discord有数十亿的Read State。每个用户(User)的每个频道(Channel)都有一个Read State。每个Read State都有多个计数器需要自动更新,并且经常会被重置为零。例如,其中有个计数器用来记录你某个频道中被提及了多少次。

为了快速获取原子计数器的更新,在每个Read State服务器中都保存了一个Read State的最近最少使用(LRU,Least Recently Used)的缓存。每个缓存中都有数百万的用户,每个缓存中又会有数千万的Read State。每秒钟会有成千上万的缓存更新。

对于持久化来讲,我们使用Cassandra数据库集群作为缓存的支撑。在缓存键清除(eviction)的时候,我们会将Read State提交到数据库。每当Read State更新的时候,我们会将数据库提交调度到未来的30秒。每秒钟会有成千上万的数据库写入操作。

在下图中,我们可以看到Go服务的峰值采样时间帧的响应时间和CPU(图表数据基于Go 1.9.2。我们尝试了版本1.8、1.9和1.10版本,但没有任何改善。从Go到Rust的第一次切换是在2019年5月完成。)。正如我们所看到的,基本每两分钟就会出现延迟和CPU峰值。

为何每两分钟会出现峰值?

在Go中,当缓存键清除时,内存不会立即释放。相反,垃圾收集器每隔一定的时间就会运行一次,以便于查找不再被引用的内存并释放它。换句话说,Go并不是在内存用完后立即释放,内存会挂起一段时间,直到垃圾收集器确定它真的是不再需要了。在垃圾收集的时候,Go必须要做大量的工作来确认哪些内存是空闲的,这可能会降低程序的运行速度。

这些峰值看起来确实是垃圾收集器对性能的影响,但是我们所编写的Go代码已经非常高效了,内存分配很少。我们并没有制造太多的垃圾。

在深入研究了Go的源码之后,我们了解到至少每两分钟,Go将强制运行一次垃圾收集。换句话说,如果垃圾收集器已经有两分钟没有运行了,不管堆增加了多少,Go依然会强制运行垃圾收集。

我们认为可以优化垃圾收集器,使其运行地更加频繁,从而防止出现较大的峰值,因此我们在服务中实现了一个端点,在运行时修改垃圾收集器的GC百分比。令人遗憾的是,无论我们如何配置GC百分比,都不会发生任何变化。为什么会这样呢?事实证明,这是因为我们分配内存的速度不够快,从而导致无法强制垃圾收集频繁进行。

我们继续深入研究,发现出现如此大的峰值并不是因为有大量待释放的内存,而是因为垃圾收集器要扫描整个LRU缓存,以便于确定内存是否完全没有被引用。鉴于此,我们认为更小的LRU缓存会更快,因为垃圾收集器要扫描的内容会更少。所以,我们在服务上添加了另外一项配置,允许修改LRU缓存的大小,并修改了架构,让每台服务器上能有许多的LRU缓存分区。

我们是正确的。LRU缓存越小,垃圾回收的峰值越小。

但是,缩小LRU缓存的代价就是第99个百分位延迟时间的增长。这是因为,如果缓存比较小的话,用户的Read State在缓存中的几率就会降低。如果它不在缓存中,那么我们就需要进行数据库加载。

对不同的缓存容量进行了大量的负载测试之后,我们发现了一个看起来还不错的设置。虽然这不能让人完全满意,但是也是可以接受的,而且当时还有更重要的事情要做,所以我们让服务就这样运行了很长一段时间。

在那段时间里,我们看到Rust在Discord的其他地方越来越成功,于是我们一致决定要完全基于Rust创建用于构建新服务所需的框架和库。这个服务是移植到Rust的最佳候选,因为它很小而且是自包含的,但是我们也希望Rust能够修复这些延迟峰值的问题。所以,我们接受了将Read States移植到Rust的任务,希望Rust是一门合格的服务语言并且提升用户体验(澄清一下,我们认为,你们并不应该为了要使用Rust,就将所有的服务使用Rust重写一遍)。

Rust中的内存管理

Rust非常快并且节省内存:它没有运行时和垃圾收集器,能够支撑性能关键型的服务、可以运行在嵌入式设备中并且能够很容易地与其他语言集成(引自Rust官网)。

Rust没有垃圾收集,所以我们认为它不会有与Go相同的延迟峰值问题。

Rust使用了一种比较独特的内存管理方法,其中包含了内存“所有权”的概念。简而言之,Rust会跟踪谁能够读写内存。它知道程序什么时候使用内存,并在不再需要内存的时候立即释放它。它在编译时强制执行内存规则,这样它根本不可能出现运行时内存错误(当然,除非你使用unsafe)。我们不需要手动跟踪内存,编译器会处理它。

因此,在Read States服务的Rust版本中,当用户的Read State从LRU缓存中清除时,它会立即从内存中释放。Read State内存不会等待垃圾收集器来收集它。Rust知道它不会再使用了,并立即释放它。在Rust中并没有运行时进程来确定是否应该释放它。

异步的Rust

但是Rust生态系统有一个问题。在这个服务重新实现的时候,Rust稳定版并没有很好的异步Rust功能。但是对于网络服务来说,异步编程是必需的。有一些社区库支持异步Rust,但是它们需要大量的样板式处理,而且错误消息非常模糊不清。

幸运的是,Rust团队正在努力使异步编程变得更加简单,并且该功能可以在Rust不稳定的nightly版本中使用。

Discord从来都不惧怕接受那些看起来很有前途的新技术。例如,我们是Elixir、React、React Native和Scylla的早期采用者。如果某项技术很有前途,并能够给我们带来好处,我们不介意处理其固有的困难和不稳定性。这也是我们在不到50名工程师的情况下能够快速达到2.5亿用户的方法之一。

接受Rust nightly版本的异步特性就是我们愿意拥抱新的、有前途的技术的另外一个佐证。作为一个工程团队,我们认为值得使用Rust nightly版本,并承诺为nightly版本做出提交贡献直到异步功能在稳定环境下得到完全支持。我们一起处理出现的各种问题,此后Rust稳定版支持了异步Rust(参见该网址)。终于苦尽甘来。

实现、负载测试和发布

实际的重写相当简单。首先,我们有一个大致的转换,然后我们把它进行有意义的优化。例如,Rust有一个很好的类型系统,对泛型提供了广泛的支持,因此我们可以抛弃那些仅仅因为缺少泛型而存在的Go代码。另外,Rust的内存模型能够推断出线程之间的内存安全性,因此我们能够抛弃Go中所需要的跨goroutine的内存保护。

刚开始进行负载测试时,我们马上就对结果感到非常满意。Rust版本的延迟和Go版本一样好,而且没有延迟峰值!

值得注意的是,在编写Rust版本时,我们只对性能优化进行了非常基本的思考。即使只是基本的优化,Rust也能够超越手动调优的Go版本。这深切证明了相对于深入研究Go,使用Rust编写高效的程序有多么的容易。

但我们并不满足于简单地匹配Go的性能。经过一些性能分析和性能优化之后,我们能够在每个性能指标上击败Go。在Rust版本中,延迟、CPU和内存指标都更好。

Rust版本中的性能优化包括:

  1. 在LRU缓存中,更改为使用BTreeMap取代HashMap以优化内存占用。
  2. 将最初的指标库替换为使用现代Rust并发功能的指标库。
  3. 减少我们正在执行的内存副本的数量。

对此感到满意之后,我们决定推出这项服务。

由于我们进行了负载测试,所以发布过程相当顺利。我们把它放到一个金丝雀部署的节点上,查找到一些缺失的边缘情况,并修复了它们。不久之后,我们就把它推广到整个环境之中。

以下是测试的结果,Go是紫色的线,Rust是蓝色的线。

提高缓存的容量

在服务成功运行了几天之后,我们决定重新提高LRU的缓存容量。如上所述,在Go版本中,提高LRU缓存上限会导致更长的垃圾收集时间。现在,我们不再需要处理垃圾收集,因此我们认为可以提高缓存的上限并能够获得更好的性能。我们增加了内存容量,优化了数据结构以使用更少的内存(仅仅为了好玩),并将缓存容量增加到800万条Read States。

下面的结果不言自明。注意,现在平均时间以微秒计算,获取提及数的最大耗时以毫秒计算。

生态系统的演化

最后,Rust的另一个好处是它有一个快速演化的生态系统。最近,tokio(我们使用的异步运行时)发布了0.2版。我们进行了升级,它免费带来了CPU方面的优化。下面你可以看到CPU在16号左右开始就一直很低。

最后的思考

现在,Discord在其软件栈的许多地方都在使用Rust。我们将它用于游戏SDK、Go Live的视频捕获和编码、Elixir NIFs以及其他几个后端服务等等。

当开始一个新项目或软件组件时,我们都会考虑使用Rust。当然,我们只在有意义的地方使用它。

除了性能之外,Rust对于工程团队还有许多好处。例如,如果产品需求发生了变化,或者发现了关于该语言的新知识,Rust的类型安全性和借用检查器(borrow checker )使代码重构变得非常容易。除此之外,Rust的生态系统和工具都是非常优秀的,它们背后有强大的驱动力。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注