选择 Go 还是 Rust?CloudWeGo-Volo 基于 Rust 语言的探索实践

本文介绍了 CloudWeGo-Volo 的起源、设计和实现,以及基于 Rust 语言的探索实践,包括 Go 的代价有哪些,Rust 的优势有哪些。

CloudWeGo 选择 Rust 语言进行探索的原因

CloudWeGo 正式官宣新一代 Rust RPC 框架 Volo 开源!CloudWeGo 为什么会选择 Rust 这门语言进行探索呢?本文首先介绍一下其中的原因。

Go 的代价

  • 深度优化困难

Volo 早期的团队成员来自于 Kitex 项目(CloudWeGo 开源的 Golang 微服务 RPC 框架)。当时我们投入了大量的时间和精力优化 Kitex 以及其他相关基础库的性能,最终却发现实现 Go 的深度优化有些困难。 我们仅仅可以做一些算法层面和实现层面的优化,如果想往下继续做其他层面的优化,比如指令层面的优化,是很难以低成本的方式实现的。而且在大多数情况下很多优化是要和 runtime 以及编译器作斗争的。

  • 工具链和包管理不够成熟

例如,使用 Kitex 框架时需要先使用对应的 kitex 工具生成代码,才能正常编译使用。虽然这种情况可能在 Frugal 工具成熟之后有所改善,但是在 IDL 有更新的情况下,还是需要使用 kitex 重新生成对应的结构体。 这个问题并不是 Kitex 的问题,而是 Go 语言本身的问题,Go 语言在编译时没有提供类似的能力。

  • 抽象能力较弱

Go 语言的抽象能力是比较弱的,而且 Go 语言里面的抽象并不是零成本抽象,而是有代价的抽象。

那么使用 Go 语言需要付出的三个代价具体应该如何理解呢?下面进行具体分析。

深度优化困难

如图所示,这是 Kitex 项目生成代码的简单示例。这两段代码的目的是在解析出错的时候,把一些信息返回给上层。在 Kitex 新版本代码公开之后, 业务团队同学反映他们线上序列化和反序列化这部分的性能相差了 20%,经排查之后,我们发现了这个改动。

image

Kitex 新版本的代码

image

Kitex 旧版本的代码

这个改动的本意是希望能给客户提供更多错误上下文的信息。但是它带来了什么问题呢?如下图,它把汇编代码直接一对一地生成到主流程之中,也就是说 Go 语言的编译器会逐行逐句地进行翻译,并且不会做重排。

image

那么这会带来什么问题呢?由于我们主流程中的代码与正常流程相比变多了,所以我们重点关注一下 L1-icache-load-misses 这一行, 新版本的代码比旧版本的代码在 L1 指令 cache 层面 cache-misses 高出 20%,这也就是我们的代码效率降低 20% 的原因。那么我们是如何解决这个问题的呢?

我们的解决方案如下图所示。在 err != nil 的情况下,直接手动加一条 goto 语句,把所有错误处理这部分的代码放到函数末尾,即 return 之后。 这相当于在编译器没有实现指令重排的情况下,用人工方式做一次指令重排。最后优化的效果是非常明显的,可以看到 cache-misses 比之前的那一次还要降低 25%。

image

上述例子只是使用 Go 语言时在做深度优化方面遇到的难题。在抽象能力方面,使用 Go 语言也会遇到一些困难。

零成本抽象(Zero-Cost Abstraction)

什么是零成本抽象呢?使用 C++ 和 Rust 的同学对这个概念可能有所了解。零成本抽象是指我们不需要对没有使用的功能付出编译和运行的开销,也就是用户不需要给没有使用的东西付费。 对应地,如果用户对于已经使用的东西也没有再继续优化的空间,因为它已经默认提供了最佳实践。总结如下:

  • 不用的东西,不需要为之付出代价;
  • 用到的东西,你也不可能做得更好。

那么为什么说 Go 语言里面没有零成本抽象呢?以 Thrift 编解码为例,我们最开始使用的是 Apache Thrift,它为了支持多种不同 Protocol、Transport 组合, 抽象出了 TProtocol Interface、TTransport Interface,但 Kitex 直接依赖具体的 BinaryProtocol 的实现(struct)。可以试想,Apache Thrift 这么做的代价是什么呢?这就是 Go 里面 Interface 带来的代价。

