Rust优势与FFI
出于工作量和可行性考虑,我们不准备使用 Rust 完全重写 Spark ,而是准备选取 Spark 的性能瓶颈且Rust可以为之带来较大优化的部分,使用Rust改写,以达到优化Spark性能的目的。 为此,我们需要考虑Rust在哪些方面比Scala(Spark所使用的语言)有较大的优势,并在这些方面,寻找可能的切入点。
Rust在Spark背景下相较于Scala的优势
安全性 scala 所有的对象都是在堆中的,有 Head 的,生命周期由 GC 管控的。虽然有不用关心分配、释放的自由。却也导致了 STW 和更大的内存占用。 Rust 通过静态内存安全管理和所有权系统,可以避免许多 Spark 运行时错误,例如内存泄漏、垂悬指针异常等。而与Scala相比,Rust的内存管理发生在编译期,其所有权和声明周期的计算与检查都在编译期执行,这使得它无需消耗较大性能的GC机制,就能保证内存安全。 此外,Rust将运行时错误划分为两类,通过模式匹配的控制方式,在面对可恢复的错误时执行对应的错误处理代码,而面对不可恢复的错误时发生panic停止程序,既进一步保证了安全,又提高了用户的体验。 在Spark的内存密集阶段,可以使用Rust改写,以减少内存占用、提高程序性能。 高性能 Rust 秉承零成本抽象原则,通过无运行时开销的特性,将许多其他语言的运行时开销(如GC)放置到了编译期,并将顶层的代码编译为较为高效的机器码,使得程序员在进行抽象时,不必担心性能的下降。 使用 Rust 进行 Spark 的性能瓶颈优化可以提高数据处理速度和效率,减少资源浪费和计算成本。 并发性 Spark 是一个分布式计算框架,具有良好的并发性能。而 Rust 则通过所有权和类型系统,将许多并发错误转化为了编译时错误,从而避免在部署到生产环境后修复代码或出现竞争、死锁或其他难以复现和修复的 bug ,实现了高效而安全的并发设计。 安全高效的并行与函数式编程息息相关。Scala正是由于其函数式编程的特性被Spark选中,而同样作为函数式的语言,Rust对并行的支持更好。使用 Rust 对 Spark 的高并发场景进行优化,可以进一步提高 Spark 的并发性能和安全性,从而提高整个应用程序的性能。 编程实践 为了深入体会Rust的编程风格和相较于其他语言的优势,笔者选用Rust语言来实现OS Lab2 的编写Shell的项目,对Rust的优点和风格有了一定感触。 Rust为了获取安全性和高性能,对程序员施加了较多的规则,在编译期进行了较为严格的检查(内存安全正),使得编程难度显著提高。但是如果熟悉了它的编程风格,就可以轻松写出安全而高效的代码。此外,用Rust编写的代码,只要能够通过编译,基本就可以正常运行,且在调试代码时,可以分模块测试而不用担心它们的互相影响————这提高了调试代码的效率,而且适于多人协作开发(在函数式编程方式下尤是如此)。 项目地址:https://github.com/XhyDds/osh-2023-labs/tree/master/lab2
Rust与Scala的交互
可行性
Scala是在JVM上运行的语言,和Java比较相似。与其他语言交互时,主要有JNI(Java Native Interface), JNA(Java Native Access), OpenJDK project Panama三种方式。其中最常用的即为JNI接口。 Rust则通过二进制接口的方式与其他语言进行交互,调用包含其它函数接口的二进制库,或生成二进制库供其他语言使用。 在我们的项目中,由于希望保留scala的外部接口,所以选择scala为client语言,用Rust改进spark的一些功能,并将接口提供给scala语言下的spark框架使用。
技术依据
Scala可以与Java代码无缝衔接,而Java可以与C通过JNI来交互,所以可以通过Rust的extern语法,按照C的方式调用JNI,并完成Scala和Rust的各类交互。 然而,正如我们通常不会直接在Rust中通过二进制接口调用C的标准库函数,而是使用libc crate一样,直接使用JNI对C的接口会使得编程较为繁琐且不够安全,代码中的大量unsafe块使得程序稳定性大大下降,所以,我们选择对JNI进行了安全的封装的接口:jni^1 crate。
Rust调用Scala
数据交互
两种语言在进行交互时,必须使用两边共有的数据类型。
对于基础的参数类型,可以直接用jni::sys::*
模块提供的系列类型来声明,对照表如下:
|Scala 类型 |Native 类型 |类型描述|
|---|---|---|
|boolean |jboolean |unsigned 8 bits|
|byte |jbyte |signed 8 bits|
|char |jchar |unsigned 16 bits|
|short |jshort |signed 16 bits|
|int |jint |signed 32 bits|
|long |jlong |signed 64 bits|
|float |jfloat |32 bits|
|double |jdouble |64 bits|
|void |void |not applicable|
对于复合类型,如对象等,则可以统一用jni::objects::JObject
类型声明。该类型封装了由JVM返回的对象指针,并为该指针赋予了生命周期,以保证在Rust代码中的安全性。
方法交互
由于语言间对对象及其特性的实现不同,很难直接调用对方语言中的函数或方法。于是通常需要使用server-client模型,将执行函数或方法的任务交给sever语言,即:client传递所需的数据参数,并由server执行计算任务,并将最终结果返回给client。
基于这种模型的设计,jni提供了调用scala中函数、对象方法以及获取对象数据域的方法。它们定义于jni::JNIEnv
中,如接受对象、方法名和方法的签名与参数的jni::JNIEnv::call_method
,接受对象、成员名、类型的jni::JNIEnv::get_field
等
此外,jni额外实现了一个jni::objects::JString
接口,用以方便地实现字符串的传输。
Scala调用Rust
Rust可以通过pub unsafe extern "C" fn{}
来创建导出函数,或通过jni封装的函数JNIEnv::register_native_methods
动态注册native方法。
导出函数会通过函数名为Scala的对应类提供一个native的静态方法。
动态注册会在JNI_Onload这个导出函数里执行,jvm加载jni动态库时会执行这个函数,从而加载注册的函数。
在Rust中定义这些函数时,同样需要遵循上面的那些交互方法和规范。