【Rust 新手小册】Day 2. 选择 Go 还是选择 Rust ?
本期内容介绍:
1. Rust 和 C++、Go 的对比
2. Go 与 Rust 在一些重要语言特性上应该如何取舍
3. 在选择上的一点小建议
01 Rust 和 C++、Go 的对比
如果 Go 的服务想用另一种语言重写,目前还是 Rust 语言和 C++ 可选性高一些,因此我将这三种语言进行对比,以期为面临选择编程语言的用户提供一些参考。
在学习难度方面,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 语言评定的使用成本是中等。
02 Go 与 Rust 在一些重要语言特性上应该如何取舍
Box VS 逃逸分析
在刚刚给出的两个版本的 helloworld 实现中,其实 Go 与 Rust 的实现并不等价。因为在 Rust 中 println 是一个宏,在宏展开的过程中,它支持了编译时对打印信息的格式化。由于 Go 并不支持宏,所以我们就尝试进行改写,即基于 Go 标准库的 fmt 库做一个简单的运行时的格式化,再观察编译以及运行时的过程。下图是简单的可格式化版本的 helloworld,打开 Go 编译器的逃逸分析选项,我们会发现即使是一个简单的 print fmt 过程,也会导致 world 字符串逃逸并且被分配在堆上。
逃逸分析是指在做实例化的过程中,每一个实例并不需要自己指定分配在栈上还是堆上,而是通过编译器的逃逸分析来确认。当编译器认为这个对象会从栈上逃逸时,它就会把这个对象分配在堆上。在 Go 基础库的开发和系统调优中,我经常打开这个编译选项,然后观察编译器的逃逸分析结果。因为堆内存分配的频率对执行效率的影响非常大,内存分配的开销远远大于在栈上进行分配的开销。
所以在性能调优中,我会一直观察所有的堆内存分配合适吗?是必要的吗?是否存在很多不必要的堆内存分配情况呢?其实从我们的上述例子来看,情况非常不乐观,因为即使是一个非常简单的 print fmt 也有可能意外地导致对象分配位置发生转移。这对于性能调优是非常不利的,因为我们在写的过程中很难发现这样的细节,难以保证所有的堆内存分配都是合理的。
第一部分我们提到 Go 语言官网使用的其中一个关键词是快速地构建开发(Build fast),我觉得在这个例子中我们稍加分析就能确认,或者使用 Rust 版本的例子也能发现,world 字符串是完全不必在堆上进行分配的。那么为什么 Go 的逃逸分析没有做更加准确的分析呢?我觉得可能主要的原因在于 Go 牺牲了一些逃逸分析的精确性,以换取更快的构建速度。
如果大家追踪 print fmt 里的具体实现可以发现,其实标准库的 print fmt 函数的复杂度蛮高的,这个函数的调用栈也非常深。在这种情况下进行精确的逃逸分析可能会导致 Go 编译器的编译速度明显下降。所以我认为 Go 对过于这个复杂的函数没有进行非常精确逃逸分析,导致 world 字符串在堆上进行分配。
那么 Rust 的内存如何分配呢?我们或多或少有了解到 Rust 的分配是相对静态的,是不依赖逃逸分析的。我们在 Rust 的例子中尝试把 Rust 的版本变更到和 Go 的行为一致的情况。当然由于在 Rust 标准库中并没有运行时的格式化,所以我这里只是简单地用 println 宏做模拟。如下图所示,在 Rust 中,我们使用了一个 Box 类型描述字符串,Rust 有一个特点是 Rust 类型化,即要求用户显示地声明内存分配的方式。同时基于 Box 类型化,把堆内存的一些操作以及用户需要的一些行为都绑定在了 Box 类型的方法上,然后通过这种方式让开发者非常清楚地感知自己程序的开销。
如果需要对内存进行分配,需要显示地用 Box 类型封装实例,我觉得这一点对于需要对自己的程序进行比较深入地技能调优类的开发者来讲非常友好。这样在写的过程中我可以对必须要做队列分配的部分使用 Box 类型,如果我没有使用 Box 类型,意味着第三方库的提供者认为我不需要感知队列分配,或者我完全没有队列在分配。
这又是一次 Performance VS Build fast 的选择。Go 使用一种对开发更方便,同时编译效率也非常高的方式,但 Rust 通过一种类型化显示指定的方式让用户更清楚地感知性能开销,同时它也会带来一定的心智负担,因为程序员需要感知内存分配方式。
所有权 + 生命周期 VS 垃圾回收
与内存分配同样重要的问题是,内存内存如何进行回收。Rust 与 Go 的内存回收机制也有很大的差异。Go 是基于标记星图的垃圾回收机制做内存回收。但是 Rust 可以不依赖垃圾回收,也能保证内存安全不依赖垃圾回收。
当然,也有许多其他的基础语言宣称自己不依赖垃圾回收,但是同时也能保证内存安全的语言相对而言比较罕见。Rust 除了能够保证最基本的内存安全以外,它甚至能进一步保证在已经声明安全的代码中不存在数据竞争,这也是 Rust 对于并发编程非常重要的一个特性。为了解决不依赖垃圾回收,但是同时能保证内存安全这个问题,Rust 提出了两个基本的概念:一个是所有权,另一个是生命周期。
其实有一种我们早已熟知的古老的内存回收方式,而且每一种语言都在使用它,那就是栈的分配和回收。如果我们不考虑堆内存,栈上分配的变量就一定会在这个栈被弹出的时候回收,同时基于栈的分配和回收是不存在额外的运行时开销的。
Rust 的所有权也是基于这一点展开的,只是它更进一步,即我们需要同时考虑堆和 栈 上对象的回收时机。在 Rust 中,每个对象都绑定其所在栈,当然这个栈不一定等同于程序的栈,因为我们可以显示地让这个 Rust 对象以及引用提前回收,这样的栈在 Rust 中被称为所有权。同时它的对象以及变量可以被移动,在被移动的同时也意味着转移了所有权。这里我们只考虑了这个对象本身,那么如果我们再考虑对象引用呢?
Rust 提出了以生命周期的方式来解决引用的合法性及其时机问题。引用计数也是一种被很多语言使用的内存回收的方式,它也存在额外的运行时开销。 Rust 进一步把引用计数的思路用在编译器中,它要求引用总是要在被引用对象的所有权释放之前结束,通过这种方式精确地保证所有对象在生命周期内的引用一定是合法的。这也是 Rust 与 Go 有所差异的地方。
Go 很少做 GC 的调优,但这是字节跳动内部比较关注的问题。因为即使 Go 的垃圾回收算法相对简洁,但是依然有整个程序停顿的问题,并且即使再简洁也是有额外开销的。而在 Rust 中,我们通过编译器来解决内存适配回收的问题,不会产生任何额外的运行时开销,我觉得这也是 Rust Performance 的体现。
我们再简单地观察一下带有显示指定生命周期参数以及有生命周期约束的 Rust 函数,可以发现在 Rust 中指定生命周期参数、约束生命周期在语法上的位置与 Rust 泛型的类型参数位置是相同且一一对应的。我在实际使用中也能感受到生命周期其实就是 Rust 类型系统的一部分,所有的对象都是某个匿名生命周期类型的实例,并且这些生命周期类型由编译器自动地管理。尽管我们很多时候可以省略对大部分对象的生命周期约束,但是生命周期类型总是无处不在的。
Trait VS Interface
我们从生命周期的类型系统回到更加传统的类型上,Rust 与 Go 都同样使用了组合而不是继承的方式来表达类型之间的关系。这个对于 Go 开发者来讲是非常熟悉的,因为我们在 Go 中同样使用 Interface 来表达类型之间的关系。但实际上二者还是有很多不同的。
对我自己而言,最主要的不同点在于 Rust 的 Trait 需要显式声明。
这个好处在于不需要声明类型实现某个 Trait,而是它只要实现了所有 Interface 的方法,编译器就认为它实现了这个 Interface。但是除了这一点,还有没有更深层次的变化呢?显式指定除了变得更加麻烦以外,带来了哪些好处呢?
答案是肯定的。在 Rust 中有一种标记类型,它没有实际运行时的功能,它的作用只是告诉编译器在编译的时候如何帮助开发者检查是否正确地处理以及使用了某种类型。这在 Go 的隐式声明中是没办法实现的。
刚刚提到的很多例子,包括 Box 类型以及生命周期的类型系统,都体现了 Rust 拥有一个非常强大的类型系统,并且充分利用了类型系统帮助我们写出更正确的软件。我在写 Rust 程序的时候相对比较放心,只要程序编译过了就能确保这个实现是没有太大问题的。而且我也能很清楚地感知到我的类型系统提供给我的信息,包括是否并发安全、是否需要处理、是否需要考虑数据竞争、是否需要感知以及是否它是否需要被分配在堆上。
Stackless VS Stackful
还有一个值得讨论的问题是,Go 宣称自己具备并发与规模伸缩伸缩上的友好性,那么在并发编程上, Rust 和 Go 有哪些不一样的设计呢?Rust 并发生态的发展历程是一个非常复杂的问题,因此我们只从宏观角度做对比。
并发编程涉及到一个在计算机历史上非常古老的问题,即如何让程序从执行中“暂停”并“恢复”? 关于这个问题有两个不同的解答思路。
- 函数式编程的思路。如果能将暂停时的所有状态在暂停前作为函数的值返回,并且在恢复时作为参数传入下一个需要被执行的函数,就可以解决这个暂停与恢复的问题。其实暂停往往出现在函数内部,它不一定出现在一个函数调用结束以及下一个函数调用开始的这个时机,所以函数式编程找到了一种方法,在任何情况下都能将一个在函数内部暂停的状态重新编译为多个不同的函数,并且把这个状态暴露为参数,在这些函数之间传递。
- 从程序实际执行的方式触发。如果我们保存程序当前所有的寄存器数据以及内存数据,那么我们就可以直接从寄存器和内存数据中恢复执行,对于操作系统的进程与线程也是使用这种方式。
Rust 和 Go 分别使用了这两种不同的方式。Rust 始终采取第一种方式,我们通常把它叫做无 栈 协程,因为它不需要额外的栈保存协程状态,只需函数自身的栈就可以实现这一点。Go 使用的是有栈协程,因为 Goroutine 需要额外的栈保存。Goroutine 当前的堆内存情况,以及 Goroutine 的栈到达临界值之后,我们需要如何处理 Goroutine 的栈扩容,这些都是需要付出额外的开销及空间的。它的优势在于付出了一定的运行时成本,因此使用起来更方便。
相信 Go 开发者都有深刻的体验,我们只需要用同步的方式编写代码,之后用 Go 关键字来调用它,它就可以自动做并发执行。而对于各类基于无栈协程的语言,比如 Rust,我们通常需要显式指定所有从这里中断并且恢复的点,相对而言比较麻烦。但是由于它不需要使用额外的栈,因此它可以更好地嵌入到某些阻塞的非并发的程序中,理论上它的性能也更好。而 Go 调用其他阻塞的代码是比较麻烦的。
我们在前面内容中提到了很多就是 Rust 与 Go 在特性以及风格上的不同之处。但其实这也只是 Rust 所有特性中非常小的一部分,而且我也只是基于最基本的例子进行了介绍。基于这些例子以及在我学习 Rust 过程中的体会,我认为 Rust 是性能调优之友,也是框架开发者之友。
为什么这么讲呢?因为 Rust 非常鼓励开发者感知程序中的性能开销以及额外开销,这对于做性能调优是非常有帮助的。此外 Rust 有一个非常强大的类型系统,可以更好地描述框架约束,这个对于框架的开发者以及框架提供者而言非常友好。因为框架本身是对一类逻辑的抽象,而高级的类型系统本身也是对于类型的一种抽象,那么如果我们能使用好类型的抽象系统,或者如果它本身提供了非常丰富的高级类型抽象的方式,我们就能定义出更严谨的框架以及中间件类型的约束,同时也可以帮助框架使用者更正确地使用框架。
03 在选择上的一点小建议
如果将 Rust 语言和 Go 语言单独做对比,我们应该如何解读它们呢?这是一个非常经典的问题。可以尝试从以下四方面考虑:
-
合作关系,取长补短 我们团队认为其实二者并不是对立关系,而是合作关系,它们是取长补短的。毕竟语言只是工具,很多时候我们只是需要一个更加得心应手的工具而已。
-
(性能 >> 开发效率) || (安全性 >> 开发效率) -> Rust 对于需要极致性能,重计算的应用,以及需要稳定性并能接受一定开发速度损失的应用,推荐使用 Rust,Rust 在极致性能优化和安全性上的优势可以在这类应用中得以发挥。
-
迭代速度要求高 -> Go 对于性能不敏感的应用、重 IO 的应用以及需要快速开发快速迭代胜过稳定性的应用,推荐使用 Go 语言,这种应用使用 Rust 并不会带来明显的收益。
-
考虑团队技术储备和人才储备 当然,还有一个很重要的考虑因素,是团队现有的技术栈,即技术储备和人才储备
项目地址
GitHub:github.com/cloudwego
转载自:https://juejin.cn/post/7217644586867376188