Go 里面 Interface 是动态分发的,也就是运行时通过类型元数据和指针去动态调用所需方法,它会在运行时多做一次内存寻址。但这并不是最关键的,最关键的是它会使得编译器没有办法 inline 以及没有办法做很多优化。 一般比较注重性能的语言都会同时提供静态分发和动态分发两种方式的抽象能力,但是 Go 语言只提供了 Interface 动态分发能力,也就可以理解为在 Go 语言中抽象和性能是不可兼得的,这也就是 Go 语言抽象能力比较弱的原因。

Sonic

Sonic 是 CloudWeGo 开源的一个 JSON 库,这个库有很多 CloudWeGo 的用户都使用过。最初这个库组成部分如下图所示,有 2/3 的代码都是 Assembly 汇编。

image

Sonic 库中仅有的 27% 的 Go 源代码如下图所示。虽然它被统计到了 Go 代码中,但实际上是汇编代码。所以我们可以总结出,世界上最快的 Go 语言程序大概就是用汇编代码写就的。

image

性能最好的 Go JSON 库

尽管 Sonic 里面采用了各种黑科技,甚至有 2/3 的代码都是经过人工精调的汇编代码,但是 Sonic 的综合性能还是不如 Rust 最通用的 Serde JSON 库。 如图所示,绿色柱状图代表 Serde JSON 库,蓝色柱状图代表 Sonic 库。根据这个 Benchmark,即使是和 C、C++ 的库相比,用 Rust 语言编写的这个库在各方面综合表现也是最佳的。

试想,又有多少 Go 组件能够得到如此大量的人力投入从而进行深度优化呢?这只是一个例子,其实我们之前在 Kitex 中的很多优化也是要和编译器以及 runtime 作斗争的。因此我们认识到在 Go 语言中想做深度优化是非常困难的。

image

关于 Rust

我们为什么要选择 Rust 这门语言呢?在解答这个问题之前,要先了解这门语言。所以先介绍一下 Rust 语言的发展历史。

Rust 的历史

Rust 语言由 Graydon Hoare 私人研发,他是 Mozilla 做编程语言的工程师,专门给语言开发编译器和工具集。当时 Mozilla 要开发 Servo 引擎,想要保证安全的同时又能拥有高性能, 于是就选择了 Rust 语言。2010 - 2015 年期间,Rust 是有 GC 的,后来社区一致表示支持 Rust 必须要有高性能,所以 GC 被取缔。2015 年,Rust 发布 1.0 版本,这也表示正式官宣 Rust 的稳定性。

Rust 是以三年为单位进行社区规划和迭代的。2015 - 2018 年,Rust 达成了生产力的承诺,也就是它的工具文档还有编译器变得更加智能,也对开发者更加友好了。 2018 - 2021 年,Rust 做了更多异步生态的完善。之前的 Rust 是没有异步生态的,但是自 2018 年开始,它正式引入了异步功能。

image

Rust 2024

2021 - 2024 年,Rust 有一个 2024 规划,主题叫做 Scaling Enpowerment(扩展授权)。之所以取这个名字,是因为 Rust 有一个目标——“empower everyone to build reliable and efficient software”。 Rust 最关注也是大家经常诟病的一点,就是 Rust 的整个学习曲线非常陡峭,所以在这个规划中写道 “Flatten the learning curve”。

image

Rust 三大优势

在 2022 年,很多开源项目已经呈现爆炸式增长。我们了解到 Rust 这门语言后,发现它有三大非常重要的优势:第一是高性能;第二是很强的安全性;第三是协作方便。 因此我们想尝试在服务端使用 Rust 语言开发微服务,以此解决我们面临的一些性能上的问题。

  • 性能

很多用户都对性能有很高的要求,也想知道 Rust 的性能如何。下图是各语言的 Benchmark 对比结果,可以看出 Rust 的性能是非常优秀的,远超过 Go 语言,甚至比 C++ 的性能更好。

