高品质的音视频能力是怎样的? | Qcon 全球软件开发大会·上海站邀请函 了解详情
写点什么

最终,我们放弃了 GO,迁移至 Rust,特性使然

  • 2020-02-10
  • 本文字数:4227 字

    阅读完需:约 14 分钟

最终,我们放弃了GO,迁移至Rust,特性使然

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



在各个领域,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 版本中的性能优化包括:


在 LRU 缓存中,更改为使用 BTreeMap 取代 HashMap 以优化内存占用。将最初的指标库替换为使用现代 Rust 并发功能的指标库。减少我们正在执行的内存副本的数量。对此感到满意之后,我们决定推出这项服务。


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


以下是测试的结果,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 的生态系统和工具都是非常优秀的,它们背后有强大的驱动力。


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


原文链接:


https://blog.discordapp.com/why-discord-is-switching-from-go-to-rust-a190bbca2b1f


2020-02-10 09:0513354
用户头像
赵钰莹 InfoQ 主编

发布了 807 篇内容, 共 503.3 次阅读, 收获喜欢 2545 次。

关注

评论 11 条评论

发布
用户头像
我选GO,主要害怕Mozilla扛不住,要是有个爹来包养,我立刻转RUST
2021-01-08 16:15
回复
用户头像
从语言背后的机构来看,我选择GO
2020-12-28 11:47
回复
用户头像
所以重写优化一般都是优先级比较低的,有空再搞,哈哈
2020-02-29 20:48
回复
用户头像
缓存、LRU,用redis是不是可以搞定?
2020-02-16 10:42
回复
我也觉得放在外面的缓存是不是更好些
2020-04-01 16:03
回复
用户头像
这个文章很火啊,看到很多转载的,go的GC看来坑了不少人,一定认为go和c一个层级会害死人的,特别是和钱打交道的组件,如果每天相差0.01%,神哪!
2020-02-12 17:07
回复
用户头像
同样保持最新版本的GO,做好profiler,用对正确的库,几个指标提升不到RUST相同level?
2020-02-11 10:25
回复
毛刺是基于垃圾收集器的固有问题,可能他们认为使用完内存立即释放应该会更好点,主要他们是大神,学个语言什么的很快。
2020-02-12 11:06
回复
应该说他们团队大神多吧,国内很难啊,招个人费劲的很
2021-01-08 16:16
回复
用户头像
真能折腾!
2020-02-10 11:33
回复
😄,我刚也是这么想的,能重写,不是逼到一定程度,就是闲的发懵
2020-02-12 16:59
回复
没有更多了
发现更多内容

实现内网穿透(二)

风斩断晚霞

Go websocket

JDK 15 以上版本的字符串块

HoneyMoose

Docker下RabbitMQ四部曲之二:细说RabbitMQ镜像制作

程序员欣宸

Java RabbitMQ 5月月更

都是限制,都是秘密,JS逆向某建筑市场数据,python爬虫120例

梦想橡皮擦

5月月更

LabVIEW控制Arduino LED灯闪烁(基础篇—2)

不脱发的程序猿

单片机 LabVIEW Arduino LED灯闪烁 LIAT

跨平台应用开发进阶(十六) :uni-app实现H5页面唤醒APP

No Silver Bullet

uni-app App 5月月更 H5页面

中兴通讯加入龙蜥社区,共建ICT全场景开源生态

OpenAnolis小助手

开源 生态 龙蜥社区 CLA 中兴通讯

读书笔记之怪诞行为学6:非凡的决定

宇宙之一粟

读书笔记 5月月更

HTML语法基本规范

黎燃

5月月更

车联网的发展面临的难点怎样突破

Geek_99967b

小程序 车联网

沉浸式体验网易云信在线 KTV

网易云信

音视频技术

浅谈Http,Https

秋名山码民

HTTP 5月月更

怎样为自己的小程序打包为App

Geek_99967b

小程序 小程序转app

智能手表的机遇与挑战并存

Geek_99967b

数据安全 物联网, 智能手表

AIRIOT物联网低代码平台如何配置Modbus TCP协议?

AIRIOT

低代码平台 驱动配置

OpenClusterManagement 开源之夏 2022 来了

阿里巴巴云原生

阿里云 云原生 开源之夏

一种直流电池/电源正反接均可供电的电路方案

不脱发的程序猿

电路设计 电源电路 嵌入式硬件 直流电池/电源正反接电路

LabVIEW和Arduino的巧妙结合(基础篇—1)

不脱发的程序猿

单片机 LabVIEW Arduino 上位机

Linux环境混合使用静态库与动态库

Loken

音视频 5月月更

打造TOB企业流量场,钉钉将推出“视频号”,提升CDN性能是关键

郑州埃文科技

钉钉 CDN加速 IP地址 IP定位 网络优化

gitlab 8.13.6添加server hook后保护分支失效

阿呆

#GitLab gitlab hook 保护分支

一文学完Linux Shell编程,比书都好懂

编程攻略

Linux

Java Core「6」反射与SPI机制

Samson

学习笔记 5月月更 Java core

《0次与10000次》:让坏事发生0次,让好事发生10000次

郭明

【网易云信】沉浸式体验网易云信在线 KTV

网易智企

音视频

go语言学习之并发并行【青训营】

上进小菜猪

Go 5月月更

SAAS服务的特点

Geek_99967b

小程序 SaaS

将微信小程序生成商用App很简单吗?

Geek_99967b

ide 小程序转app 小程序预览

Java 8 中的设计模式策略

HoneyMoose

最终,我们放弃了GO,迁移至Rust,特性使然_架构_Jesse Howarth_InfoQ精选文章