当然我们要着重说明,这个 Benchmark 要求所有语言必须使用相同的算法,并且不得经过额外优化。毕竟如果都用汇编代码写,其实各语言性能相差无几。 但是在真正的开发过程中,又有多少代码能够经过那么大量的人工精细优化呢?另外,有人可能会对 Rust 的性能比 C 和 C++ 更优秀产生质疑,其实这也是因为 Rust 对于程序员的输入要求得更加严格,所以编译器可以做更进一步的优化。

image

  • 安全性

因为在 Rust 语言的安全性方面可查阅到大量资料,因此不再过多赘述。只阐述一个重要结论:Rust 1.0 之后,在非 Unsafe 代码中是不可能出现内存安全问题的。 这个结论是通过数学证明过的,因此非常可靠。我们应该如何理解这个结论呢?可以从它的推论入手,即:一切内存/并发安全问题,都是 unsafe 代码导致的。 也就是如果真的出现安全问题,我们可以限制在一个非常小的范围内进行排查。因为毕竟绝大多数的 Rust 语言代码都是 Safe Rust,而不是 Unsafe Rust。

  • 协作

Rust 是一门真正通过工程实践形成的语言,它有非常 智能的编译器完善的文档集群的工具链成熟的包管理 ,因此 Rust 非常适合协作。 我们在使用时可以专注于逻辑功能的实现,而不用担心内存安全和并发安全的问题等等。还有非常重要的一点就是可以限制别人的代码,因为如果别人的代码有内存安全问题或并发安全问题,将无法进行编译。 所以在做 Code Review 时,我们只需关注逻辑上的功能正确性就可以,因为只要能够通过编译提交上来的代码,安全性是不必担心的。 这虽然是 Rust 语言的优点,但也给使用者带来一些不便之处。我们常听说 Rust 开发者很难,也正是因为编译。

Rust 的影响力

如下图,Rust 已经连续七年位居 Stack Overflow 最受开发者喜爱的编程语言榜榜首。此外,有一个非常重量级的项目叫做 “Rust for Linux”, 除了 C 语言之外,Rust 是 Linux 内核迄今为止接受的唯一语言。这些成绩足以看出 Rust 在开源业界的重量级和影响力。

image

创建 RPC 框架 Volo 的原因

明确了 CloudWeGo 选择 Rust 语言的原因以及 Rust 的优势,我也阐述一下创造 Volo 框架的原因以及 Volo 的特点。

生态现状

创造 Volo 框架与当时的生态情况是有关的。我们当时调研过整个社区的生态,发现没有生产可用的 Async Thrift 实现。哪怕是社区中最成熟的 Tonic 框架,它的服务治理功能也是比较弱的,而且易用性也不够强。 更重要的是当时在 Rust 语言社区,还没有基于 Generic Associated Type(GAT,Rust 语言最新的⼀个重量级 Feature)和 Type Alias Impl Trait(TAIT,另⼀个重量级 Feature)的易用性强的抽象。

易用性

为什么单独说明 GAT 和 TAIT 这两个特性呢?按照 Rust 官方团队的说法,这是自 Rust 1.0 以来语言层面和 Type System 层面最大的变化。 举例简单说明,下图是一个现有的社区方案,代码是没有使用 GAT 和 TAIT 的超时中间件的编写,我们可以发现如果要保证性能不受损耗,需要编写大量代码。

image

而在 Volo 框架中,因为采用了 GAT 和 TAIT 这两个特性,编写代码如下图所示。我们可以明显对比出代码量和易用性方面的差距是非常明显的。 Rust 以难学难用而闻名,我们希望尽可能地降低用户使用 Volo 框架和 Rust 语言编写微服务的难度,提供给用户最符合人体工程学和直觉的编码体验,因此我们把框架易用性作为重要目标之一。 只有让大家真正地使用 VoloVolo 才能体现它的价值。所以 Volo 框架 基于 GAT 和 TAIT 特性大大提升了用户编写中间件的便利程度

image

除此之外,我们提供了 Volo 命令行工具生成默认 Layout ,并且 Volo 的命令行工具提供 IDL 管理的能力 ,这在业界是首例。 我们还提供了过程宏等能够再度降低 Service 编写难度的功能。当然还有很多其他的精心设计,比如很多 API 都是尽量以最符合人体工程学的方式给出的,也可以避免误用。

扩展性

  • 基于 Service 的抽象

受益于 Rust 强大的表达和抽象能力,开发者可以基于非常灵活的 Service 抽象,用统一的形式对 RPC 的元信息请求和响应做一些处理,比如服务发现、负载均衡等服务治理功能都是直接实现 Service 即可。

image

  • 基于 RPC 元信息的控制

另外,在我们的框架设计中,所有框架行为都是受到 RPC 元信息控制的。因此我们只要在 Service 中对 RPC 元信息进行修改,就能直接控制框架的行为,从而实现所需的功能。

下图是 Volo 自带的负载均衡中间件实现中最关键的一部分,即红色线框圈出的代码。只要把 Load Balance 选出来的地址放到 RPC 元信息中就可以,其他代码可以直接忽视掉。

image

性能

如果过多谈论框架的性能对比,容易引战。但是基于 Rust 语言的性能优势以及 CloudWeGo 团队对于极致性能的追求,我们可以预想到 Volo 的性能也是非常高的。

如果把 VoloKitex 进行跨语言的对比也是不太公平的,但是因为很多用户都关注性能数据,为了让使用者对 Volo 框架的性能有大致的了解,我们只给出比较简单的性能数据。 在与 Kitex 相同的测试条件(限制 4C)下,Volo 极限 QPS 为 35W。同时,我们内部正在验证基于 Monoio(CloudWeGo 开源的 Rust Async Runtime)的版本,极限 QPS 可以达到 44W。

当然还有很多其他的性能指标,比如响应时间也是非常影响用户体验的。所以除了 Benchmark,我们选取了由 Go 迁移到 Volo 框架的两个业务,呈现真实的业务落地收益。

  1. 业务 A(Proxy 类) 。A 业务的 IO 比较多,迁移到 Volo 框架后的各方面数据如下:
  • CPU Usage 630% -> 380%
  • MEM 9GB -> 2GB
  • P99 150-200ms -> 20-35ms
  • AVG 4-5ms -> 1.5ms

可以看出不论是 CPU、内存还是延时的指标,都有非常明显的提升。下图中间红线代表 Volo 上线的时间,也就是红线左侧这一部分是 Go 的指标,红线右侧是 Rust 的指标,左右对比可以更直观看出 Volo 框架给业务 A 带来的收益。

image

  1. 业务 B(有大量业务逻辑) 。业务 B 是一个计算密集型的业务,使用 Volo 框架后 CPU 400% -> 130%。因此在计算密集型的业务中,CPU 的提升更加明显。

相关生态

随着 Volo 框架开源,一起开源的所有生态如下:

  • Volo 是 RPC 框架的名字,包含了 Volo-Thrift 和 Volo-gRPC 两部分。
  • Volo-rs 组织 :Volo 的相关生态。
  • Pilota :Volo 使用的 Thrift 与 Protobuf 编译器及编解码的纯 Rust 实现(不依赖 protoc)。
  • Motore :Volo 参考 Tower 设计的,使用了 GAT 和 TAIT 的 middleware 抽象层。
  • Metainfo :Volo 用于进行元信息透传的组件,定义了一套元信息透传的标准。

全景图如下:

image

仓库地址

以下是所有相关生态的仓库地址。欢迎大家来提 Issue 或 PR,一起共建 Volo

  • Volo:https://github.com/cloudwego/volo
  • Volo-rs:https://github.com/volo-rs
  • Pilota:https://github.com/cloudwego/pilota
  • Motore:https://github.com/cloudwego/motore
  • Metainfo:https://github.com/cloudwego/metainfo

Rust 语言和 Go 语言如何选择

了解 Volo 框架后,关于 Rust 语言和 Go 如何选择的问题,我有一些主观的建议和想法。

和 C++、Go 对比

如果 Go 的服务想用另一种语言重写,目前还是 Rust 语言和 C++ 可选性高一些,因此我将这三种语言进行对比,以期为面临选择编程语言的用户提供一些参考。

image

在学习难度方面,Rust 语言和 C++ 学习难度比较高,而 Go 语言的学习难度比较低。

在性能方面,Rust 语言和 C++ 的性能比较高。我给 Go 语言的性能评级为中等,毕竟和 Python 这些服务相比,Go 语言还是要强很多的。

在安全性方面,C++ 的安全性比较低,Go 语言安全性中等,Rust 语言安全性比较高。因为 Go 语言 虽然能够通过 GC 防住一些内存安全的问题,但是它没有办法防住类似 Data Race 这种并发安全的问题, 而且大多数时候这类问题其实很难排查。Rust 能够做到可防可控,应防尽防,只要有内存安全问题或并发安全问题,都无法成功编译。

在协作方面,Rust 语言的协作能力比较高,Go 语言和 C++ 的协作等级是中等。首先,C++ 没有官方提供的包管理工具,它必须借助第三方社区提供的包管理工具,但是不同的项目使用的包管理工具可能是不一样的, 所以这是对用户来说非常不便的;其次,在开发者可以保证自己的代码没有 Bug、符合最佳实践的情况下,还是不可避免地会和一些第三方的库以及比较老旧社区一流的库产生交集,并且产生混用的情形; 最后,如果涉及到大型项目,需要团队协作开发,我们无法保证团队中其他人写出的代码也不存在内存安全问题。至于 Go 语言,它的编译时及工具链的能力相对来说比较弱,因此也定级为中等。

在特性和使用成本方面,用户应该都有所了解,不再过多赘述。从使用成本上来讲,我的评级为给 C++ 为高使用成本,Go 语言和 Rust 语言的使用成本是中等。C++ 的业务上线之后经常出状况, 而且排查问题困难是很常见的情况。而使用 Go 语言做一些通用的编程是可以的,但是一旦涉及到定制化的需求在实现上就有一定的困难,比如需要根据不同的平台系统做系统级编程, 使用 Go 语言做起来就非常麻烦。语言只是工具,我们还是要根据不同的场景选用更为合适的语言。

那么 Go 语言和 Rust 语言的使用成本为什么是中等呢?因为我们不能只关注编写代码的效率,还要考虑运维和 Debug 的成本。Go 语言可能也会产生 Panic,我们内部也经常会有一些并发的问题,然后需要不断地排查。 而 Rust 语言前置了这部分成本,相比于其他语言框架在上线之后测试、保证稳定性,我们把这部分的时间精力用在了开发期间,这样也避免了线上事故带来的损失。因此我给 Go 语言和 Rust 语言评定的使用成本是中等。

Rust & Go

如果将 Rust 语言和 Go 语言单独做对比,我们应该如何解读它们呢?这是一个非常经典的问题。可以尝试从以下四方面考虑:

  • 合作关系,取长补短

我们团队认为其实二者并不是对立关系,而是合作关系,它们是取长补短的。毕竟语言只是工具,很多时候我们只是需要一个更加得心应手的工具而已。

  • (性能 » 开发效率) || (安全性 » 开发效率) -> Rust

对于需要极致性能,重计算的应用,以及需要稳定性并能接受一定开发速度损失的应用,推荐使用 Rust,Rust 在极致性能优化和安全性上的优势可以在这类应用中得以发挥。

  • 迭代速度要求高 -> Go

对于性能不敏感的应用、重 IO 的应用以及需要快速开发快速迭代胜过稳定性的应用,推荐使用 Go 语言,这种应用使用 Rust 并不会带来明显的收益。

  • 考虑团队技术储备和人才储备

当然,还有一个很重要的考虑因素,是团队现有的技术栈,即技术储备和人才储备。

小结

希望以上内容能让大家初步了解 Volo 以及相关的生态。目前 Volo 还处于早期发展阶段,欢迎各位感兴趣的同学加入我们,共同建设 CloudWeGo 以及 Rust 开源社区。 我们诚心期待更多开发者加入,也期待 Volo 能够助力越来越多的企业快速构建云原生架构。